diff --git a/docs/anleitung/bilder/rotation-desktop.png b/docs/anleitung/bilder/rotation-desktop.png new file mode 100644 index 0000000..4b3c4bd Binary files /dev/null and b/docs/anleitung/bilder/rotation-desktop.png differ diff --git a/docs/anleitung/bilder/widerruf-desktop.png b/docs/anleitung/bilder/widerruf-desktop.png new file mode 100644 index 0000000..5b83932 Binary files /dev/null and b/docs/anleitung/bilder/widerruf-desktop.png differ diff --git a/docs/anleitung/bilder/widerruf-dry-run-desktop.png b/docs/anleitung/bilder/widerruf-dry-run-desktop.png new file mode 100644 index 0000000..0f13e66 Binary files /dev/null and b/docs/anleitung/bilder/widerruf-dry-run-desktop.png differ diff --git a/docs/issues/023-rotation.md b/docs/issues/023-rotation.md index 6abca80..fb1b7e5 100644 --- a/docs/issues/023-rotation.md +++ b/docs/issues/023-rotation.md @@ -1,19 +1,46 @@ # #023 Geführte Rotation -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.4.0 **Labels:** backend, ui -**Geschätzt:** 3 h **Tatsächlich:** – +**Geschätzt:** 3 h **Tatsächlich:** 0 h 20 min ## Ziel Neuer Schlüssel an allen Verwendungen, Test, alter entfernt. ## Aufgaben -- [ ] wird zu Beginn der Phase verfeinert +- [x] „Rotieren“ auf der Schlüsselseite, Schrittfolge `/schluessel/[id]/rotieren` (wiederaufnehmbar) +- [x] Neuer Schlüssel gleichen Typs, Name „… (rotiert TT.MM.JJJJ)“, `vorgaengerId` = alter +- [x] Zuweisungen übernehmen: Public gleiche Ziele/Optionen, Private gleicher Host/Benutzer/Alias mit neuem Pfad +- [x] Dry-Run + Ausrollen, Verbindungstests, erst dann den alten widerrufen (#024) → `ROTIERT` +- [x] Personen-Key ohne Private Key: nur Hinweis „neuer Public Key nötig“ +- [x] Rotationskette auf der Schlüsselseite (Vorgänger/Nachfolger verlinkt) ## Akzeptanzkriterien -- [ ] wird zu Beginn der Phase verfeinert +- [x] DB-Tests: Schritte, Reihenfolge, Rechte, Namen/Pfade (`tests/rotation.test.ts`) +- [x] Integrationstest: neuer Schlüssel funktioniert, alter von beiden Hosts weg, `ssh ` geht weiter +- [x] E2E im Browser (`tests/e2e/rotation.spec.ts`), Screenshot `rotation-desktop.png` ## Abhängigkeiten -#020 +#020, #024 ## Notizen / Entscheidungen +- **Fünf Schritte:** Neuer Schlüssel → Zuweisungen übernehmen → Dry-Run & Ausrollen → Verbindungstests → + Alten widerrufen. Der aktuelle Schritt wird aus der Datenbank errechnet (`rotationsStand`), deshalb + lässt sich die Rotation jederzeit neu laden und fortsetzen; jeder Schritt ist idempotent. +- **Kette sofort:** Der Worker setzt `vorgaengerId` schon beim Erzeugen (neues Feld in der + Vorgangs-Eingabe) – auch ein abgebrochener Browser hinterlässt keinen verwaisten Schlüssel. + `vorgaengerId` ist eindeutig: zwei gleichzeitige Rotationen scheitern verständlich. +- **Gleicher Alias, zwei Zuweisungen:** `privat.ts` schreibt je Alias genau einen config-Eintrag, die + jüngste Zuweisung gewinnt (an der Position der ersten). So zeigt `ssh ` nach dem Ausrollen auf + die neue Datei, während die alte bis zum Widerruf daneben liegt. +- **Neuer Pfad:** `-rJJJJMMTT`, bei Kollision mit laufender Nummer – + auch entfernte Zuweisungen belegen einen Pfad (eindeutiger Index über alle Zustände). +- **Grün heißt aktuell:** Der alte Schlüssel wird nur widerrufen, wenn für jede Private-Zuweisung mit + Alias ein erfolgreicher `VERBINDUNGSTEST` nach dem letzten Ausrollen vorliegt (Server prüft das). + Ohne Private-Zuweisung mit Alias entfällt der Test. +- **Abschluss:** Widerruf aus #024 ohne Zielkonten (der Nachfolger nutzt sie weiter); ein aktiver + Nachfolger macht aus `WIDERRUFEN` den Status `ROTIERT`. +- Ablaufdatum: Der Nachfolger erbt es nur, wenn es in der Zukunft liegt. Eine Passphrase wird nicht + übernommen (sshkv erzeugt ohne). +- Rechte: `schluesselVerwalten` + `publicKeyAusrollen`, bei Private-Zuweisungen `privateKeyAusrollen`. + Deployment-Key: eigene Rotation (später). diff --git a/docs/issues/024-widerruf.md b/docs/issues/024-widerruf.md index be624da..efdf78f 100644 --- a/docs/issues/024-widerruf.md +++ b/docs/issues/024-widerruf.md @@ -1,19 +1,49 @@ # #024 Widerruf und Massenwiderruf -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.4.0 **Labels:** backend, ui, sicherheit -**Geschätzt:** 3 h **Tatsächlich:** – +**Geschätzt:** 3 h **Tatsächlich:** 0 h 25 min ## Ziel Ein-Klick-Widerruf inkl. Benutzer/sudoers; Massenwiderruf mit Re-Auth. ## Aufgaben -- [ ] wird zu Beginn der Phase verfeinert +- [x] „Widerrufen“ auf der Schlüsselseite mit Dialog, der jede Entfernung auflistet +- [x] Public- und Private-Verwendungen → ENTFERNEN, Dry-Run, anwenden, erst dann `WIDERRUFEN` +- [x] Zielkonten optional entfernen (nur `vonSshkvAngelegt`, nie ein noch genutztes) +- [x] Massenwiderruf auf der Schlüsselliste, ein gemeinsamer Dry-Run, Protokoll je Schlüssel +- [x] Abgelaufene Schlüssel erkennbar (Filter) und Widerruf anbieten – ohne automatisches Entfernen ## Akzeptanzkriterien -- [ ] wird zu Beginn der Phase verfeinert +- [x] DB-Tests: Zustände, Rechte, Zielkonten, Protokoll ohne Geheimnisse (`tests/widerruf.test.ts`) +- [x] Integrationstest gegen zwei Hosts inkl. „Host nicht erreichbar“ (`tests/integration/widerruf.test.ts`) +- [x] E2E im Browser (`tests/e2e/widerruf.spec.ts`), Screenshot `widerruf-desktop.png` ## Abhängigkeiten #018 ## Notizen / Entscheidungen +- **Zwischenzustand statt Sofort-Status:** neues Feld `Schluessel.widerrufEingeleitetAm` (Migration + `20261012130000_widerruf_eingeleitet`). Der Status bleibt AKTIV/ABGELAUFEN, bis keine Verwendung mehr + offen ist; erst `widerrufAbschliessen` setzt `WIDERRUFEN` (bzw. `ROTIERT`, wenn ein aktiver Nachfolger + existiert, #023). Schlüsselseite und Liste zeigen „Widerruf unvollständig“ und die offenen Ziele. +- **Reihenfolge:** vormerken → Dry-Run/Anwenden (bestehende Hash-Sperre aus #016/#017) → optional + Zielkonten (`ZIELKONTO_ENTFERNEN`, erst wenn die Schlüssel überall weg sind) → abschließen. + Ein Ziel, das im Dry-Run scheitert (nicht erreichbar), wird nicht angewendet; seine Verwendung bleibt + ENTFERNEN, ein neuer Dry-Run versucht es erneut. Der Abschluss läuft nach erfolgreichem Anwenden + automatisch und meldet, was noch fehlt. +- **Nichts zu schreiben:** Steht der Schlüssel schon nirgends mehr, bietet das Dry-Run-Panel + „Zustand übernehmen“ an (sonst bliebe der Widerruf hängen). +- **Archivierte Hosts:** sshkv verbindet sich dort nicht mehr – deren Verwendungen gelten sofort als + entfernt; der Dialog sagt das, das Protokoll nennt die Hosts. +- **Zielkonten:** Ein Zielkonto kennt seinen Schlüssel nicht; Kandidat ist ein von sshkv angelegtes Konto + auf (Host, Benutzer) einer Zuweisung der widerrufenen Schlüssel. Nutzt ein anderer, nicht im Widerruf + befindlicher Schlüssel das Konto noch, bleibt es (Dialog nennt den Schlüssel). +- **Rechte:** nur Public Keys → `publicKeyAusrollen`; Private Keys → `privateKeyAusrollen` (Admin); + mehrere Schlüssel → `massenwiderruf`, Zielkonten → `zielkontenVerwalten` (jeweils Admin mit frischer + Bestätigung). Der Deployment-Key ist nicht widerrufbar. Ein Schlüssel im Widerruf lässt sich nicht + mehr zuweisen. +- **Ablauf:** Status-Filter „abgelaufen“ umfasst aktive Schlüssel mit vergangenem Ablaufdatum; die + Schlüsselseite bietet dann „Jetzt widerrufen“. Entfernt wird nie automatisch. +- Massenwiderruf als GET-Formular `/schluessel/widerruf?id=…` – die Auswahl der Zielkonten reist als + `?zielkonten=` mit und übersteht Neuladen und erneute Bestätigung. diff --git a/prisma/migrations/20261012130000_widerruf_eingeleitet/migration.sql b/prisma/migrations/20261012130000_widerruf_eingeleitet/migration.sql new file mode 100644 index 0000000..ae9ff29 --- /dev/null +++ b/prisma/migrations/20261012130000_widerruf_eingeleitet/migration.sql @@ -0,0 +1,3 @@ +-- #024: Zwischenzustand „Widerruf eingeleitet“. Der Schluessel bleibt AKTIV (bzw. ABGELAUFEN), +-- bis alle Verwendungen nachweislich entfernt sind; erst dann wird er WIDERRUFEN bzw. ROTIERT. +ALTER TABLE "Schluessel" ADD COLUMN "widerrufEingeleitetAm" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f372c12..0e1e57b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -274,44 +274,47 @@ model HostSchluessel { // ---------------------------------------------------------------- Schluessel model Schluessel { - id String @id @default(cuid()) + id String @id @default(cuid()) /// Zweck, z. B. „Backup srv-backup01 → srv-pve01“. - name String + name String /// Fuer Dateinamen und Kommentare: backup-srv-backup01-srv-pve01. - slug String @unique - typ SchluesselTyp - kategorie SchluesselKategorie - status SchluesselStatus @default(AKTIV) + slug String @unique + typ SchluesselTyp + kategorie SchluesselKategorie + status SchluesselStatus @default(AKTIV) /// OpenSSH-Zeile ohne Optionen: "ssh-ed25519 AAAA… kommentar". - publicKey String - fingerprint String @unique - kommentar String + publicKey String + fingerprint String @unique + kommentar String /// Envelope-Chiffrat des Private Keys. Liest ausschliesslich src/worker/geheimnisse.ts. - privatVerschluesselt Bytes? + privatVerschluesselt Bytes? /// Data-Key, verschluesselt mit dem Master-Key. - dekVerschluesselt Bytes? - masterKeyVersion Int? - privatGeloeschtAm DateTime? - passphraseGeschuetzt Boolean @default(false) + dekVerschluesselt Bytes? + masterKeyVersion Int? + privatGeloeschtAm DateTime? + passphraseGeschuetzt Boolean @default(false) /// Genau ein Deployment-Key ist Instanz-Standard (partieller Index). - instanzStandard Boolean @default(false) - personId String? - person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) - vorlageId String? - vorlage Vorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull) - tags String[] - ablaufAm DateTime? - zuletztVerwendetAm DateTime? + instanzStandard Boolean @default(false) + personId String? + person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) + vorlageId String? + vorlage Vorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull) + tags String[] + ablaufAm DateTime? + zuletztVerwendetAm DateTime? + /// 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 - vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull) - nachfolger Schluessel? @relation("Rotation") - erstelltVonId String? - erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull) + vorgaengerId String? @unique + vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull) + nachfolger 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). - selbstHinterlegt Boolean @default(false) - angelegtAm DateTime @default(now()) - geaendertAm DateTime @updatedAt + selbstHinterlegt Boolean @default(false) + angelegtAm DateTime @default(now()) + geaendertAm DateTime @updatedAt publicVerwendungen PublicVerwendung[] privateVerwendungen PrivateVerwendung[] diff --git a/src/app/(app)/schluessel/[id]/page.tsx b/src/app/(app)/schluessel/[id]/page.tsx index 02c98cc..836e6f7 100644 --- a/src/app/(app)/schluessel/[id]/page.tsx +++ b/src/app/(app)/schluessel/[id]/page.tsx @@ -1,4 +1,4 @@ -import { Download, Lock, LockOpen, Pencil } from "lucide-react"; +import { Download, Lock, LockOpen, Pencil, RefreshCw } from "lucide-react"; import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; @@ -15,10 +15,15 @@ import { Statuspunkt, type HostStatus } from "@/components/ui/Statuspunkt"; import { datum, datumZeit } from "@/lib/format"; import { darf } from "@/server/auth/rechte"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; -import { schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel"; +import { rotationsKette, schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel"; import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel"; import { EintragAktionen } from "@/components/ausrollen/EintragAktionen"; import { zielAuswahl } from "@/server/queries/ziele"; +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 { PrivateAusrollenAktion } from "./PrivateAusrollenAktion"; import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen"; import { PublicAusrollenAktion } from "./PublicAusrollenAktion"; @@ -187,18 +192,61 @@ function PrivatStatus({ s, anzeigen }: { s: SchluesselDetail; anzeigen?: ReactNo } /** Schlüsseldetail (#012) nach dem Prototyp aus #002; Ausrollen folgt mit #016/#017. */ -export default async function SchluesselSeite({ params }: { params: Promise<{ id: string }> }) { +export default async function SchluesselSeite({ + params, + searchParams, +}: { + params: Promise<{ id: string }>; + searchParams: Promise<{ zielkonten?: string | string[] }>; +}) { const { id } = await params; const { benutzer } = await sitzungErforderlich("lesen", `/schluessel/${id}`); const s = await schluesselDetail(id); if (!s) notFound(); + const zielkontenParameter = (await searchParams).zielkonten; - const aktiv = s.status === "AKTIV"; + // Im Widerruf (#024) bleibt der Status AKTIV, bis alles entfernt ist – zuweisen geht aber nicht mehr. + const imWiderruf = Boolean(s.widerrufEingeleitetAm) && (s.status === "AKTIV" || s.status === "ABGELAUFEN"); + const aktiv = s.status === "AKTIV" && !imWiderruf; const rechte: Rechte = { public: darf(benutzer.rolle, "publicKeyAusrollen"), privat: darf(benutzer.rolle, "privateKeyAusrollen"), testen: darf(benutzer.rolle, "hostsVerwalten"), }; + const jetzt = new Date(); + const abgelaufen = s.status === "ABGELAUFEN" || ablaufZustand(s.ablaufAm, jetzt).art === "abgelaufen"; + const verwaltenRecht = darf(benutzer.rolle, "schluesselVerwalten"); + const widerrufbar = + !imWiderruf && + !(s.nachfolger?.status === "AKTIV") && + s.kategorie !== "DEPLOYMENT" && + (s.status === "AKTIV" || s.status === "ABGELAUFEN") && + rechte.public && + (s.privateVerwendungen.length === 0 || rechte.privat); + const widerruf = widerrufbar || imWiderruf ? await widerrufUebersicht([s.id]) : undefined; + // Rotation (#023): Kette, Knopf bzw. Hinweis fuer Schluessel ohne Private Key in sshkv. + const kette = await rotationsKette(s.id); + const aktivePrivat = s.privateVerwendungen.filter((v) => v.zustand !== "ENTFERNEN").length; + const rotationsSperre = rotationGesperrt({ + kategorie: s.kategorie, + status: s.status, + masterKeyVersion: s.privatGespeichert ? 1 : null, + widerrufEingeleitetAm: s.widerrufEingeleitetAm, + privatZuweisungen: aktivePrivat, + }); + const rotationLaeuft = s.nachfolger?.status === "AKTIV" && s.status !== "ROTIERT"; + 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; + const vorauswahl = ( + Array.isArray(zielkontenParameter) ? zielkontenParameter.join(",") : (zielkontenParameter ?? "") + ) + .split(",") + .filter(Boolean); const auswahl = aktiv && rechte.public ? await zielAuswahl() : undefined; const personenKey = s.kategorie === "PERSON"; const verwalten = darf(benutzer.rolle, "schluesselVerwalten"); @@ -206,7 +254,6 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id const privatHerausgebbar = s.privatGespeichert && s.kategorie !== "DEPLOYMENT"; const darfAnzeigen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyAnzeigen"); const darfLoeschen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyLoeschen"); - const jetzt = new Date(); return (
@@ -228,6 +275,7 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id + {imWiderruf && Widerruf unvollständig} {s.instanzStandard && Instanz-Standard} {s.selbstHinterlegt && selbst hinterlegt} {s.vorlage && Vorlage: {s.vorlage.name}} @@ -240,14 +288,99 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id } aktionen={ - verwalten && ( - + (verwalten || rotierbar || (widerrufbar && widerruf)) && ( + <> + {verwalten && ( + + )} + {rotierbar && ( + + )} + {widerrufbar && widerruf && ( + + )} + ) } /> + {rotationDesVorgaengers && ( + +

+ Dieser Schlüssel löst „{rotationDesVorgaengers.name}“ ab. Die Rotation ist erst fertig, wenn der + neue Schlüssel ausgerollt und getestet und der alte entfernt ist.{" "} + + Rotation fortsetzen + +

+
+ )} + + {imWiderruf && widerruf && ( + + + + )} + + {abgelaufen && widerrufbar && widerruf && ( + +
+

+ {s.ablaufAm ? ( + <> + Das Ablaufdatum () ist + überschritten.{" "} + + ) : null} + Nach Projektende bzw. Ablaufdatum: Widerruf mit einem Klick. sshkv entfernt abgelaufene + Schlüssel nie von selbst – erst nach Ihrer Bestätigung und dem Dry-Run. +

+ +
+
+ )} + + {(s.status === "WIDERRUFEN" || s.status === "ROTIERT") && ( + +

+ Von allen Hosts entfernt; der Schlüssel lässt sich nicht mehr zuweisen. + {s.nachfolger && ( + <> + {" "} + Ersetzt durch{" "} + + {s.nachfolger.name} + + . + + )} +

+
+ )} +
@@ -321,6 +454,7 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
{rechte.public && + !imWiderruf && (s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && ( )} + {(kette.length > 1 || (rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf)) && ( + 1 ? "Vorgänger und Nachfolger dieses Schlüssels" : undefined} + > + {kette.length > 1 && ( +
    + {kette.map((glied, index) => ( +
  1. + {index > 0 && ( + + → + + )} + {glied.id === s.id ? ( + + {glied.name} + + ) : ( + + {glied.name} + + )} + +
  2. + ))} +
+ )} + {rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && ( +

1 ? "mt-3" : ""}`}> + {rotationsSperre.grund} +

+ )} +
+ )} + }) { + const { id } = await params; + const { benutzer } = await sitzungErforderlich("schluesselVerwalten", `/schluessel/${id}/rotieren`); + if (!darf(benutzer.rolle, "publicKeyAusrollen")) notFound(); + let stand; + try { + stand = await rotationsStand(id); + } catch (fehler) { + if (fehler instanceof RotationsFehler) notFound(); + throw fehler; + } + const { alt } = stand; + + const kopf = ( + + } + titel={`„${alt.name}“ rotieren`} + untertitel="Neuer Schlüssel an allen Stellen, testen, erst dann den alten entfernen." + /> + ); + + // Ohne Nachfolger und nicht rotierbar: nur der Hinweis, keine halbe Rotation. + if (!stand.neu && stand.gesperrt) { + return ( +
+ {kopf} + } + titel={stand.gesperrt.neuerPublicKeyNoetig ? "Neuer Public Key nötig" : "Nicht rotierbar"} + text={stand.gesperrt.grund} + aktion={} + /> +
+ ); + } + + const widerruf = + stand.neu && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT" + ? await offeneEntfernungen([alt.id]) + : null; + + return ( +
+ {kopf} + +
+ ); +} diff --git a/src/app/(app)/schluessel/page.tsx b/src/app/(app)/schluessel/page.tsx index 335a139..81ba98e 100644 --- a/src/app/(app)/schluessel/page.tsx +++ b/src/app/(app)/schluessel/page.tsx @@ -1,8 +1,10 @@ -import { KeyRound, Lock, Plus, Search } from "lucide-react"; +import { Ban, KeyRound, Lock, Plus, Search } from "lucide-react"; import type { Metadata } from "next"; import Link from "next/link"; import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige"; +import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; +import { AlleAuswaehlen } from "@/components/widerruf/AlleAuswaehlen"; import { Auswahlliste, Feld } from "@/components/ui/Feld"; import { Karte } from "@/components/ui/Karte"; import { Leerzustand } from "@/components/ui/Leerzustand"; @@ -50,6 +52,31 @@ function PrivatHinweis({ s }: { s: SchluesselListenEintrag }) { ); } +const MASSEN_FORMULAR = "massenwiderruf"; + +/** Nur Schluessel, die sich widerrufen lassen, sind waehlbar (#024). */ +function Auswahl({ s }: { s: SchluesselListenEintrag }) { + const gesperrt = s.kategorie === "DEPLOYMENT" || s.status === "WIDERRUFEN" || s.status === "ROTIERT"; + return ( + + ); +} + +function WiderrufBadge({ s }: { s: SchluesselListenEintrag }) { + if (!s.widerrufEingeleitetAm || (s.status !== "AKTIV" && s.status !== "ABGELAUFEN")) return null; + return Widerruf unvollständig; +} + /** Schlüsselliste mit Filter (#012). Filter per GET, damit Links teilbar sind. */ export default async function Schluessel({ searchParams }: { searchParams: Suchparameter }) { // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). @@ -60,6 +87,8 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp const status = einzeln(parameter["status"]); const liste = await schluesselListe({ q, kategorie, status }); const verwalten = darf(benutzer.rolle, "schluesselVerwalten"); + // Massenwiderruf (#024): Auswahl nur fuer Admins; die frische Bestaetigung prueft die Aktion. + const massen = darf(benutzer.rolle, "massenwiderruf"); const gefiltert = q !== "" || kategorie !== "" || status !== ""; const jetzt = new Date(); @@ -142,13 +171,27 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp ) ) : ( + {massen && ( +
+ + + + )} {/* Unter md eine Liste statt der Tabelle: sechs Spalten passen nicht auf 390 px. */}
    {liste.map((s) => ( -
  • +
  • + {massen && } {s.name} @@ -161,6 +204,7 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp + @@ -175,6 +219,7 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp className="hidden md:block" beschriftung="Schlüssel" spalten={[ + ...(massen ? [{ titel: "Auswahl", versteckt: true, className: "w-10" }] : []), { titel: "Name" }, { titel: "Typ" }, { titel: "Kategorie" }, @@ -185,6 +230,11 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp > {liste.map((s) => ( + {massen && ( + + + + )} + {s.person && {s.person.name}} diff --git a/src/app/(app)/schluessel/widerruf/page.tsx b/src/app/(app)/schluessel/widerruf/page.tsx new file mode 100644 index 0000000..f034ab5 --- /dev/null +++ b/src/app/(app)/schluessel/widerruf/page.tsx @@ -0,0 +1,118 @@ +import { Ban } from "lucide-react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { StatusBadge } from "@/components/schluessel/Anzeige"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Button } from "@/components/ui/Button"; +import { Karte } from "@/components/ui/Karte"; +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 { darf } from "@/server/auth/rechte"; +import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { widerrufUebersicht } from "@/server/queries/widerruf"; + +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 + * Bestaetigung (Recht `massenwiderruf`). Protokolliert wird je Schluessel. + */ +export default async function MassenwiderrufSeite({ searchParams }: { searchParams: Suchparameter }) { + const parameter = await searchParams; + const ids = liste(parameter.id); + const selbst = `/schluessel/widerruf?${ids.map((id) => `id=${id}`).join("&")}`; + const { benutzer } = await sitzungErforderlich("massenwiderruf", selbst); + + const kopf = ( + } + titel="Massenwiderruf" + untertitel="Mehrere Schlüssel gemeinsam von allen Hosts entfernen – ein Dry-Run für alle." + /> + ); + if (ids.length === 0) { + return ( +
    + {kopf} + } + titel="Keine Schlüssel gewählt" + text="Wählen Sie in der Schlüsselliste die Schlüssel aus, die widerrufen werden sollen." + aktion={} + /> +
    + ); + } + + const uebersicht = await widerrufUebersicht(ids); + 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); + + return ( +
    + {kopf} + + {nochNichtEingeleitet.length > 0 || uebersicht.schluessel.some((s) => s.gesperrt && !s.eingeleitet) ? ( + + {!bestaetigungFrisch(benutzer.bestaetigtAm) && ( +

    + Der Massenwiderruf verlangt eine frische Bestätigung Ihrer Anmeldung – sshkv fragt beim + Vormerken danach. +

    + )} + +
    + ) : laufend.length > 0 ? ( + + s.id)} + offen={uebersicht.offen} + zielkonten={uebersicht.zielkonten} + vorauswahl={vorauswahl} + ruecksprung={selbst} + darfPrivat={darf(benutzer.rolle, "privateKeyAusrollen")} + /> + + ) : null} + + +
      + {uebersicht.schluessel.map((s) => ( +
    • + + {s.name} + + {s.person && · {s.person}} + + {s.eingeleitet && offeneStati(s) && ( + Widerruf unvollständig + )} +
    • + ))} +
    +
    +
    + ); +} diff --git a/src/components/assistent/Assistent.tsx b/src/components/assistent/Assistent.tsx index 03bdd55..9a1b8ea 100644 --- a/src/components/assistent/Assistent.tsx +++ b/src/components/assistent/Assistent.tsx @@ -1,12 +1,13 @@ "use client"; -import { ArrowLeft, ArrowRight, Check, ExternalLink, Play } from "lucide-react"; +import { ArrowLeft, ArrowRight, ExternalLink, Play } from "lucide-react"; import { useState } from "react"; -import { clsx } from "clsx"; import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel"; import { Ankreuzfeld, Button, Feld, Formularfehler, Karte } from "@/components/ui"; import { Auswahlliste, Textbereich } from "@/components/ui/Feld"; +import { Schrittleiste } from "@/components/ui/Schrittleiste"; import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf"; +import { warten } from "@/components/vorgang/warten"; import { istIpAdresse, platzhalterErsetzen } from "@/lib/vorlagen"; import { sudoersInhalt } from "@/lib/ssh/zielkonto"; import { @@ -91,19 +92,6 @@ function kurz(text: string): string { ); } -/** Wartet auf das Ende eines Vorgangs (fuer die Kette im Klick, nicht als Hook). */ -async function warten( - vorgangId: string, -): Promise<{ status: string; fehler: string | null; ergebnis: unknown }> { - for (;;) { - const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" }); - if (!antwort.ok) throw new Error("Status des Vorgangs nicht abrufbar."); - const stand = (await antwort.json()) as { status: string; fehler: string | null; ergebnis: unknown }; - if (stand.status !== "WARTEND" && stand.status !== "LAEUFT") return stand; - await new Promise((weiter) => setTimeout(weiter, 800)); - } -} - export function Assistent({ daten, bestaetigt }: { daten: AssistentDaten; bestaetigt: boolean }) { const [schritt, setSchritt] = useState(0); const [w, setW] = useState(LEER); @@ -249,27 +237,7 @@ export function Assistent({ daten, bestaetigt }: { daten: AssistentDaten; bestae return (
    -
      - {SCHRITTE.map((titel, index) => ( -
    1. - - {index < schritt ? : index + 1} - - {titel} -
    2. - ))} -
    + {schritt === 0 && ( void; +}) { const router = useRouter(); const [trockenlaufId, setTrockenlaufId] = useState(); const [ausrollenId, setAusrollenId] = useState(); @@ -43,12 +52,22 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of const trockenlauf = useVorgang(trockenlaufId); const ausrollen = useVorgang(ausrollenId); + // Ueber eine Ref, damit der Rueckruf genau einmal je Lauf feuert und nicht bei jedem Render. + const rueckruf = useRef(nachAusrollen); useEffect(() => { - if (ausrollen.stand && !laeuftNoch(ausrollen.stand)) router.refresh(); + rueckruf.current = nachAusrollen; + }); + useEffect(() => { + if (!ausrollen.stand || laeuftNoch(ausrollen.stand)) return; + router.refresh(); + if (ausrollen.stand.status === "ERLEDIGT") rueckruf.current?.(); }, [ausrollen.stand, router]); const plan = (trockenlauf.stand?.plan as { ziele?: PlanZiel[] } | null)?.ziele; const aenderungen = plan?.filter((z) => z.geaendert && !z.fehler).length ?? 0; + // Steht schon alles so auf dem Host (etwa ein Key, den jemand von Hand entfernt hat), gibt es + // nichts zu schreiben – der Zustand der Zuweisungen muss trotzdem nachgezogen werden (#024). + const nurZustand = aenderungen === 0 && offen > 0 && (plan?.some((z) => !z.fehler) ?? false); const laeuft = startet || laeuftNoch(trockenlauf.stand) || laeuftNoch(ausrollen.stand); const fertig = ausrollen.stand?.status === "ERLEDIGT"; @@ -103,7 +122,7 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
    ))} - {plan && aenderungen > 0 && !fertig && ( + {plan && (aenderungen > 0 || nurZustand) && !fertig && (
    )} - {plan && aenderungen === 0 && ( + {plan && aenderungen === 0 && !fertig && (

    Alles ist bereits auf dem Soll-Stand.

    )} {ausrollen.stand?.status === "FEHLGESCHLAGEN" && ( diff --git a/src/components/rotation/Rotation.tsx b/src/components/rotation/Rotation.tsx new file mode 100644 index 0000000..41c82b4 --- /dev/null +++ b/src/components/rotation/Rotation.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { Ban, CircleCheck, CircleX, KeyRound, PlugZap } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel"; +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 { + rotationAltWiderrufenAktion, + rotationStartenAktion, + rotationUebernehmenAktion, +} from "@/server/actions/rotation"; +import { verbindungTestenAktion } from "@/server/actions/verwendungen"; +import type { OffeneEntfernung } from "@/server/services/widerruf"; + +/** + * Gefuehrte Rotation (#023) als Schrittfolge wie der Assistent (#020). Der Schritt kommt vom + * Server (aus der Datenbank errechnet) – nach Neuladen, Abbruch oder Fehlschlag geht es an + * derselben Stelle weiter. + */ +const SCHRITTE = [ + "Neuer Schlüssel", + "Zuweisungen übernehmen", + "Dry-Run & Ausrollen", + "Verbindungstests", + "Alten widerrufen", +] as const; + +export type RotationsTest = { + id: string; + ziel: string; + ausgerollt: boolean; + status: string | null; + fehler: string | null; +}; + +export type RotationsDaten = { + alt: { id: string; name: string }; + neu: { id: string; name: string } | null; + schritt: 0 | 1 | 2 | 3 | 4 | 5; + neuOffen: number; + tests: RotationsTest[]; + /** Offene Entfernungen des alten Schluessels, sobald sein Widerruf laeuft. */ + widerruf: OffeneEntfernung[] | null; + darfPrivat: boolean; +}; + +export function Rotation({ daten }: { daten: RotationsDaten }) { + const router = useRouter(); + const [laeuft, setLaeuft] = useState(); + const [fehler, setFehler] = useState(); + const { alt, neu, schritt } = daten; + + async function kette(arbeit: () => Promise) { + setFehler(undefined); + try { + const meldung = await arbeit(); + if (meldung) setFehler(meldung); + } catch (f) { + setFehler((f as Error).message); + } finally { + setLaeuft(undefined); + router.refresh(); + } + } + + const vorbereiten = () => + kette(async () => { + if (!neu) { + setLaeuft("Neues Schlüsselpaar wird erzeugt …"); + const antwort = await rotationStartenAktion(alt.id); + if (antwort.fehler || !antwort.vorgangId) return antwort.fehler; + const ende = await warten(antwort.vorgangId); + if (ende.status !== "ERLEDIGT") return ende.fehler ?? "Erzeugen fehlgeschlagen."; + } + setLaeuft("Zuweisungen werden übernommen …"); + return (await rotationUebernehmenAktion(alt.id)).fehler; + }); + + const testen = () => + kette(async () => { + for (const test of daten.tests) { + setLaeuft(`Teste ${test.ziel} …`); + const antwort = await verbindungTestenAktion(test.id); + if (antwort.fehler || !antwort.vorgangId) return antwort.fehler; + await warten(antwort.vorgangId); + } + return undefined; + }); + + const altWiderrufen = () => + kette(async () => { + setLaeuft("Alter Schlüssel wird zum Entfernen vorgemerkt …"); + return (await rotationAltWiderrufenAktion(alt.id)).fehler; + }); + + const status = ( + <> + + {laeuft && ( +

    + {laeuft} +

    + )} + + ); + + return ( +
    + + + {schritt <= 1 && ( + +
      +
    • Neues Schlüsselpaar gleichen Typs, Name mit „(rotiert …)“, verknüpft mit dem alten.
    • +
    • Public Key: dieselben Ziele und Einschränkungen wie bisher.
    • +
    • + Private Key: derselbe Host und Benutzer, neue Datei mit Suffix -rJJJJMMTT; der + config-Eintrag behält seinen Alias und zeigt danach auf die neue Datei. +
    • +
    • Der alte Schlüssel bleibt gültig, bis der neue getestet ist.
    • +
    + {status} + +
    + )} + + {schritt === 2 && neu && ( + + + + )} + + {schritt === 3 && ( + +
      + {daten.tests.map((t) => ( +
    • + + {t.status === "ERLEDIGT" ? ( + + ) : t.status === "FEHLGESCHLAGEN" ? ( + + ) : ( + + )} + {t.ziel} + + {t.fehler && {t.fehler}} +
    • + ))} +
    + {status} + +
    + )} + + {schritt === 4 && !daten.widerruf && ( + 0 + ? "Alle Verbindungstests sind grün." + : "Keine Private-Key-Zuweisung mit Alias – es gibt nichts zu testen." + } + > +

    + Der alte Schlüssel „{alt.name}“ wird wie bei einem Widerruf von allen Hosts entfernt (Dry-Run + folgt). Danach gilt er als rotiert. +

    + {status} + +
    + )} + + {schritt === 4 && daten.widerruf && ( + + + + )} + + {schritt === 5 && neu && ( + +

    + Der alte Schlüssel ist von allen Hosts entfernt. Weiter mit{" "} + + {neu.name} + + . +

    +
    + )} +
    + ); +} diff --git a/src/components/ui/Schrittleiste.tsx b/src/components/ui/Schrittleiste.tsx new file mode 100644 index 0000000..ef15635 --- /dev/null +++ b/src/components/ui/Schrittleiste.tsx @@ -0,0 +1,41 @@ +import { Check } from "lucide-react"; +import { clsx } from "clsx"; + +/** + * Schrittanzeige fuer gefuehrte Ablaeufe (Assistent #020, Widerruf #024, Rotation #023): + * erledigte Schritte mit Haken, der aktuelle in Akzentfarbe und als `aria-current="step"`. + */ +export function Schrittleiste({ + schritte, + aktuell, + beschriftung = "Schritte", +}: { + schritte: readonly string[]; + /** Index des aktuellen Schritts; `schritte.length` heisst: alles erledigt. */ + aktuell: number; + beschriftung?: string; +}) { + return ( +
      + {schritte.map((titel, index) => ( +
    1. + + {index < aktuell ? : index + 1} + + {titel} +
    2. + ))} +
    + ); +} diff --git a/src/components/vorgang/warten.ts b/src/components/vorgang/warten.ts new file mode 100644 index 0000000..48aa47c --- /dev/null +++ b/src/components/vorgang/warten.ts @@ -0,0 +1,15 @@ +/** + * Wartet auf das Ende eines Vorgangs – fuer Ketten im Klick (Assistent #020, Widerruf #024, + * Rotation #023), nicht als Hook. + */ +export type VorgangsEnde = { status: string; fehler: string | null; ergebnis: unknown }; + +export async function warten(vorgangId: string): Promise { + for (;;) { + const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" }); + if (!antwort.ok) throw new Error("Status des Vorgangs nicht abrufbar."); + const stand = (await antwort.json()) as VorgangsEnde; + if (stand.status !== "WARTEND" && stand.status !== "LAEUFT") return stand; + await new Promise((weiter) => setTimeout(weiter, 800)); + } +} diff --git a/src/components/widerruf/AlleAuswaehlen.tsx b/src/components/widerruf/AlleAuswaehlen.tsx new file mode 100644 index 0000000..59d162d --- /dev/null +++ b/src/components/widerruf/AlleAuswaehlen.tsx @@ -0,0 +1,24 @@ +"use client"; + +/** + * „Alle auswählen“ fuer den Massenwiderruf (#024): setzt alle aktivierbaren Kaestchen, die zum + * Formular gehoeren. Die Auswahl selbst bleibt ein schlichtes GET-Formular – ohne JavaScript + * waehlt man eben einzeln. + */ +export function AlleAuswaehlen({ formular }: { formular: string }) { + return ( + + ); +} diff --git a/src/components/widerruf/WiderrufAblauf.tsx b/src/components/widerruf/WiderrufAblauf.tsx new file mode 100644 index 0000000..cbbba05 --- /dev/null +++ b/src/components/widerruf/WiderrufAblauf.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { CheckCheck, ExternalLink } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel"; +import { Ankreuzfeld, Button, Formularfehler } from "@/components/ui"; +import { Schrittleiste } from "@/components/ui/Schrittleiste"; +import { warten } from "@/components/vorgang/warten"; +import { widerrufAbschliessenAktion, widerrufZielkontenAktion } from "@/server/actions/widerruf"; +import type { OffeneEntfernung, ZielkontoKandidat } from "@/server/services/widerruf"; + +/** + * Ein eingeleiteter Widerruf (#024), wiederaufnehmbar wie der Assistent (#020): Dry-Run und + * Anwenden fuer alle offenen Entfernungen, dann optional Zielkonten, dann Abschluss. + * + * Was noch fehlt, kommt immer frisch vom Server (`offen`) – nach einem Neuladen, einem + * Fehlschlag auf einem Host oder einer erneuten Bestaetigung geht es an derselben Stelle weiter. + */ +const SCHRITTE = ["Vorgemerkt", "Dry-Run & Anwenden", "Zielkonten", "Abgeschlossen"] as const; + +const ZUSTAND_TEXT: Record = { + ENTFERNEN: "steht aus", + FEHLGESCHLAGEN: "letzter Versuch fehlgeschlagen", +}; + +export function WiderrufAblauf({ + schluesselIds, + offen, + zielkonten, + vorauswahl, + ruecksprung, + darfPrivat, + ohneZielkonten = false, + nachAbschluss, +}: { + schluesselIds: string[]; + offen: OffeneEntfernung[]; + zielkonten: ZielkontoKandidat[]; + vorauswahl: string[]; + /** Diese Seite – Ziel der erneuten Bestaetigung. */ + ruecksprung: string; + darfPrivat: boolean; + /** Rotation (#023): das Zielkonto nutzt der Nachfolger weiter. */ + ohneZielkonten?: boolean; + nachAbschluss?: () => void; +}) { + const router = useRouter(); + const waehlbar = ohneZielkonten ? [] : zielkonten.filter((k) => k.nochGenutztVon.length === 0); + // Ohne waehlbares Zielkonto entfaellt der Schritt ganz – ein leerer Schritt verwirrt nur. + const ohneSchritt = waehlbar.length === 0; + const [auswahl, setAuswahl] = useState(() => + vorauswahl.filter((id) => waehlbar.some((k) => k.id === id)), + ); + const [zielkontenFertig, setZielkontenFertig] = useState(false); + const [laeuft, setLaeuft] = useState(); + const [fehler, setFehler] = useState(); + const [bestaetigen, setBestaetigen] = useState(false); + const [noch, setNoch] = useState(); + + const aktuell = offen.length > 0 ? 1 : auswahl.length > 0 && !zielkontenFertig ? 2 : 3; + const privatOhneRecht = !darfPrivat && offen.some((o) => o.art === "privat"); + + async function abschliessen() { + setFehler(undefined); + setBestaetigen(false); + setNoch(undefined); + try { + if (auswahl.length > 0 && !zielkontenFertig) { + setLaeuft("Zielkonten werden entfernt …"); + const antwort = await widerrufZielkontenAktion(schluesselIds, auswahl); + if (antwort.bestaetigen) setBestaetigen(true); + if (antwort.fehler || !antwort.vorgaenge) { + setFehler(antwort.fehler); + return; + } + const fehlschlaege: string[] = []; + for (const vorgang of antwort.vorgaenge) { + const ende = await warten(vorgang.id); + if (ende.status !== "ERLEDIGT") + fehlschlaege.push(`${vorgang.beschriftung}: ${ende.fehler ?? "fehlgeschlagen"}`); + } + if (fehlschlaege.length > 0) { + setFehler(`Zielkonto nicht entfernt – ${fehlschlaege.join(" · ")}`); + return; + } + setZielkontenFertig(true); + } + setLaeuft("Widerruf wird abgeschlossen …"); + const antwort = await widerrufAbschliessenAktion(schluesselIds); + if (antwort.fehler) { + setFehler(antwort.fehler); + return; + } + const offeneSchluessel = (antwort.ergebnisse ?? []).filter((e) => e.status === "OFFEN"); + if (offeneSchluessel.length > 0) { + setNoch(offeneSchluessel.flatMap((e) => e.offen)); + } else { + nachAbschluss?.(); + } + router.refresh(); + } catch (fehler) { + setFehler((fehler as Error).message); + } finally { + setLaeuft(undefined); + } + } + + const bestaetigenZiel = (() => { + const url = new URL(ruecksprung, "http://x"); + if (auswahl.length > 0) url.searchParams.set("zielkonten", auswahl.join(",")); + else url.searchParams.delete("zielkonten"); + return `${url.pathname}${url.search}`; + })(); + + return ( +
    + s !== "Zielkonten") : SCHRITTE} + aktuell={ohneSchritt && aktuell >= 2 ? aktuell - 1 : aktuell} + beschriftung="Schritte des Widerrufs" + /> + + {offen.length > 0 && ( +
    +

    Noch nicht entfernt ({offen.length}):

    +
      + {offen.map((o) => ( +
    • + ●{" "} + {o.ziel} + + {" "} + · {ZUSTAND_TEXT[o.zustand] ?? o.zustand.toLowerCase()} + +
    • + ))} +
    + {privatOhneRecht ? ( +

    Private Keys entfernt nur ein Admin.

    + ) : ( + <> +

    + Der Dry-Run zeigt je Host, was entfernt wird – und warum ein Host gerade nicht geht (etwa + nicht erreichbar). Fehlgeschlagene Ziele bleiben offen; ein neuer Dry-Run versucht es erneut. +

    + void abschliessen()} + /> + + )} +
    + )} + + {waehlbar.length > 0 && ( +
    + Zielkonten entfernen (optional) + {zielkonten.map((k) => { + const genutzt = k.nochGenutztVon.length > 0; + return ( + + setAuswahl((alt) => (e.target.checked ? [...alt, k.id] : alt.filter((id) => id !== k.id))) + } + beschriftung={ + <> + Benutzer {k.benutzername} auf{" "} + {k.hostName} entfernen + + } + erklaerung={ + genutzt + ? `Bleibt: wird noch von „${k.nochGenutztVon.join("“, „")}“ genutzt.` + : "Nach dem Entfernen der Schlüssel; verlangt eine frische Bestätigung." + } + /> + ); + })} +
    + )} + + {noch && noch.length > 0 && ( +

    + Noch nicht widerrufen – offen: {noch.map((o) => o.ziel).join(", ")}. Bitte einen neuen Dry-Run + starten. +

    + )} + + {bestaetigen && ( + + Anmeldung bestätigen, danach hier fortsetzen + + )} + {laeuft && ( +

    + {laeuft} +

    + )} + {offen.length === 0 && ( +
    + +
    + )} +
    + ); +} diff --git a/src/components/widerruf/WiderrufFormular.tsx b/src/components/widerruf/WiderrufFormular.tsx new file mode 100644 index 0000000..96254db --- /dev/null +++ b/src/components/widerruf/WiderrufFormular.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { Ban } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { Ankreuzfeld, Button, Formularfehler } from "@/components/ui"; +import { widerrufEinleitenAktion } from "@/server/actions/widerruf"; +import type { WiderrufUebersicht } from "@/server/queries/widerruf"; + +/** + * Bestaetigung eines Widerrufs (#024): listet genau auf, was auf welchem Host passiert, und + * laesst Zielkonten (Standard: aus) zum Entfernen auswaehlen. Der Klick merkt nur vor – auf den + * Hosts aendert sich erst nach Dry-Run und „Anwenden“ etwas. + * + * Die Auswahl der Zielkonten reist als `?zielkonten=` in der Adresse weiter: so uebersteht sie + * ein Neuladen und die erneute Bestaetigung der Anmeldung. + */ +export function WiderrufFormular({ + uebersicht, + ruecksprung, + abbrechen, +}: { + uebersicht: WiderrufUebersicht; + /** Wohin nach dem Vormerken (Schluesselseite bzw. Massenwiderruf-Seite). */ + ruecksprung: string; + abbrechen?: () => void; +}) { + const router = useRouter(); + const [auswahl, setAuswahl] = useState([]); + const [fehler, setFehler] = useState(); + const [bestaetigen, setBestaetigen] = useState(false); + const [laeuft, starten] = useTransition(); + const gesperrt = uebersicht.schluessel.filter((s) => s.gesperrt); + const mehrere = uebersicht.schluessel.length > 1; + + const ziel = (() => { + const url = new URL(ruecksprung, "http://x"); + if (auswahl.length > 0) url.searchParams.set("zielkonten", auswahl.join(",")); + return `${url.pathname}${url.search}`; + })(); + + return ( +
    + + + {uebersicht.zielkonten.length > 0 && ( +
    + Zielkonten zusätzlich entfernen (optional) + {uebersicht.zielkonten.map((k) => { + const genutzt = k.nochGenutztVon.length > 0; + return ( + + setAuswahl((alt) => (e.target.checked ? [...alt, k.id] : alt.filter((id) => id !== k.id))) + } + beschriftung={ + <> + Benutzer {k.benutzername} auf{" "} + {k.hostName} entfernen + + } + erklaerung={ + genutzt + ? `Bleibt: wird noch von „${k.nochGenutztVon.join("“, „")}“ genutzt.` + : `Von sshkv angelegt. Benutzer samt Home${k.sudoModus !== "KEINER" ? " und sudoers-Datei" : ""} werden gelöscht – verlangt eine frische Bestätigung.` + } + /> + ); + })} +
    + )} + +
    +

    So geht es weiter

    +
      +
    1. + Die Zuweisungen werden zum Entfernen vorgemerkt; der Schlüssel lässt sich nicht mehr zuweisen. +
    2. +
    3. Der Dry-Run zeigt jede Änderung. Erst „Anwenden“ ändert etwas auf den Hosts.
    4. +
    5. + Erst wenn alle Entfernungen erfolgreich waren, gilt der Schlüssel als widerrufen. Scheitert ein + Host, bleibt er im Zwischenzustand und die Seite zeigt, was fehlt. +
    6. +
    +
    + + {gesperrt.map((s) => ( +

    + {s.gesperrt} +

    + ))} + + {bestaetigen && ( + + Anmeldung bestätigen und danach erneut „Widerruf vorbereiten“ wählen + + )} +
    + {abbrechen && } + +
    +
    + ); +} + +/** Die Liste „was passiert“ – im Dialog und auf der Massenwiderruf-Seite. */ +export function WiderrufInhalt({ + uebersicht, + mehrere, +}: { + uebersicht: WiderrufUebersicht; + mehrere: boolean; +}) { + return ( +
    + {uebersicht.schluessel.map((s) => { + const oeffentlich = uebersicht.public.filter((v) => v.schluesselId === s.id); + const privat = uebersicht.privat.filter((v) => v.schluesselId === s.id); + return ( +
    + {mehrere && ( +

    + {s.name} + {s.person && · {s.person}} +

    + )} + {oeffentlich.length === 0 && privat.length === 0 && ( +

    + Keine Zuweisungen – der Schlüssel wird ohne Änderung auf einem Host widerrufen. +

    + )} + {oeffentlich.length > 0 && ( +
    +

    Public Key wird aus authorized_keys entfernt:

    +
      + {oeffentlich.map((v) => ( +
    • + − {v.ziel} + {v.archiviert && ( + + {" "} + · Host archiviert – sshkv ändert dort nichts + + )} +
    • + ))} +
    +
    + )} + {privat.length > 0 && ( +
    +

    Private Key wird vom Quell-Host entfernt:

    +
      + {privat.map((v) => ( +
    • + + − {v.ziel}:{v.pfad} + + + {v.beimWiderruf === "UMBENENNEN" + ? `Wird umbenannt in …${v.pfad.split("/").pop()}.sshkv-widerrufen.` + : "Wird gelöscht"} + , dazu die .pub-Datei + {v.alias ? `, der config-Eintrag „Host ${v.alias}“ und sein known_hosts-Block` : ""} + {v.archiviert ? " · Host archiviert – sshkv ändert dort nichts" : ""} + +
    • + ))} +
    +
    + )} +
    + ); + })} +
    + ); +} diff --git a/src/components/widerruf/WiderrufKnopf.tsx b/src/components/widerruf/WiderrufKnopf.tsx new file mode 100644 index 0000000..14cdcf5 --- /dev/null +++ b/src/components/widerruf/WiderrufKnopf.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { Ban, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/Button"; +import type { WiderrufUebersicht } from "@/server/queries/widerruf"; +import { WiderrufFormular } from "./WiderrufFormular"; + +/** + * „Widerrufen“ auf der Schluesselseite (#024): Gefahr-Knopf mit Bestaetigungsdialog. Offen/zu + * als Zustand wie im AusrollDialog – kein Render-Pfad liest die Ref. + */ +export function WiderrufKnopf({ + uebersicht, + ruecksprung, + beschriftung = "Widerrufen", +}: { + uebersicht: WiderrufUebersicht; + ruecksprung: string; + beschriftung?: string; +}) { + const dialog = useRef(null); + const [offen, setOffen] = useState(false); + const name = uebersicht.schluessel[0]?.name ?? ""; + + useEffect(() => { + const element = dialog.current; + if (!element) return; + if (offen && !element.open) element.showModal(); + if (!offen && element.open) element.close(); + }, [offen]); + + const schliessen = () => setOffen(false); + return ( + <> + + +
    +

    + „{name}“ widerrufen? +

    + +
    + {offen && ( + + )} +
    + + ); +} diff --git a/src/lib/rotation.ts b/src/lib/rotation.ts new file mode 100644 index 0000000..3665525 --- /dev/null +++ b/src/lib/rotation.ts @@ -0,0 +1,37 @@ +/** + * Namens- und Pfadregeln der gefuehrten Rotation (#023). Rein, damit testbar. + */ + +const NAME_SUFFIX = /\s*\(rotiert \d{2}\.\d{2}\.\d{4}\)$/; +const PFAD_SUFFIX = /-r\d{8}(?:-\d+)?$/; + +/** TT.MM.JJJJ und JJJJMMTT nach dem Kalender in Berlin. */ +export function rotationsDatum(jetzt: Date = new Date()): { anzeige: string; kompakt: string } { + const [jahr, monat, tag] = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin" }) + .format(jetzt) + .split("-"); + return { anzeige: `${tag}.${monat}.${jahr}`, kompakt: `${jahr}${monat}${tag}` }; +} + +/** + * „Backup A → B (rotiert 12.10.2026)“ – ein frueheres Rotations-Suffix wird ersetzt, damit + * die Namen bei jeder Rotation nicht laenger werden. + */ +export function rotationsName(name: string, datum: string, maxLaenge = 200): string { + const suffix = ` (rotiert ${datum})`; + const basis = name.replace(NAME_SUFFIX, "").trim(); + return `${basis.slice(0, Math.max(1, maxLaenge - suffix.length))}${suffix}`; +} + +/** + * Neuer Pfad fuer den Private Key des Nachfolgers: gleicher Name mit `-rJJJJMMTT`. Ein alter + * Suffix wird ersetzt; ist der Pfad (auch durch eine fruehere, entfernte Zuweisung) belegt, + * kommt eine laufende Nummer dazu. Zwei Dateien nebeneinander sind gewollt: bis der Nachfolger + * getestet ist, bleibt der alte Schluessel benutzbar. + */ +export function rotationsPfad(pfad: string, datumKompakt: string, belegt: ReadonlySet): string { + const basis = pfad.replace(PFAD_SUFFIX, ""); + let kandidat = `${basis}-r${datumKompakt}`; + for (let nummer = 2; belegt.has(kandidat); nummer += 1) kandidat = `${basis}-r${datumKompakt}-${nummer}`; + return kandidat; +} diff --git a/src/lib/schluessel.ts b/src/lib/schluessel.ts index 31c6c5d..dfcb0ae 100644 --- a/src/lib/schluessel.ts +++ b/src/lib/schluessel.ts @@ -200,6 +200,8 @@ export type SchluesselVorgangEingabe = { readonly typ?: "ED25519" | "RSA4096"; /** Nur Import: soll der Worker eine Passphrase erwarten? Die Passphrase selbst reist im Job. */ readonly mitPassphrase?: boolean; + /** Rotation (#023): der abgeloeste Schluessel – so ist die Kette sofort da, auch ohne Browser. */ + readonly vorgaengerId?: string | null; }; /** Prueft `Vorgang.eingabe` im Worker – sie lag zwischendurch in der Datenbank. */ @@ -217,6 +219,7 @@ export const vorgangEingabeSchema = z.object({ .nullable(), typ: z.enum(["ED25519", "RSA4096"]).optional(), mitPassphrase: z.boolean().optional(), + vorgaengerId: z.string().max(64).nullish(), }); /** Was im verschluesselten `geheim` eines Erzeugen- oder Import-Jobs steht. */ diff --git a/src/server/actions/rotation.ts b/src/server/actions/rotation.ts new file mode 100644 index 0000000..72ba2f8 --- /dev/null +++ b/src/server/actions/rotation.ts @@ -0,0 +1,71 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "@/lib/validierung"; +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 { VerwendungsFehler } from "@/server/services/verwendungen"; + +/** + * Gefuehrte Rotation (#023): je Schritt eine duenne Aktion, die Oberflaeche verkettet sie. + * Rotieren heisst einen Schluessel erzeugen und ausrollen: `schluesselVerwalten` und + * `publicKeyAusrollen`; fuer Private-Key-Zuweisungen zusaetzlich `privateKeyAusrollen` (Service). + */ +export type RotationsAntwort = { readonly vorgangId?: string; readonly fehler?: string }; + +function alsAntwort(fehler: unknown): RotationsAntwort { + if ( + fehler instanceof RechteFehler || + fehler instanceof BestaetigungFehler || + fehler instanceof VerwendungsFehler || + fehler instanceof FachFehler + ) { + return { fehler: fehler.message }; + } + throw fehler; +} + +const id = z.string().min(1).max(64); + +async function berechtigt() { + await rechtErforderlich("schluesselVerwalten"); + return rechtErforderlich("publicKeyAusrollen"); +} + +export async function rotationStartenAktion(schluesselId: string): Promise { + try { + const { benutzer } = await berechtigt(); + return { vorgangId: await rotationStarten(id.parse(schluesselId), akteurVon(benutzer)) }; + } catch (fehler) { + return alsAntwort(fehler); + } +} + +export async function rotationUebernehmenAktion(schluesselId: string): Promise { + try { + const { benutzer } = await berechtigt(); + await rotationUebernehmen(id.parse(schluesselId), akteurVon(benutzer), { + privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"), + }); + revalidatePath(`/schluessel/${schluesselId}`); + return {}; + } catch (fehler) { + return alsAntwort(fehler); + } +} + +export async function rotationAltWiderrufenAktion(schluesselId: string): Promise { + try { + const { benutzer } = await berechtigt(); + await rotationAltWiderrufen(id.parse(schluesselId), akteurVon(benutzer), { + privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"), + }); + revalidatePath(`/schluessel/${schluesselId}`); + return {}; + } catch (fehler) { + return alsAntwort(fehler); + } +} diff --git a/src/server/actions/verwendungen.ts b/src/server/actions/verwendungen.ts index f2f01f1..fa10776 100644 --- a/src/server/actions/verwendungen.ts +++ b/src/server/actions/verwendungen.ts @@ -74,7 +74,11 @@ export async function publicEntfernenAktion( } } -export type AusrollAuswahl = { readonly schluesselId: string } | { readonly hostId: string }; +export type AusrollAuswahl = + | { readonly schluesselId: string } + | { readonly hostId: string } + /** Ein gemeinsamer Dry-Run fuer mehrere Schluessel (Massenwiderruf, #024). */ + | { readonly schluesselIds: readonly string[] }; export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise { try { @@ -84,7 +88,9 @@ export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise { + try { + const ids = idListe.parse(schluesselIds); + const { benutzer } = await rechtErforderlich(ids.length > 1 ? "massenwiderruf" : "publicKeyAusrollen"); + await widerrufEinleiten(ids, akteurVon(benutzer), { + privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"), + }); + pfadeNeuLaden(ids); + return {}; + } catch (fehler) { + return alsAntwort(fehler); + } +} + +export async function widerrufZielkontenAktion( + schluesselIds: string[], + zielkontoIds: string[], +): Promise { + try { + const ids = idListe.parse(schluesselIds); + const konten = idListe.parse(zielkontoIds); + // Ein Benutzer samt Home verschwindet: Admin mit frischer Bestaetigung (#018). + const { benutzer } = await rechtErforderlich("zielkontenVerwalten"); + return { vorgaenge: await widerrufZielkontenEntfernen(ids, konten, akteurVon(benutzer)) }; + } catch (fehler) { + return alsAntwort(fehler); + } +} + +export async function widerrufAbschliessenAktion( + schluesselIds: string[], +): Promise { + try { + const ids = idListe.parse(schluesselIds); + // Abschliessen setzt nur den Status nach dem, was die Datenbank belegt – kein Host-Zugriff. + const { benutzer } = await rechtErforderlich("publicKeyAusrollen"); + const ergebnisse = await widerrufAbschliessen(ids, akteurVon(benutzer)); + pfadeNeuLaden(ids); + return { ergebnisse }; + } catch (fehler) { + return alsAntwort(fehler); + } +} diff --git a/src/server/queries/schluessel.ts b/src/server/queries/schluessel.ts index a0badc6..d4cdb97 100644 --- a/src/server/queries/schluessel.ts +++ b/src/server/queries/schluessel.ts @@ -23,6 +23,7 @@ export const SCHLUESSEL_LISTE_SELECT = { ablaufAm: true, masterKeyVersion: true, passphraseGeschuetzt: true, + widerrufEingeleitetAm: true, person: { select: { id: true, name: true } }, _count: { select: { @@ -50,12 +51,15 @@ export const SCHLUESSEL_DETAIL_SELECT = { tags: true, ablaufAm: true, zuletztVerwendetAm: true, + widerrufEingeleitetAm: true, angelegtAm: true, personId: true, vorlageId: true, 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 } }, publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } }, orderBy: { angelegtAm: "asc" }, @@ -101,13 +105,34 @@ function alsStatus(wert: string | undefined): SchluesselStatus | undefined { return STATI.find((s) => s === wert); } +/** + * Heute 00:00 UTC nach dem Kalender in Berlin – Ablaufdaten liegen als UTC-Mitternacht ihres + * Tages in der Datenbank, und `ablaufZustand()` rechnet ebenfalls mit dem Berliner Datum. + */ +function heute(jetzt = new Date()): Date { + const tag = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin" }).format(jetzt); + return new Date(`${tag}T00:00:00Z`); +} + +/** + * Status als Filter (#024): ein aktiver Schluessel mit Ablaufdatum in der Vergangenheit gilt als + * abgelaufen, auch wenn niemand den Status von Hand gesetzt hat. Automatisch entfernt wird nichts. + */ +function statusFilter(status: SchluesselStatus | undefined): Prisma.SchluesselWhereInput { + if (status === "AKTIV") return { status, OR: [{ ablaufAm: null }, { ablaufAm: { gte: heute() } }] }; + if (status === "ABGELAUFEN") { + return { OR: [{ status }, { status: "AKTIV", ablaufAm: { lt: heute() } }] }; + } + return status ? { status } : {}; +} + export async function schluesselListe(filter: SchluesselFilter = {}) { const q = filter.q?.trim().slice(0, 100); const kategorie = alsKategorie(filter.kategorie); const status = alsStatus(filter.status); const where: Prisma.SchluesselWhereInput = { ...(kategorie ? { kategorie } : {}), - ...(status ? { status } : {}), + AND: [statusFilter(status)], ...(q ? { OR: [ @@ -166,3 +191,44 @@ export async function vorlagenAuswahl() { select: { id: true, name: true, standardLaufzeitTage: true }, }); } + +// ---------------------------------------------------------------- Rotationskette (#023) + +export type KettenGlied = { readonly id: string; readonly name: string; readonly status: SchluesselStatus }; + +/** + * Die ganze Rotationskette eines Schluessels, aeltester zuerst. Begrenzt, damit eine (durch + * Fehler entstandene) Schleife die Seite nicht aufhaengt. + */ +export async function rotationsKette(id: string, maximal = 25): Promise { + const glied = (schluesselId: string) => + prisma.schluessel.findUnique({ + where: { id: schluesselId }, + select: { + id: true, + name: true, + status: true, + vorgaengerId: true, + nachfolger: { select: { id: true } }, + }, + }); + const start = await glied(id); + if (!start) return []; + const kette: KettenGlied[] = [{ id: start.id, name: start.name, status: start.status }]; + const gesehen = new Set([start.id]); + for (let davor = start.vorgaengerId; davor && kette.length < maximal && !gesehen.has(davor);) { + const g = await glied(davor); + if (!g) break; + gesehen.add(g.id); + 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);) { + 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; + } + return kette; +} diff --git a/src/server/queries/widerruf.ts b/src/server/queries/widerruf.ts new file mode 100644 index 0000000..afcac1e --- /dev/null +++ b/src/server/queries/widerruf.ts @@ -0,0 +1,121 @@ +import { prisma } from "@/lib/db"; +import { + offeneEntfernungen, + widerrufGesperrt, + zielkontoKandidaten, + type OffeneEntfernung, + type ZielkontoKandidat, +} from "@/server/services/widerruf"; + +/** + * Was ein Widerruf bewirkt (#024) – fuer den Bestaetigungsdialog, die Massenwiderruf-Seite und + * die Fortsetzen-Ansicht. Nur Namen, Ziele und Pfade; nie Schluesselinhalte. + */ +export type WiderrufSchluessel = { + readonly id: string; + readonly name: string; + readonly kategorie: "PERSON" | "DIENST" | "DEPLOYMENT"; + readonly status: "AKTIV" | "ROTIERT" | "WIDERRUFEN" | "ABGELAUFEN"; + readonly eingeleitet: boolean; + readonly person: string | null; + readonly gesperrt: string | null; +}; + +export type WiderrufZiel = { + readonly id: string; + readonly schluesselId: string; + readonly ziel: string; + readonly archiviert: boolean; +}; + +export type WiderrufPrivatZiel = WiderrufZiel & { + readonly pfad: string; + readonly alias: string | null; + readonly beimWiderruf: "LOESCHEN" | "UMBENENNEN"; +}; + +export type WiderrufUebersicht = { + readonly schluessel: WiderrufSchluessel[]; + readonly public: WiderrufZiel[]; + readonly privat: WiderrufPrivatZiel[]; + readonly zielkonten: ZielkontoKandidat[]; + readonly offen: OffeneEntfernung[]; +}; + +export async function widerrufUebersicht(schluesselIds: readonly string[]): Promise { + const ids = [...new Set(schluesselIds)]; + const schluessel = await prisma.schluessel.findMany({ + where: { id: { in: ids } }, + select: { + id: true, + name: true, + kategorie: true, + status: true, + widerrufEingeleitetAm: true, + person: { select: { name: true } }, + }, + orderBy: { name: "asc" }, + }); + const [oeffentlich, privat, zielkonten, offen] = await Promise.all([ + prisma.publicVerwendung.findMany({ + where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } }, + select: { + id: true, + schluesselId: true, + zielbenutzer: true, + host: { select: { name: true, archiviertAm: true } }, + hostgruppe: { select: { name: true } }, + }, + orderBy: { angelegtAm: "asc" }, + }), + prisma.privateVerwendung.findMany({ + where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } }, + select: { + id: true, + schluesselId: true, + zielbenutzer: true, + pfad: true, + sshConfig: true, + beimWiderruf: true, + host: { select: { name: true, archiviertAm: true } }, + }, + orderBy: { angelegtAm: "asc" }, + }), + zielkontoKandidaten(ids), + offeneEntfernungen(ids), + ]); + return { + schluessel: schluessel.map((s) => ({ + id: s.id, + name: s.name, + kategorie: s.kategorie, + status: s.status, + eingeleitet: Boolean(s.widerrufEingeleitetAm), + person: s.person?.name ?? null, + gesperrt: widerrufGesperrt(s), + })), + public: oeffentlich.map((v) => ({ + id: v.id, + schluesselId: v.schluesselId, + ziel: v.host + ? `${v.zielbenutzer}@${v.host.name}` + : `${v.zielbenutzer}@${v.hostgruppe?.name ?? "?"} (Gruppe)`, + archiviert: Boolean(v.host?.archiviertAm), + })), + privat: privat.map((v) => { + const config = + v.sshConfig && typeof v.sshConfig === "object" ? (v.sshConfig as { alias?: unknown }) : null; + return { + id: v.id, + schluesselId: v.schluesselId, + ziel: `${v.zielbenutzer}@${v.host.name}`, + pfad: v.pfad, + alias: typeof config?.alias === "string" ? config.alias : null, + beimWiderruf: v.beimWiderruf, + archiviert: Boolean(v.host.archiviertAm), + }; + }), + zielkonten, + offen, + }; +} diff --git a/src/server/services/rotation.ts b/src/server/services/rotation.ts new file mode 100644 index 0000000..a490ea7 --- /dev/null +++ b/src/server/services/rotation.ts @@ -0,0 +1,313 @@ +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { 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"; +import { + VerwendungsFehler, + privateVerwendungAnlegen, + publicVerwendungAnlegen, +} from "@/server/services/verwendungen"; +import { widerrufEinleiten } from "@/server/services/widerruf"; +import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge"; + +/** + * Gefuehrte Rotation (#023): neuer Schluessel gleichen Typs, alle aktiven Zuweisungen + * uebernehmen, ausrollen, Verbindungen testen – und erst dann den alten Schluessel ueber den + * Widerruf (#024) entfernen. Der alte endet als ROTIERT, der neue zeigt per `vorgaengerId` auf ihn. + * + * Jeder Schritt ist idempotent und liest seinen Stand aus der Datenbank: die Oberflaeche kann + * jederzeit neu laden und an derselben Stelle weitermachen. + */ +export class RotationsFehler extends VerwendungsFehler { + constructor(nachricht: string) { + super(nachricht); + this.name = "RotationsFehler"; + } +} + +type Akteur = { readonly id: string; readonly name: string }; + +/** Zustaende, in denen eine Verwendung mitgenommen wird. */ +const AKTIVE = ["GEPLANT", "AUSGEROLLT", "ABWEICHUNG", "FEHLGESCHLAGEN"] as const; + +const ALT_SELECT = { + id: true, + name: true, + typ: true, + kategorie: true, + status: true, + fingerprint: true, + tags: true, + ablaufAm: true, + personId: true, + vorlageId: true, + masterKeyVersion: true, + widerrufEingeleitetAm: true, + nachfolger: { select: { id: true, name: true, status: true } }, + _count: { select: { privateVerwendungen: { where: { zustand: { in: [...AKTIVE] } } } } }, +} satisfies Prisma.SchluesselSelect; + +type Alt = Prisma.SchluesselGetPayload<{ select: typeof ALT_SELECT }>; + +/** + * Warum sshkv diesen Schluessel nicht selbst rotieren kann – oder null. Ein Personen-Key (oder + * ein Dienst-Key, dessen Private Key woanders liegt) braucht einen neuen Public Key von aussen. + */ +export function rotationGesperrt( + s: Pick & { + readonly privatZuweisungen: number; + }, +): { grund: string; neuerPublicKeyNoetig?: boolean } | null { + if (s.kategorie === "DEPLOYMENT") + return { grund: "Der Deployment-Key wird über eine eigene Rotation ersetzt." }; + if (s.status !== "AKTIV" && s.status !== "ABGELAUFEN") + return { grund: "Nur aktive oder abgelaufene Schlüssel lassen sich rotieren." }; + if (s.kategorie === "PERSON" || (s.masterKeyVersion === null && s.privatZuweisungen === 0)) { + return { + grund: + "Den Private Key hat sshkv nicht – rotieren heißt hier: Die Person erzeugt ein neues Schlüsselpaar und liefert den neuen Public Key. Diesen importieren, genauso zuweisen und danach diesen Schlüssel widerrufen.", + neuerPublicKeyNoetig: true, + }; + } + return null; +} + +async function altLaden(id: string): Promise { + const alt = await prisma.schluessel.findUnique({ where: { id }, select: ALT_SELECT }); + if (!alt) throw new RotationsFehler("Diesen Schlüssel gibt es nicht."); + return alt; +} + +function gesperrtPruefen(alt: Alt) { + // Ein laufender Widerruf gehoert zur Rotation, wenn es schon einen Nachfolger gibt. + if (alt.widerrufEingeleitetAm && !alt.nachfolger) + throw new RotationsFehler("Dieser Schlüssel wird gerade widerrufen."); + const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen }); + if (gesperrt) throw new RotationsFehler(gesperrt.grund); +} + +/** Schritt 1: Nachfolger im Worker erzeugen lassen. Liefert die Vorgangs-ID. */ +export async function rotationStarten(altId: string, akteur: Akteur): Promise { + const alt = await altLaden(altId); + gesperrtPruefen(alt); + 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 slug = await eindeutigerSlug(null, name); + // Ein vergangenes Ablaufdatum wuerde der Nachfolger sonst erben – er waere sofort abgelaufen. + const ablaufAm = alt.ablaufAm && alt.ablaufAm > new Date() ? alt.ablaufAm.toISOString() : null; + const eingabe: SchluesselVorgangEingabe = { + name, + slug, + tags: alt.tags, + ablaufAm, + personId: alt.personId, + vorlageId: alt.vorlageId, + kommentar: null, + typ: alt.typ === "RSA4096" ? "RSA4096" : "ED25519", + vorgaengerId: alt.id, + }; + return vorgangEinreihen({ art: "SCHLUESSEL_ERZEUGEN", angefordertVonId: akteur.id, eingabe }); +} + +async function nachfolgerVon(alt: Alt) { + if (!alt.nachfolger) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen."); + if (alt.nachfolger.status !== "AKTIV") throw new RotationsFehler("Der neue Schlüssel ist nicht aktiv."); + return alt.nachfolger; +} + +/** + * Schritt 2: aktive Zuweisungen fuer den Nachfolger uebernehmen. Public: gleiche Ziele und + * Optionen. Private: gleicher Host/Benutzer und Alias, neuer Pfad – der config-Block zeigt nach + * dem Ausrollen auf die neue Datei (juengste Zuweisung gewinnt je Alias, src/worker/ablaeufe/privat.ts). + */ +export async function rotationUebernehmen( + altId: string, + akteur: Akteur, + optionen: { readonly privatErlaubt: boolean }, +): Promise<{ public: number; privat: number }> { + const alt = await altLaden(altId); + gesperrtPruefen(alt); + const neu = await nachfolgerVon(alt); + const [altPublic, altPrivat, neuPublic, neuPrivat] = await Promise.all([ + prisma.publicVerwendung.findMany({ + where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } }, + orderBy: { angelegtAm: "asc" }, + }), + prisma.privateVerwendung.findMany({ + where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } }, + orderBy: { angelegtAm: "asc" }, + }), + prisma.publicVerwendung.findMany({ where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } } }), + prisma.privateVerwendung.findMany({ where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } } }), + ]); + if (altPrivat.length > 0 && !optionen.privatErlaubt) + throw new RotationsFehler("Private Keys rollt nur ein Admin aus."); + + let anzahlPublic = 0; + for (const v of altPublic) { + const schon = neuPublic.some( + (n) => n.hostId === v.hostId && n.hostgruppeId === v.hostgruppeId && n.zielbenutzer === v.zielbenutzer, + ); + if (schon) continue; + await publicVerwendungAnlegen( + { + schluesselId: neu.id, + hostId: v.hostId, + hostgruppeId: v.hostgruppeId, + zielbenutzer: v.zielbenutzer, + optionen: v.optionen, + }, + akteur, + ); + anzahlPublic += 1; + } + + const datum = rotationsDatum().kompakt; + let anzahlPrivat = 0; + for (const v of altPrivat) { + const config = v.sshConfig as { alias: string; hostName: string; benutzer: string; port: number } | null; + const schon = neuPrivat.some( + (n) => + n.hostId === v.hostId && + n.zielbenutzer === v.zielbenutzer && + ((n.sshConfig as { alias?: string } | null)?.alias ?? null) === (config?.alias ?? null), + ); + if (schon) continue; + // Auch entfernte Zuweisungen belegen einen Pfad (eindeutiger Index ueber alle Zustaende). + const belegt = new Set( + ( + await prisma.privateVerwendung.findMany({ + where: { hostId: v.hostId, zielbenutzer: v.zielbenutzer }, + select: { pfad: true }, + }) + ).map((p) => p.pfad), + ); + await privateVerwendungAnlegen( + { + schluesselId: neu.id, + hostId: v.hostId, + zielbenutzer: v.zielbenutzer, + pfad: rotationsPfad(v.pfad, datum, belegt), + sshConfig: config, + knownHostsFuer: v.knownHostsFuer, + beimWiderruf: v.beimWiderruf, + }, + akteur, + ); + anzahlPrivat += 1; + } + if (anzahlPublic + anzahlPrivat > 0) { + await protokollieren({ + akteur, + aktion: "schluessel.rotation.uebernommen", + ergebnis: "ERFOLG", + fingerprint: alt.fingerprint, + details: { nachfolgerId: neu.id, public: anzahlPublic, privat: anzahlPrivat }, + }); + } + return { public: anzahlPublic, privat: anzahlPrivat }; +} + +/** Verbindungstests, die der Nachfolger bestehen muss: jede Private-Zuweisung mit Alias. */ +async function testZiele(neuId: string) { + const verwendungen = await prisma.privateVerwendung.findMany({ + where: { schluesselId: neuId, zustand: { not: "ENTFERNT" } }, + select: { + id: true, + zielbenutzer: true, + sshConfig: true, + zustand: true, + zuletztAngewendetAm: true, + host: { select: { name: true } }, + }, + orderBy: { angelegtAm: "asc" }, + }); + const mitAlias = verwendungen.filter((v) => (v.sshConfig as { alias?: string } | null)?.alias); + return Promise.all( + mitAlias.map(async (v) => { + // Gruen ist nur ein Test nach dem letzten Ausrollen – ein aelterer bewiese nichts. + const test = await prisma.vorgang.findFirst({ + where: { + art: "VERBINDUNGSTEST", + eingabe: { equals: { privatVerwendungId: v.id } }, + ...(v.zuletztAngewendetAm ? { angelegtAm: { gte: v.zuletztAngewendetAm } } : {}), + }, + orderBy: { angelegtAm: "desc" }, + select: { status: true, fehler: true }, + }); + return { + id: v.id, + ziel: `${v.zielbenutzer}@${v.host.name} → ${(v.sshConfig as { alias: string }).alias}`, + ausgerollt: v.zustand === "AUSGEROLLT", + status: test?.status ?? null, + fehler: test?.fehler ?? null, + }; + }), + ); +} + +export type RotationsStand = Awaited>; + +/** Wo steht die Rotation? Fuer die Schrittanzeige – alles aus der Datenbank. */ +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 }; + + const [altPublic, altPrivat, neuPublic, neuPrivat, tests] = await Promise.all([ + prisma.publicVerwendung.count({ where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } } }), + prisma.privateVerwendung.count({ where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } } }), + prisma.publicVerwendung.findMany({ + where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } }, + select: { zustand: true }, + }), + prisma.privateVerwendung.findMany({ + where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } }, + select: { zustand: true }, + }), + testZiele(neu.id), + ]); + const neuOffen = [...neuPublic, ...neuPrivat].filter((v) => v.zustand !== "AUSGEROLLT").length; + // Uebernommen, sobald der Neue mindestens so viele Zuweisungen hat wie der Alte (noch) aktive. + const uebernommen = + alt.widerrufEingeleitetAm !== null || (neuPublic.length >= altPublic && neuPrivat.length >= altPrivat); + const testsGruen = tests.every((t) => t.ausgerollt && t.status === "ERLEDIGT"); + const schritt = + alt.status === "ROTIERT" + ? (5 as const) + : !uebernommen + ? (1 as const) + : alt.widerrufEingeleitetAm + ? (4 as const) + : neuOffen > 0 + ? (2 as const) + : !testsGruen + ? (3 as const) + : (4 as const); + return { alt, gesperrt, neu, schritt, neuOffen, tests, testsGruen }; +} + +/** + * Schritt 5: den alten Schluessel widerrufen – nur wenn der Nachfolger ueberall ausgerollt ist + * und jeder Verbindungstest seit dem letzten Ausrollen gruen war. + */ +export async function rotationAltWiderrufen( + altId: string, + akteur: Akteur, + optionen: { readonly privatErlaubt: boolean }, +): Promise { + const stand = await rotationsStand(altId); + if (!stand.neu) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen."); + if (stand.schritt === 5) return; + if (stand.schritt < 4) { + throw new RotationsFehler( + stand.schritt === 3 + ? "Erst wenn alle Verbindungstests mit dem neuen Schlüssel grün sind, wird der alte entfernt." + : "Der neue Schlüssel ist noch nicht überall ausgerollt.", + ); + } + await widerrufEinleiten([altId], akteur, optionen); +} diff --git a/src/server/services/verwendungen.ts b/src/server/services/verwendungen.ts index 7dbde1e..6a4cf9e 100644 --- a/src/server/services/verwendungen.ts +++ b/src/server/services/verwendungen.ts @@ -20,6 +20,9 @@ export class VerwendungsFehler extends Error { type Akteur = { readonly id: string; readonly name: string }; +/** Ein Schluessel im Widerruf (#024) bekommt keine neuen Ziele – sonst wuerde er nie fertig. */ +const WIDERRUF_LAEUFT = "Dieser Schlüssel wird gerade widerrufen und lässt sich nicht mehr zuweisen."; + export type PublicZuweisung = { readonly schluesselId: string; readonly hostId?: string | null; @@ -46,11 +49,12 @@ export async function publicVerwendungAnlegen(eingabe: PublicZuweisung, akteur: } const schluessel = await prisma.schluessel.findUnique({ where: { id: eingabe.schluesselId }, - select: { status: true, fingerprint: true, kategorie: true }, + select: { status: true, fingerprint: true, kategorie: true, widerrufEingeleitetAm: true }, }); if (!schluessel) throw new VerwendungsFehler("Diesen Schlüssel gibt es nicht."); if (schluessel.status !== "AKTIV") throw new VerwendungsFehler("Nur aktive Schlüssel lassen sich zuweisen."); + if (schluessel.widerrufEingeleitetAm) throw new VerwendungsFehler(WIDERRUF_LAEUFT); if (schluessel.kategorie === "DEPLOYMENT") { throw new VerwendungsFehler("Der Deployment-Key wird von sshkv selbst verwaltet."); } @@ -116,13 +120,47 @@ export async function publicVerwendungEntfernen(id: string, akteur: Akteur): Pro }); } +/** Ziele mehrerer Schluessel ohne Doppelte – ein gemeinsamer Dry-Run beim Massenwiderruf (#024). */ +async function zieleMehrererSchluessel( + schluesselIds: readonly string[], + mitPrivat: boolean, +): Promise { + const ziele = new Map(); + for (const schluesselId of schluesselIds) { + const liste = [ + ...(await zieleVonVerwendungen({ schluesselId })), + ...(mitPrivat ? await zieleVonPrivatVerwendungen({ schluesselId }) : []), + ]; + for (const ziel of liste) + ziele.set(`${ziel.hostId}\u0000${ziel.zielbenutzer}\u0000${ziel.art ?? "public"}`, ziel); + } + return [...ziele.values()]; +} + /** Trockenlauf fuer die Ziele eines Schluessels oder fuer ausgewaehlte Verwendungen. */ export async function trockenlaufStarten( - auswahl: { schluesselId?: string; hostId?: string; verwendungIds?: string[]; ziele?: Ziel[] }, + auswahl: { + schluesselId?: string; + /** Mehrere Schluessel in einem Dry-Run (Massenwiderruf, #024). */ + schluesselIds?: readonly string[]; + hostId?: string; + verwendungIds?: string[]; + ziele?: Ziel[]; + }, akteur: Akteur, /** Private-Key-Ziele nur fuer Admins (Freigabe Frage 3) – der Aufrufer entscheidet per darf(). */ mitPrivat = false, ): Promise { + if (auswahl.schluesselIds && !auswahl.ziele) { + return trockenlaufStarten( + { + ziele: await zieleMehrererSchluessel(auswahl.schluesselIds, mitPrivat), + ...(auswahl.schluesselIds.length === 1 ? { schluesselId: auswahl.schluesselIds[0] } : {}), + }, + akteur, + mitPrivat, + ); + } const ziele = auswahl.ziele ?? [ ...(await zieleVonVerwendungen({ ...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}), @@ -197,11 +235,13 @@ export async function privateVerwendungAnlegen(eingabe: PrivateZuweisung, akteur kategorie: true, privatGeloeschtAm: true, masterKeyVersion: true, + widerrufEingeleitetAm: true, }, }); if (!schluessel) throw new VerwendungsFehler("Diesen Schlüssel gibt es nicht."); if (schluessel.status !== "AKTIV") throw new VerwendungsFehler("Nur aktive Schlüssel lassen sich ausrollen."); + if (schluessel.widerrufEingeleitetAm) throw new VerwendungsFehler(WIDERRUF_LAEUFT); if (schluessel.kategorie !== "DIENST") throw new VerwendungsFehler("Private Keys werden nur für Service-Schlüssel ausgerollt."); // masterKeyVersion ist genau dann gesetzt, wenn ein Private Key gespeichert ist (DB-CHECK). diff --git a/src/server/services/widerruf.ts b/src/server/services/widerruf.ts new file mode 100644 index 0000000..53bb957 --- /dev/null +++ b/src/server/services/widerruf.ts @@ -0,0 +1,433 @@ +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { protokollieren } from "@/server/services/protokoll"; +import { VerwendungsFehler } from "@/server/services/verwendungen"; +import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge"; + +/** + * Widerruf und Massenwiderruf (#024). + * + * Ablauf in drei Schritten, damit der Status nie mehr verspricht, als auf den Hosts passiert ist: + * + * 1. `widerrufEinleiten`: alle Verwendungen auf ENTFERNEN, `widerrufEingeleitetAm` setzen. + * Der Schluessel bleibt AKTIV, laesst sich aber nicht mehr zuweisen. + * 2. Dry-Run und Ausrollen wie sonst auch (#016/#017). Was gelingt, wird ENTFERNT; was auf einem + * Host scheitert (nicht erreichbar, Datei geaendert), bleibt ENTFERNEN. + * 3. `widerrufAbschliessen`: erst wenn keine Verwendung mehr offen ist, wird der Status + * WIDERRUFEN (bzw. ROTIERT, wenn ein Nachfolger aus einer Rotation existiert, #023). + * + * Optional dazwischen: Zielkonten entfernen, die fuer diese Schluessel angelegt wurden. + */ +export class WiderrufFehler extends VerwendungsFehler { + constructor(nachricht: string) { + super(nachricht); + this.name = "WiderrufFehler"; + } +} + +type Akteur = { readonly id: string; readonly name: string }; + +/** Verwendungen in diesen Zustaenden zaehlen als „noch in Gebrauch“ (fuer Zielkonten). */ +const IN_GEBRAUCH = ["GEPLANT", "AUSGEROLLT", "ABWEICHUNG", "FEHLGESCHLAGEN"] as const; + +const SCHLUESSEL_KOPF = { + id: true, + name: true, + kategorie: true, + status: true, + fingerprint: true, + widerrufEingeleitetAm: true, + nachfolger: { select: { id: true, status: true } }, +} satisfies Prisma.SchluesselSelect; + +type SchluesselKopf = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>; + +/** Warum ein Schluessel nicht widerrufen werden kann – oder null. */ +export function widerrufGesperrt(s: Pick): string | null { + // Der Deployment-Key ist sshkvs eigener Zugang; ohne ihn ist kein Host mehr erreichbar. + if (s.kategorie === "DEPLOYMENT") + return "Der Deployment-Key lässt sich nicht widerrufen – er wird über eine eigene Rotation ersetzt."; + if (s.status === "WIDERRUFEN") return `„${s.name}“ ist bereits widerrufen.`; + if (s.status === "ROTIERT") return `„${s.name}“ ist bereits rotiert und von allen Hosts entfernt.`; + return null; +} + +async function schluesselLaden(ids: readonly string[]): Promise { + const eindeutig = [...new Set(ids)]; + if (eindeutig.length === 0) throw new WiderrufFehler("Bitte mindestens einen Schlüssel wählen."); + const liste = await prisma.schluessel.findMany({ + where: { id: { in: eindeutig } }, + select: SCHLUESSEL_KOPF, + orderBy: { name: "asc" }, + }); + if (liste.length !== eindeutig.length) throw new WiderrufFehler("Einen der Schlüssel gibt es nicht."); + return liste; +} + +// ---------------------------------------------------------------- Zielkonten + +export type ZielkontoKandidat = { + readonly id: string; + readonly hostId: string; + readonly hostName: string; + readonly benutzername: string; + readonly sudoModus: "KEINER" | "BEFEHLE" | "VOLL"; + /** Namen anderer Schluessel, die das Konto noch nutzen – dann bleibt es. */ + readonly nochGenutztVon: string[]; +}; + +/** + * Zielkonten, die sshkv fuer diese Schluessel angelegt hat: ein von sshkv angelegtes, noch + * vorhandenes Konto auf einem Host, auf dem einer der Schluessel (in irgendeinem Zustand) fuer + * genau diesen Benutzer zugewiesen war. Ein Zielkonto kennt seinen Schluessel nicht; die + * Zuweisung ist die einzige belastbare Verbindung. + */ +export async function zielkontoKandidaten(schluesselIds: readonly string[]): Promise { + const ids = [...new Set(schluesselIds)]; + const [oeffentlich, privat] = await Promise.all([ + prisma.publicVerwendung.findMany({ + where: { schluesselId: { in: ids } }, + select: { + zielbenutzer: true, + hostId: true, + hostgruppe: { select: { mitglieder: { select: { hostId: true } } } }, + }, + }), + prisma.privateVerwendung.findMany({ + where: { schluesselId: { in: ids } }, + select: { zielbenutzer: true, hostId: true }, + }), + ]); + const paare = new Set(); + for (const v of oeffentlich) { + const hosts = v.hostId ? [v.hostId] : (v.hostgruppe?.mitglieder.map((m) => m.hostId) ?? []); + for (const hostId of hosts) paare.add(`${hostId}\u0000${v.zielbenutzer}`); + } + for (const v of privat) paare.add(`${v.hostId}\u0000${v.zielbenutzer}`); + if (paare.size === 0) return []; + + const konten = await prisma.zielkonto.findMany({ + where: { + entferntAm: null, + vonSshkvAngelegt: true, + host: { archiviertAm: null }, + OR: [...paare].map((p) => { + const [hostId, benutzername] = p.split("\u0000") as [string, string]; + return { hostId, benutzername }; + }), + }, + select: { + id: true, + hostId: true, + benutzername: true, + sudoModus: true, + host: { select: { name: true } }, + }, + orderBy: [{ host: { name: "asc" } }, { benutzername: "asc" }], + }); + + const ergebnis: ZielkontoKandidat[] = []; + for (const konto of konten) { + const [andere, anderePrivat] = await Promise.all([ + prisma.publicVerwendung.findMany({ + where: { + schluesselId: { notIn: ids }, + zielbenutzer: konto.benutzername, + zustand: { in: [...IN_GEBRAUCH] }, + schluessel: { widerrufEingeleitetAm: null }, + OR: [{ hostId: konto.hostId }, { hostgruppe: { mitglieder: { some: { hostId: konto.hostId } } } }], + }, + select: { schluessel: { select: { name: true } } }, + }), + prisma.privateVerwendung.findMany({ + where: { + schluesselId: { notIn: ids }, + hostId: konto.hostId, + zielbenutzer: konto.benutzername, + zustand: { in: [...IN_GEBRAUCH] }, + schluessel: { widerrufEingeleitetAm: null }, + }, + select: { schluessel: { select: { name: true } } }, + }), + ]); + ergebnis.push({ + id: konto.id, + hostId: konto.hostId, + hostName: konto.host.name, + benutzername: konto.benutzername, + sudoModus: konto.sudoModus, + nochGenutztVon: [...new Set([...andere, ...anderePrivat].map((v) => v.schluessel.name))].sort(), + }); + } + return ergebnis; +} + +// ---------------------------------------------------------------- Einleiten + +export type EinleitenOptionen = { + /** Darf der Akteur Private Keys entfernen (privateKeyAusrollen)? Prueft die Aktion per darf(). */ + readonly privatErlaubt: boolean; +}; + +/** + * Schritt 1: Verwendungen zum Entfernen vormerken. Idempotent – ein zweiter Aufruf (etwa beim + * Fortsetzen) aendert an bereits vorgemerkten Verwendungen nichts. + */ +export async function widerrufEinleiten( + schluesselIds: readonly string[], + akteur: Akteur, + optionen: EinleitenOptionen, +): Promise { + const liste = await schluesselLaden(schluesselIds); + for (const s of liste) { + const grund = widerrufGesperrt(s); + if (grund) throw new WiderrufFehler(grund); + } + const ids = liste.map((s) => s.id); + const privatOffen = await prisma.privateVerwendung.count({ + where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } }, + }); + if (privatOffen > 0 && !optionen.privatErlaubt) { + throw new WiderrufFehler( + "Private Keys auf Hosts entfernt nur ein Admin. Bitte einen Admin widerrufen lassen.", + ); + } + + await prisma.$transaction(async (tx) => { + const jetzt = new Date(); + for (const s of liste) { + const [oeffentlich, privat] = await Promise.all([ + tx.publicVerwendung.findMany({ + where: { schluesselId: s.id, zustand: { not: "ENTFERNT" } }, + select: { + id: true, + zustand: true, + zuletztAngewendetAm: true, + host: { select: { name: true, archiviertAm: true } }, + }, + }), + tx.privateVerwendung.findMany({ + where: { schluesselId: s.id, zustand: { not: "ENTFERNT" } }, + select: { + id: true, + zustand: true, + zuletztAngewendetAm: true, + host: { select: { name: true, archiviertAm: true } }, + }, + }), + ]); + // Nie ausgerollt: auf dem Host steht nichts. Archivierter Host: sshkv verbindet sich dort + // nicht mehr – das Protokoll haelt fest, dass dort nichts entfernt wurde. + const sofort = (v: (typeof oeffentlich)[number]) => + (v.zustand === "GEPLANT" && !v.zuletztAngewendetAm) || Boolean(v.host?.archiviertAm); + const archiviert = [...oeffentlich, ...privat] + .filter((v) => v.host?.archiviertAm) + .map((v) => v.host!.name); + await tx.publicVerwendung.updateMany({ + where: { id: { in: oeffentlich.filter(sofort).map((v) => v.id) } }, + data: { zustand: "ENTFERNT" }, + }); + await tx.publicVerwendung.updateMany({ + where: { id: { in: oeffentlich.filter((v) => !sofort(v)).map((v) => v.id) } }, + data: { zustand: "ENTFERNEN" }, + }); + await tx.privateVerwendung.updateMany({ + where: { id: { in: privat.filter(sofort).map((v) => v.id) } }, + data: { zustand: "ENTFERNT" }, + }); + await tx.privateVerwendung.updateMany({ + where: { id: { in: privat.filter((v) => !sofort(v)).map((v) => v.id) } }, + data: { zustand: "ENTFERNEN" }, + }); + if (!s.widerrufEingeleitetAm) { + await tx.schluessel.update({ + where: { id: s.id }, + data: { widerrufEingeleitetAm: jetzt }, + select: { id: true }, + }); + } + await protokollieren( + { + akteur, + aktion: "schluessel.widerruf.eingeleitet", + ergebnis: "ERFOLG", + fingerprint: s.fingerprint, + details: { + name: s.name, + public: oeffentlich.length, + privat: privat.length, + ohneHostZugriff: oeffentlich.filter(sofort).length + privat.filter(sofort).length, + archivierteHosts: [...new Set(archiviert)].sort(), + massenwiderruf: liste.length > 1, + }, + }, + tx, + ); + } + }); +} + +// ---------------------------------------------------------------- Offene Entfernungen + +export type OffeneEntfernung = { + readonly id: string; + readonly schluesselId: string; + readonly art: "public" | "privat"; + readonly ziel: string; + readonly zustand: string; +}; + +/** Was fuer diese Schluessel noch nicht entfernt ist – fuer Seite und Abschluss. */ +export async function offeneEntfernungen(schluesselIds: readonly string[]): Promise { + const ids = [...new Set(schluesselIds)]; + const [oeffentlich, privat] = await Promise.all([ + prisma.publicVerwendung.findMany({ + where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } }, + select: { + id: true, + schluesselId: true, + zielbenutzer: true, + zustand: true, + host: { select: { name: true } }, + hostgruppe: { select: { name: true } }, + }, + orderBy: { angelegtAm: "asc" }, + }), + prisma.privateVerwendung.findMany({ + where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } }, + select: { + id: true, + schluesselId: true, + zielbenutzer: true, + pfad: true, + zustand: true, + host: { select: { name: true } }, + }, + orderBy: { angelegtAm: "asc" }, + }), + ]); + return [ + ...oeffentlich.map((v) => ({ + id: v.id, + schluesselId: v.schluesselId, + art: "public" as const, + ziel: v.host + ? `${v.zielbenutzer}@${v.host.name}:~/.ssh/authorized_keys` + : `${v.zielbenutzer}@${v.hostgruppe?.name ?? "?"} (Gruppe):~/.ssh/authorized_keys`, + zustand: v.zustand, + })), + ...privat.map((v) => ({ + id: v.id, + schluesselId: v.schluesselId, + art: "privat" as const, + ziel: `${v.zielbenutzer}@${v.host.name}:${v.pfad}`, + zustand: v.zustand, + })), + ]; +} + +// ---------------------------------------------------------------- Zielkonten entfernen + +/** + * Optionaler Schritt nach dem Ausrollen: ausgewaehlte Zielkonten entfernen. Erst wenn die + * Schluessel von den Hosts verschwunden sind – sonst haette ein Fehlschlag beim Ausrollen einen + * halb geloeschten Benutzer mit noch gueltigem Schluessel zur Folge. + */ +export async function widerrufZielkontenEntfernen( + schluesselIds: readonly string[], + zielkontoIds: readonly string[], + akteur: Akteur, +): Promise<{ id: string; beschriftung: string }[]> { + const liste = await schluesselLaden(schluesselIds); + if (liste.some((s) => !s.widerrufEingeleitetAm)) + throw new WiderrufFehler("Bitte zuerst den Widerruf einleiten."); + const offen = await offeneEntfernungen(liste.map((s) => s.id)); + if (offen.length > 0) { + throw new WiderrufFehler( + "Zielkonten werden erst entfernt, wenn die Schlüssel von allen Hosts entfernt sind.", + ); + } + const kandidaten = new Map((await zielkontoKandidaten(liste.map((s) => s.id))).map((k) => [k.id, k])); + const vorgaenge: { id: string; beschriftung: string }[] = []; + const gewaehlt = [...new Set(zielkontoIds)]; + for (const id of gewaehlt) { + const konto = kandidaten.get(id); + if (!konto) throw new WiderrufFehler("Dieses Zielkonto gehört nicht zu den gewählten Schlüsseln."); + if (konto.nochGenutztVon.length > 0) { + throw new WiderrufFehler( + `${konto.benutzername}@${konto.hostName} wird noch von „${konto.nochGenutztVon.join("“, „")}“ genutzt und bleibt.`, + ); + } + } + for (const id of gewaehlt) { + const konto = kandidaten.get(id)!; + vorgaenge.push({ + id: await vorgangEinreihen({ + art: "ZIELKONTO_ENTFERNEN", + angefordertVonId: akteur.id, + hostId: konto.hostId, + eingabe: { benutzer: konto.benutzername }, + }), + beschriftung: `${konto.benutzername}@${konto.hostName}`, + }); + } + return vorgaenge; +} + +// ---------------------------------------------------------------- Abschliessen + +export type AbschlussErgebnis = { + readonly schluesselId: string; + readonly name: string; + readonly status: "WIDERRUFEN" | "ROTIERT" | "OFFEN"; + readonly offen: OffeneEntfernung[]; +}; + +/** + * Schritt 3: Status setzen – aber nur fuer Schluessel ohne offene Entfernung. Die anderen + * bleiben im Zwischenzustand; die Oberflaeche zeigt, was fehlt. + */ +export async function widerrufAbschliessen( + schluesselIds: readonly string[], + akteur: Akteur, +): Promise { + const liste = await schluesselLaden(schluesselIds); + const offen = await offeneEntfernungen(liste.map((s) => s.id)); + const ergebnisse: AbschlussErgebnis[] = []; + for (const s of liste) { + if (s.status === "WIDERRUFEN" || s.status === "ROTIERT") { + ergebnisse.push({ schluesselId: s.id, name: s.name, status: s.status, offen: [] }); + continue; + } + if (!s.widerrufEingeleitetAm) throw new WiderrufFehler(`Für „${s.name}“ ist kein Widerruf eingeleitet.`); + const eigene = offen.filter((o) => o.schluesselId === s.id); + if (eigene.length > 0) { + ergebnisse.push({ schluesselId: s.id, name: s.name, status: "OFFEN", offen: eigene }); + continue; + } + // Ein aktiver Nachfolger heisst: der Widerruf ist der letzte Schritt einer Rotation (#023). + const status = s.nachfolger?.status === "AKTIV" ? "ROTIERT" : "WIDERRUFEN"; + await prisma.$transaction(async (tx) => { + const geaendert = await tx.schluessel.updateMany({ + where: { id: s.id, status: { in: ["AKTIV", "ABGELAUFEN"] } }, + data: { status }, + }); + if (geaendert.count === 0) return; + await protokollieren( + { + akteur, + aktion: status === "ROTIERT" ? "schluessel.rotiert" : "schluessel.widerrufen", + ergebnis: "ERFOLG", + fingerprint: s.fingerprint, + details: { + name: s.name, + ...(s.nachfolger && status === "ROTIERT" ? { nachfolgerId: s.nachfolger.id } : {}), + }, + }, + tx, + ); + }); + ergebnisse.push({ schluesselId: s.id, name: s.name, status, offen: [] }); + } + return ergebnisse; +} diff --git a/src/worker/ablaeufe/privat.ts b/src/worker/ablaeufe/privat.ts index 8219f26..61ede27 100644 --- a/src/worker/ablaeufe/privat.ts +++ b/src/worker/ablaeufe/privat.ts @@ -60,7 +60,10 @@ async function gewollteDateien( }); const dateien: Gewollt[] = []; - const config: SshConfigEintrag[] = []; + // Je Alias genau ein Eintrag: bei einer Rotation (#023) tragen alte und neue Zuweisung denselben + // Alias. Die juengere gewinnt (Reihenfolge nach angelegtAm) – ssh nimmt sonst den ersten Treffer, + // und der Alias zeigte weiter auf den alten Schluessel. Die Position bleibt die des ersten. + const config = new Map(); const knownHosts: string[] = []; for (const verwendung of verwendungen) { const pfad = relativenPfadPruefen(verwendung.pfad); @@ -100,7 +103,7 @@ async function gewollteDateien( port?: number; } | null; if (sshConfig?.alias && sshConfig.hostName && sshConfig.benutzer) { - config.push({ + config.set(sshConfig.alias, { alias: sshConfig.alias, hostName: sshConfig.hostName, benutzer: sshConfig.benutzer, @@ -127,7 +130,7 @@ async function gewollteDateien( dateien.push({ art: "config", pfad: ".ssh/config", - inhalt: Buffer.from(sshConfigZeilen(config).join("\n")), + inhalt: Buffer.from(sshConfigZeilen([...config.values()]).join("\n")), modus: "0600", }); dateien.push({ diff --git a/src/worker/ablaeufe/schluessel.ts b/src/worker/ablaeufe/schluessel.ts index 6b5dd57..0ebf100 100644 --- a/src/worker/ablaeufe/schluessel.ts +++ b/src/worker/ablaeufe/schluessel.ts @@ -103,12 +103,15 @@ export const schluesselErzeugenHandler: Handler = async ({ vorgang, geheim }) => vorlageId: eingabe.vorlageId, erstelltVonId: akteur === "System" ? null : akteur.id, passphraseGeschuetzt: Boolean(passphrase), + vorgaengerId: eingabe.vorgaengerId ?? null, ...neu.versiegelt, }, select: { id: true }, }); } catch (fehler) { if (istEindeutigkeitsFehler(fehler)) { + // vorgaengerId ist eindeutig: zwei Rotationen desselben Schluessels gleichzeitig (#023). + 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.`, ); @@ -127,6 +130,7 @@ export const schluesselErzeugenHandler: Handler = async ({ vorgang, geheim }) => typ, kategorie: "DIENST", passphraseGeschuetzt: Boolean(passphrase), + ...(eingabe.vorgaengerId ? { vorgaengerId: eingabe.vorgaengerId } : {}), }, }); return { ergebnis: { schluesselId: neu.id, fingerprint: neu.fingerprint } }; diff --git a/tests/e2e/hilfen.ts b/tests/e2e/hilfen.ts new file mode 100644 index 0000000..edcb594 --- /dev/null +++ b/tests/e2e/hilfen.ts @@ -0,0 +1,90 @@ +import { execFileSync } from "node:child_process"; +import { expect, type Page } from "@playwright/test"; +import "dotenv/config"; + +/** + * Gemeinsame Schritte fuer Widerruf (#024) und Rotation (#023): anmelden, Hosts einrichten und + * der Kern-Anwendungsfall aus kern-anwendungsfall.spec.ts als Ausgangslage. + */ +const ROOT_PASSWORT = process.env["SSHKV_TEST_ROOT_PASSWORT"] || "sshkv-test"; +export const PORT = { + a: (process.env["SSHKV_TEST_ZIEL_A"] ?? "127.0.0.1:2221").split(":")[1] ?? "2221", + b: (process.env["SSHKV_TEST_ZIEL_B"] ?? "127.0.0.1:2222").split(":")[1] ?? "2222", +}; + +export function aufZiel(ziel: "ziel-a" | "ziel-b", befehl: string): string { + return execFileSync( + "docker", + ["compose", "-f", "docker-compose.test.yml", "exec", "-T", ziel, "sh", "-c", befehl], + { encoding: "utf8" }, + ); +} + +export function zieleZuruecksetzen(): void { + const reset = + "userdel -r sshkv 2>/dev/null; userdel -r deploy 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true"; + aufZiel("ziel-a", reset); + aufZiel("ziel-b", reset); +} + +export async function anmelden(page: Page): Promise { + await page.goto("/anmelden"); + await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click(); + await page.locator('button[data-rolle="admin"]').click(); + await page.getByRole("button", { name: "Anmelden", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible(); +} + +export async function hostEinrichten(page: Page, name: string, port: string): Promise { + await page.goto("/hosts/neu"); + await page.getByLabel("Name").fill(name); + await page.getByLabel("Adresse").fill("127.0.0.1"); + await page.getByLabel("Port").fill(port); + await page.getByRole("button", { name: "Host anlegen" }).click(); + await page.getByRole("button", { name: "Host-Key abrufen" }).click(); + await page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" }).click({ timeout: 30_000 }); + await page.getByLabel("Passwort", { exact: true }).fill(ROOT_PASSWORT); + await page.getByRole("button", { name: "Zugang einrichten" }).click(); + await expect(page.getByRole("button", { name: "Verbindung prüfen" })).toBeVisible({ timeout: 60_000 }); +} + +export async function archivieren(page: Page, name: string): Promise { + await page.goto("/hosts"); + await page.getByRole("link", { name }).first().click(); + await page.getByRole("button", { name: "Host archivieren" }).click(); + await page.getByRole("dialog").getByRole("button", { name: "Archivieren", exact: true }).click(); + await expect(page).toHaveURL(/\/hosts$/); +} + +/** + * Kern-Anwendungsfall: Schluessel erzeugen, Private Key auf A (Alias zu-b), Public Key auf B, + * Dry-Run, anwenden. Endet auf der Schluesselseite; liefert deren Adresse. + */ +export async function kernAufbauen(page: Page, zweck: string, hostA: string, hostB: string): Promise { + 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 }); + + await page.getByRole("button", { name: "Ausrollen" }).first().click(); + const privat = page.getByRole("dialog", { name: "Private Key ausrollen" }); + await privat.getByLabel("Quell-Host (bekommt den Private Key)").selectOption({ label: hostA }); + await privat.getByLabel("Ziel-Host (für known_hosts)").selectOption({ label: hostB }); + await privat.getByRole("textbox", { name: "Alias" }).fill("zu-b"); + await privat.getByRole("textbox", { name: "HostName" }).fill("ziel-b"); + await privat.getByRole("spinbutton", { name: "Port" }).fill("22"); + await privat.getByRole("button", { name: "Zuweisen" }).click(); + await expect(privat).toBeHidden(); + + 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: hostB }); + 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 }); + expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root"); + return page.url(); +} diff --git a/tests/e2e/rotation.spec.ts b/tests/e2e/rotation.spec.ts new file mode 100644 index 0000000..43a35dd --- /dev/null +++ b/tests/e2e/rotation.spec.ts @@ -0,0 +1,71 @@ +import { expect, test } from "@playwright/test"; +import { + anmelden, + archivieren, + aufZiel, + hostEinrichten, + kernAufbauen, + PORT, + zieleZuruecksetzen, +} from "./hilfen"; + +/** + * Gefuehrte Rotation im Browser (#023), gebauter Stand + Worker: Ausgangslage ist der + * Kern-Anwendungsfall. Neuer Schluessel → Zuweisungen uebernehmen → ausrollen → Verbindung + * testen → alten widerrufen. `ssh zu-b` von A funktioniert vorher und nachher. + */ +test.setTimeout(300_000); + +test.beforeAll(() => zieleZuruecksetzen()); + +test("Rotation mit Schrittanzeige", async ({ page }) => { + const kennung = Date.now().toString(36); + const hostA = `zz-e2e-a-${kennung}`; + const hostB = `zz-e2e-b-${kennung}`; + const zweck = `zz-e2e Rotation ${kennung}`; + + await anmelden(page); + await hostEinrichten(page, hostA, PORT.a); + await hostEinrichten(page, hostB, PORT.b); + await kernAufbauen(page, zweck, hostA, hostB); + const altDateien = aufZiel("ziel-a", "ls /root/.ssh"); + await page.reload(); + + await page.getByRole("link", { name: "Rotieren" }).click(); + await expect(page.getByRole("heading", { name: `„${zweck}“ rotieren` })).toBeVisible(); + await page.getByRole("button", { name: "Neuen Schlüssel erzeugen und Zuweisungen übernehmen" }).click(); + + // Schritt 3: neuen Schluessel ausrollen. + await expect(page.getByRole("button", { name: "Dry-Run starten" })).toBeVisible({ timeout: 60_000 }); + await page.getByRole("button", { name: "Dry-Run starten" }).click(); + // Derselbe Alias, neue Datei: nur die IdentityFile-Zeile aendert sich. + await expect(page.getByText(/IdentityFile ~\/\.ssh\/.*-r\d{8}$/)).toBeVisible({ timeout: 30_000 }); + await page.screenshot({ path: "docs/anleitung/bilder/rotation-desktop.png", fullPage: true }); + await page.getByRole("button", { name: /Änderung anwenden/ }).click(); + + // Schritt 4: Verbindungstest mit dem neuen Schluessel. + await page.getByRole("button", { name: "Verbindungen testen" }).click({ timeout: 60_000 }); + + // Schritt 5: alten Schluessel widerrufen. + await page.getByRole("button", { name: "Alten Schlüssel widerrufen" }).click({ timeout: 60_000 }); + await page.getByRole("button", { name: "Dry-Run starten" }).click({ timeout: 30_000 }); + await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 }); + await expect(page.getByRole("heading", { name: "Rotation abgeschlossen" })).toBeVisible({ + timeout: 60_000, + }); + + expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root"); + const neueDateien = aufZiel("ziel-a", "ls /root/.ssh"); + const alterKey = altDateien.split("\n").find((d) => d.startsWith("id_ed25519") && !d.endsWith(".pub"))!; + expect(neueDateien.split("\n")).not.toContain(alterKey); + expect(neueDateien).toMatch(/-r\d{8}\n/); + + // Kette auf der Seite des neuen Schluessels. + await page.getByRole("link", { name: /\(rotiert / }).click(); + const kette = page.getByRole("list", { name: "Rotationskette" }); + await expect(kette.getByRole("link", { name: zweck })).toBeVisible(); + await expect(kette.getByText("rotiert", { exact: true })).toBeVisible(); + + await archivieren(page, hostA); + await archivieren(page, hostB); +}); diff --git a/tests/e2e/widerruf.spec.ts b/tests/e2e/widerruf.spec.ts new file mode 100644 index 0000000..dda76c4 --- /dev/null +++ b/tests/e2e/widerruf.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from "@playwright/test"; +import { + anmelden, + archivieren, + aufZiel, + hostEinrichten, + kernAufbauen, + PORT, + zieleZuruecksetzen, +} from "./hilfen"; + +/** + * Widerruf im Browser (#024), gebauter Stand + Worker: Ausgangslage ist der Kern-Anwendungsfall + * (Private Key auf A, Public Key auf B). „Widerrufen“ → Dialog → Dry-Run → anwenden → Status + * „widerrufen“; danach kommt A nicht mehr nach B. + */ +test.setTimeout(240_000); + +test.beforeAll(() => zieleZuruecksetzen()); + +test("Widerruf mit einem Klick", async ({ page }) => { + const kennung = Date.now().toString(36); + const hostA = `zz-e2e-a-${kennung}`; + const hostB = `zz-e2e-b-${kennung}`; + const zweck = `zz-e2e Widerruf ${kennung}`; + + await anmelden(page); + await hostEinrichten(page, hostA, PORT.a); + await hostEinrichten(page, hostB, PORT.b); + await kernAufbauen(page, zweck, hostA, hostB); + await page.reload(); + + // Dialog: zeigt genau, was passiert. + await page.getByRole("button", { name: "Widerrufen" }).click(); + const dialog = page.getByRole("dialog", { name: `„${zweck}“ widerrufen?` }); + await expect(dialog.getByText(`root@${hostB}`)).toBeVisible(); + await expect(dialog.getByText(`root@${hostA}:~/.ssh/`)).toBeVisible(); + await expect(dialog.getByText(/config-Eintrag „Host zu-b“/)).toBeVisible(); + await page.screenshot({ path: "docs/anleitung/bilder/widerruf-desktop.png" }); + await dialog.getByRole("button", { name: "Widerruf vorbereiten" }).click(); + + // Zwischenzustand: noch nicht widerrufen, nichts mehr zuweisbar. + await expect(page.getByRole("heading", { name: "Widerruf läuft" })).toBeVisible(); + await expect(page.getByText("Widerruf unvollständig")).toBeVisible(); + await expect(page.getByRole("button", { name: "Ausrollen" })).toHaveCount(0); + + 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.screenshot({ path: "docs/anleitung/bilder/widerruf-dry-run-desktop.png", fullPage: true }); + await page.getByRole("button", { name: /Änderung anwenden/ }).click(); + + await expect(page.getByRole("heading", { name: "Widerrufen", exact: true })).toBeVisible({ + timeout: 60_000, + }); + await expect(page.getByText("Widerruf unvollständig")).toHaveCount(0); + expect(aufZiel("ziel-a", "ls /root/.ssh")).not.toContain("id_ed25519"); + expect(() => aufZiel("ziel-a", "ssh -o BatchMode=yes -o ConnectTimeout=5 zu-b true")).toThrow(); + + await archivieren(page, hostA); + await archivieren(page, hostB); +}); diff --git a/tests/integration/rotation.test.ts b/tests/integration/rotation.test.ts new file mode 100644 index 0000000..2b368f9 --- /dev/null +++ b/tests/integration/rotation.test.ts @@ -0,0 +1,126 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { prisma } from "@/lib/db"; +import { rotationsDatum } from "@/lib/rotation"; +import { + rotationAltWiderrufen, + rotationStarten, + rotationUebernehmen, + rotationsStand, +} from "@/server/services/rotation"; +import { zieleVonPrivatVerwendungen, zieleVonVerwendungen } from "@/server/services/soll"; +import { privateVerwendungAnlegen, publicVerwendungAnlegen } from "@/server/services/verwendungen"; +import { widerrufAbschliessen } from "@/server/services/widerruf"; +import { deploymentKeySicherstellen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse"; +import { datenbankLeeren } from "../hilfen/db"; +import { vorgangImWorker, warteschlangeStoppen } from "../hilfen/vorgang"; +import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host"; +import { befehlAlsRoot, zielA, zielB, zielBereit } from "./hilfen/ziele"; + +/** + * Rotation gegen echte Hosts (#023): der neue Schluessel funktioniert (Verbindungstest gruen), + * der alte ist danach von beiden Hosts verschwunden, und `ssh ` von A geht weiter – + * derselbe Alias zeigt jetzt auf die neue Datei. + */ +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; +}); + +async function trockenlaufUndAusrollen(schluesselId: string) { + const ziele = [ + ...(await zieleVonVerwendungen({ schluesselId })), + ...(await zieleVonPrivatVerwendungen({ schluesselId })), + ]; + const trockenlauf = await vorgangLaufen("TROCKENLAUF", { eingabe: { ziele }, angefordertVonId: AKTEUR.id }); + expect(trockenlauf.status).toBe("ERLEDIGT"); + const ausrollen = await vorgangLaufen("AUSROLLEN", { + eingabe: { trockenlaufId: trockenlauf.id }, + angefordertVonId: AKTEUR.id, + }); + 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"); + const paar = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:backup"); + const alt = await prisma.schluessel.create({ + data: { + id: paar.id, + name: "Backup", + slug: "backup", + typ: "ED25519", + kategorie: "DIENST", + publicKey: paar.publicKeyZeile, + fingerprint: paar.fingerprint, + kommentar: "sshkv:backup", + ...paar.versiegelt, + }, + select: { id: true, publicKey: true }, + }); + await privateVerwendungAnlegen( + { + schluesselId: alt.id, + hostId: a.id, + zielbenutzer: "root", + pfad: "~/.ssh/id_backup", + sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 }, + knownHostsFuer: [b.id], + }, + AKTEUR, + ); + await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: b.id, zielbenutzer: "root" }, AKTEUR); + await trockenlaufUndAusrollen(alt.id); + + // 1–2: Nachfolger erzeugen, Zuweisungen uebernehmen. + 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 }, + }); + + // 3: ausrollen – beide Schluessel liegen jetzt nebeneinander, der Alias zeigt auf den neuen. + await trockenlaufUndAusrollen(neu.id); + const neuerPfad = `id_backup-r${rotationsDatum().kompakt}`; + const config = (await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout; + expect(config.match(/^Host zu-b$/gm)).toHaveLength(1); + expect(config).toContain(`IdentityFile ~/.ssh/${neuerPfad}`); + + // 4: Verbindungstest mit dem neuen Schluessel. + const verwendung = await prisma.privateVerwendung.findFirstOrThrow({ where: { schluesselId: neu.id } }); + const test = await vorgangLaufen("VERBINDUNGSTEST", { eingabe: { privatVerwendungId: verwendung.id } }); + expect(test.fehler).toBeNull(); + expect((await rotationsStand(alt.id)).schritt).toBe(4); + + // 5: alten widerrufen. + await rotationAltWiderrufen(alt.id, AKTEUR, { privatErlaubt: true }); + await trockenlaufUndAusrollen(alt.id); + expect((await widerrufAbschliessen([alt.id], AKTEUR))[0]?.status).toBe("ROTIERT"); + + const authorized = (await befehlAlsRoot(zielB, "cat /root/.ssh/authorized_keys")).stdout; + expect(authorized).not.toContain(alt.publicKey.split(" ")[1]!); + expect(authorized).toContain(neu.publicKey.split(" ")[1]!); + const dateien = (await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout.split("\n"); + expect(dateien).not.toContain("id_backup"); + expect(dateien).toContain(neuerPfad); + const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un"); + expect(ssh.stdout.trim()).toBe("root"); + }); +}); diff --git a/tests/integration/widerruf.test.ts b/tests/integration/widerruf.test.ts new file mode 100644 index 0000000..a36273e --- /dev/null +++ b/tests/integration/widerruf.test.ts @@ -0,0 +1,171 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { prisma } from "@/lib/db"; +import { zieleVonPrivatVerwendungen, zieleVonVerwendungen } from "@/server/services/soll"; +import { privateVerwendungAnlegen, publicVerwendungAnlegen } from "@/server/services/verwendungen"; +import { + widerrufAbschliessen, + widerrufEinleiten, + widerrufZielkontenEntfernen, +} from "@/server/services/widerruf"; +import type { Plan } from "@/worker/ablaeufe/ausrollen"; +import { vorgangAusfuehren } from "@/worker/ausfuehren"; +import { deploymentKeySicherstellen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse"; +import { HANDLER } from "@/worker/handler"; +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"; + +/** + * Widerruf gegen echte Hosts (#024): Public Key aus authorized_keys von B, Private Key samt + * config- und known_hosts-Eintrag von A, optional das Zielkonto deploy auf B. Danach scheitert + * `ssh ` von A. Scheitert ein Host, bleibt der Schluessel im Zwischenzustand. + */ + +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; userdel -r deploy 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true"; + await Promise.all([befehlAlsRoot(zielA, reset), befehlAlsRoot(zielB, reset)]); + const admin = await prisma.benutzer.create({ data: { name: "Test-Admin", rolle: "ADMIN" } }); + AKTEUR.id = admin.id; +}); + +async function dienstSchluessel(name: string) { + const neu = await schluesselpaarVersiegeltErzeugen("ED25519", `sshkv:${name}`); + return prisma.schluessel.create({ + data: { + id: neu.id, + name, + slug: name, + typ: "ED25519", + kategorie: "DIENST", + publicKey: neu.publicKeyZeile, + fingerprint: neu.fingerprint, + kommentar: `sshkv:${name}`, + ...neu.versiegelt, + }, + select: { id: true, fingerprint: true, publicKey: true }, + }); +} + +async function trockenlaufUndAusrollen(schluesselId: string) { + const ziele = [ + ...(await zieleVonVerwendungen({ schluesselId })), + ...(await zieleVonPrivatVerwendungen({ schluesselId })), + ]; + const trockenlauf = await vorgangLaufen("TROCKENLAUF", { eingabe: { ziele }, angefordertVonId: AKTEUR.id }); + expect(trockenlauf.status).toBe("ERLEDIGT"); + const ausrollen = await vorgangLaufen("AUSROLLEN", { + eingabe: { trockenlaufId: trockenlauf.id }, + angefordertVonId: AKTEUR.id, + }); + return { plan: trockenlauf.plan as unknown as Plan, ausrollen }; +} + +/** Deployment-Zugang wie im Assistenten: deploy auf B, Private Key auf A, Alias zu-b. */ +async function deploymentZugang() { + const a = await eingerichteterHost(zielA, "ziel-a"); + const b = await eingerichteterHost(zielB, "ziel-b"); + const konto = await vorgangLaufen("ZIELKONTO_ANLEGEN", { + hostId: b.id, + eingabe: { benutzer: "deploy", modus: "KEINER", befehle: [] }, + }); + expect(konto.fehler).toBeNull(); + const schluessel = await dienstSchluessel("deploy-a-b"); + await privateVerwendungAnlegen( + { + schluesselId: schluessel.id, + hostId: a.id, + zielbenutzer: "root", + pfad: "~/.ssh/id_ed25519_deploy", + sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "deploy", port: 22 }, + knownHostsFuer: [b.id], + }, + AKTEUR, + ); + await publicVerwendungAnlegen( + { schluesselId: schluessel.id, hostId: b.id, zielbenutzer: "deploy" }, + AKTEUR, + ); + const { ausrollen } = await trockenlaufUndAusrollen(schluessel.id); + expect(ausrollen.fehler).toBeNull(); + const vorher = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un"); + expect(vorher.stdout.trim()).toBe("deploy"); + return { a, b, schluessel }; +} + +describe("Widerruf", () => { + it("entfernt Public Key, Private Key, config, known_hosts und auf Wunsch das Zielkonto", async () => { + const { schluessel } = await deploymentZugang(); + const blob = schluessel.publicKey.split(" ")[1]!; + + await widerrufEinleiten([schluessel.id], AKTEUR, { privatErlaubt: true }); + const { plan, ausrollen } = await trockenlaufUndAusrollen(schluessel.id); + expect(plan.ziele.every((z) => !z.fehler)).toBe(true); + expect(ausrollen.fehler).toBeNull(); + + expect((await befehlAlsRoot(zielB, "cat /home/deploy/.ssh/authorized_keys")).stdout).not.toContain(blob); + const ssh = await befehlAlsRoot(zielA, "ls -a /root/.ssh; cat /root/.ssh/config /root/.ssh/known_hosts"); + expect(ssh.stdout).not.toContain("id_ed25519_deploy"); + expect(ssh.stdout).not.toContain("zu-b"); + expect(ssh.stdout).not.toContain("ssh-ed25519"); + const danach = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o ConnectTimeout=5 zu-b id -un"); + expect(danach.code).not.toBe(0); + + // Zielkonto: erst nach den Schluesseln, dann Abschluss. + const konto = await prisma.zielkonto.findFirstOrThrow({ where: { benutzername: "deploy" } }); + const vorgaenge = await widerrufZielkontenEntfernen([schluessel.id], [konto.id], AKTEUR); + for (const v of vorgaenge) await vorgangAusfuehren(v.id, HANDLER); + expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgaenge[0]!.id } })).fehler).toBeNull(); + expect((await befehlAlsRoot(zielB, "id deploy")).code).not.toBe(0); + + const [ergebnis] = await widerrufAbschliessen([schluessel.id], AKTEUR); + expect(ergebnis?.status).toBe("WIDERRUFEN"); + const protokoll = await prisma.protokoll.findMany({ where: { fingerprint: schluessel.fingerprint } }); + expect(protokoll.map((p) => p.aktion)).toEqual( + expect.arrayContaining(["schluessel.widerruf.eingeleitet", "schluessel.widerrufen"]), + ); + expect(JSON.stringify(protokoll.map((p) => [p.details, p.diff]))).not.toContain("PRIVATE KEY"); + }); + + it("bleibt im Zwischenzustand, wenn ein Host nicht erreichbar ist", async () => { + const { b, schluessel } = await deploymentZugang(); + await widerrufEinleiten([schluessel.id], AKTEUR, { privatErlaubt: true }); + + // B „faellt aus“: ein Port, auf dem niemand lauscht. + await prisma.host.update({ where: { id: b.id }, data: { port: 1 } }); + const { plan } = await trockenlaufUndAusrollen(schluessel.id); + expect(plan.ziele.find((z) => z.hostName === "ziel-b")?.fehler).toBeTruthy(); + expect(plan.ziele.find((z) => z.hostName === "ziel-a")?.fehler).toBeUndefined(); + + const [offen] = await widerrufAbschliessen([schluessel.id], AKTEUR); + expect(offen?.status).toBe("OFFEN"); + expect(offen?.offen.map((o) => o.ziel)).toEqual(["deploy@ziel-b:~/.ssh/authorized_keys"]); + expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: schluessel.id } })).status).toBe( + "AKTIV", + ); + // Was ging, ist trotzdem weg: der Private Key auf A. + expect((await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout).not.toContain("id_ed25519_deploy"); + + // B ist zurueck: ein neuer Dry-Run erledigt den Rest. + await prisma.host.update({ where: { id: b.id }, data: { port: zielB.port } }); + const { ausrollen } = await trockenlaufUndAusrollen(schluessel.id); + expect(ausrollen.fehler).toBeNull(); + const [fertig] = await widerrufAbschliessen([schluessel.id], AKTEUR); + expect(fertig?.status).toBe("WIDERRUFEN"); + }); +}); diff --git a/tests/rotation.test.ts b/tests/rotation.test.ts new file mode 100644 index 0000000..9ee8d34 --- /dev/null +++ b/tests/rotation.test.ts @@ -0,0 +1,260 @@ +import { randomBytes } from "node:crypto"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { prisma } from "@/lib/db"; +import { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation"; +import { rotationsKette } from "@/server/queries/schluessel"; +import { + RotationsFehler, + rotationAltWiderrufen, + rotationGesperrt, + rotationStarten, + rotationUebernehmen, + rotationsStand, +} from "@/server/services/rotation"; +import { widerrufAbschliessen } from "@/server/services/widerruf"; +import { masterKeyVergessen } from "@/worker/geheimnisse"; +import { datenbankLeeren } from "./hilfen/db"; +import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang"; +import { ed25519Blob, zeile } from "./ssh/hilfen"; + +/** Gefuehrte Rotation (#023): Regeln, Schritte, Rechte, Protokoll. */ + +let verzeichnis: string; +let admin: { id: string; name: string }; +let a: { id: string }; +let b: { id: string }; + +beforeAll(() => { + verzeichnis = mkdtempSync(path.join(tmpdir(), "sshkv-master-")); + const datei = path.join(verzeichnis, "master.key"); + writeFileSync(datei, randomBytes(32), { mode: 0o600 }); + process.env["SSHKV_MASTER_KEY_FILE"] = datei; + masterKeyVergessen(); +}); + +afterAll(async () => { + rmSync(verzeichnis, { recursive: true, force: true }); + await warteschlangeStoppen(); +}); + +beforeEach(async () => { + await datenbankLeeren(); + admin = await prisma.benutzer.create({ + data: { name: "Ada Admin", rolle: "ADMIN" }, + select: { id: true, name: true }, + }); + a = await prisma.host.create({ data: { name: "srv-a", adresse: "10.0.0.1" }, select: { id: true } }); + b = await prisma.host.create({ data: { name: "srv-b", adresse: "10.0.0.2" }, select: { id: true } }); +}); + +/** Ein ausgerollter Dienst-Key wie nach dem Assistenten: Private auf A (Alias), Public auf B. */ +async function alterSchluessel() { + const alt = await prisma.schluessel.create({ + data: { + name: "Backup A → B", + slug: "backup-a-b", + typ: "ED25519", + kategorie: "DIENST", + publicKey: zeile("ssh-ed25519", ed25519Blob(), "alt"), + fingerprint: "SHA256:alt", + kommentar: "alt", + tags: ["backup"], + ablaufAm: new Date("2020-01-01T00:00:00Z"), + }, + select: { id: true }, + }); + const jetzt = new Date(); + await prisma.publicVerwendung.create({ + data: { + schluesselId: alt.id, + hostId: b.id, + zielbenutzer: "backup", + optionen: 'from="10.0.0.1"', + zustand: "AUSGEROLLT", + zuletztAngewendetAm: jetzt, + }, + }); + await prisma.privateVerwendung.create({ + data: { + schluesselId: alt.id, + hostId: a.id, + zielbenutzer: "root", + pfad: "~/.ssh/id_backup", + sshConfig: { alias: "zu-b", hostName: "10.0.0.2", benutzer: "backup", port: 22 }, + knownHostsFuer: [b.id], + beimWiderruf: "UMBENENNEN", + zustand: "AUSGEROLLT", + zuletztAngewendetAm: jetzt, + }, + }); + return alt.id; +} + +async function nachfolgerErzeugen(altId: string): Promise { + const vorgangId = await rotationStarten(altId, admin); + await vorgangImWorker(vorgangId); + const neu = await prisma.schluessel.findFirstOrThrow({ + where: { vorgaengerId: altId }, + select: { id: true }, + }); + return neu.id; +} + +describe("Namen und Pfade", () => { + it("ersetzt ein frueheres Rotations-Suffix", () => { + expect(rotationsName("Backup", "12.10.2026")).toBe("Backup (rotiert 12.10.2026)"); + expect(rotationsName("Backup (rotiert 01.01.2026)", "12.10.2026")).toBe("Backup (rotiert 12.10.2026)"); + expect(rotationsName("x".repeat(200), "12.10.2026", 120)).toHaveLength(120); + }); + + it("haengt -rJJJJMMTT an und weicht belegten Pfaden aus", () => { + expect(rotationsPfad("~/.ssh/id_backup", "20261012", new Set())).toBe("~/.ssh/id_backup-r20261012"); + expect(rotationsPfad("~/.ssh/id_backup-r20260101", "20261012", new Set())).toBe( + "~/.ssh/id_backup-r20261012", + ); + expect( + rotationsPfad("~/.ssh/id_backup-r20261012", "20261012", new Set(["~/.ssh/id_backup-r20261012"])), + ).toBe("~/.ssh/id_backup-r20261012-2"); + }); + + it("rechnet das Datum in Berlin", () => { + expect(rotationsDatum(new Date("2026-10-11T23:30:00Z"))).toEqual({ + anzeige: "12.10.2026", + kompakt: "20261012", + }); + }); +}); + +describe("Was sich rotieren laesst", () => { + const basis = { + status: "AKTIV" as const, + masterKeyVersion: 1, + widerrufEingeleitetAm: null, + privatZuweisungen: 0, + }; + it("verlangt bei Personen-Keys einen neuen Public Key von aussen", () => { + expect(rotationGesperrt({ ...basis, kategorie: "PERSON", masterKeyVersion: null })).toMatchObject({ + neuerPublicKeyNoetig: true, + }); + expect(rotationGesperrt({ ...basis, kategorie: "DIENST", masterKeyVersion: null })).toMatchObject({ + neuerPublicKeyNoetig: true, + }); + // Private Key aus sshkv geloescht, aber ausgerollt: sshkv erzeugt und verteilt den neuen selbst. + expect( + rotationGesperrt({ ...basis, kategorie: "DIENST", masterKeyVersion: null, privatZuweisungen: 1 }), + ).toBeNull(); + expect(rotationGesperrt({ ...basis, kategorie: "DEPLOYMENT" })?.grund).toMatch(/eigene Rotation/); + expect(rotationGesperrt({ ...basis, kategorie: "DIENST", status: "WIDERRUFEN" })).not.toBeNull(); + }); +}); + +describe("Rotation Schritt fuer Schritt", () => { + it("fuehrt vom neuen Schluessel bis ROTIERT und haelt die Reihenfolge ein", async () => { + const altId = await alterSchluessel(); + expect((await rotationsStand(altId)).schritt).toBe(0); + + const neuId = await nachfolgerErzeugen(altId); + const neu = await prisma.schluessel.findUniqueOrThrow({ + where: { id: neuId }, + select: { name: true, typ: true, tags: true, ablaufAm: true, kategorie: true }, + }); + expect(neu.name).toBe(`Backup A → B (rotiert ${rotationsDatum().anzeige})`); + expect(neu).toMatchObject({ typ: "ED25519", kategorie: "DIENST", tags: ["backup"], ablaufAm: null }); + expect((await rotationsStand(altId)).schritt).toBe(1); + await expect(rotationStarten(altId, admin)).rejects.toThrow(/schon einen Nachfolger/); + + await expect(rotationUebernehmen(altId, admin, { privatErlaubt: false })).rejects.toBeInstanceOf( + RotationsFehler, + ); + expect(await rotationUebernehmen(altId, admin, { privatErlaubt: true })).toEqual({ + public: 1, + privat: 1, + }); + expect(await rotationUebernehmen(altId, admin, { privatErlaubt: true })).toEqual({ + public: 0, + privat: 0, + }); + + const oeffentlich = await prisma.publicVerwendung.findFirstOrThrow({ where: { schluesselId: neuId } }); + expect(oeffentlich).toMatchObject({ hostId: b.id, zielbenutzer: "backup", optionen: 'from="10.0.0.1"' }); + const privat = await prisma.privateVerwendung.findFirstOrThrow({ where: { schluesselId: neuId } }); + expect(privat).toMatchObject({ + hostId: a.id, + zielbenutzer: "root", + pfad: `~/.ssh/id_backup-r${rotationsDatum().kompakt}`, + sshConfig: { alias: "zu-b", hostName: "10.0.0.2", benutzer: "backup", port: 22 }, + knownHostsFuer: [b.id], + beimWiderruf: "UMBENENNEN", + }); + expect((await rotationsStand(altId)).schritt).toBe(2); + await expect(rotationAltWiderrufen(altId, admin, { privatErlaubt: true })).rejects.toThrow( + /noch nicht überall/, + ); + + // Ausgerollt, aber noch nicht getestet. + const jetzt = new Date(); + await prisma.publicVerwendung.updateMany({ + where: { schluesselId: neuId }, + data: { zustand: "AUSGEROLLT" }, + }); + await prisma.privateVerwendung.updateMany({ + where: { schluesselId: neuId }, + data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt }, + }); + expect((await rotationsStand(altId)).schritt).toBe(3); + await prisma.vorgang.create({ + data: { art: "VERBINDUNGSTEST", status: "FEHLGESCHLAGEN", eingabe: { privatVerwendungId: privat.id } }, + }); + await expect(rotationAltWiderrufen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/grün/); + + await prisma.vorgang.create({ + data: { art: "VERBINDUNGSTEST", status: "ERLEDIGT", eingabe: { privatVerwendungId: privat.id } }, + }); + expect((await rotationsStand(altId)).schritt).toBe(4); + await rotationAltWiderrufen(altId, admin, { privatErlaubt: true }); + expect( + await prisma.publicVerwendung.count({ where: { schluesselId: altId, zustand: "ENTFERNEN" } }), + ).toBe(1); + // Der Nachfolger bleibt unberuehrt. + expect( + await prisma.publicVerwendung.count({ where: { schluesselId: neuId, zustand: "AUSGEROLLT" } }), + ).toBe(1); + + 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("ROTIERT"); + expect((await rotationsStand(altId)).schritt).toBe(5); + + expect((await rotationsKette(neuId)).map((g) => [g.id, g.status])).toEqual([ + [altId, "ROTIERT"], + [neuId, "AKTIV"], + ]); + expect(await prisma.protokoll.count({ where: { aktion: "schluessel.rotation.uebernommen" } })).toBe(1); + expect(await datenbankAlsText()).not.toContain("PRIVATE KEY"); + }); + + it("verweigert Personen-Keys und den Deployment-Key", async () => { + const person = await prisma.schluessel.create({ + data: { + name: "Laptop", + slug: "laptop", + typ: "ED25519", + kategorie: "PERSON", + publicKey: zeile("ssh-ed25519", ed25519Blob(), "p"), + fingerprint: "SHA256:p", + kommentar: "p", + }, + select: { id: true }, + }); + await expect(rotationStarten(person.id, admin)).rejects.toThrow(/neuen Public Key/); + }); +}); diff --git a/tests/widerruf.test.ts b/tests/widerruf.test.ts new file mode 100644 index 0000000..6fd8299 --- /dev/null +++ b/tests/widerruf.test.ts @@ -0,0 +1,346 @@ +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { prisma } from "@/lib/db"; +import { BESTAETIGUNG_NOETIG, RECHTE } from "@/server/auth/rechte"; +import { schluesselListe } from "@/server/queries/schluessel"; +import { widerrufUebersicht } from "@/server/queries/widerruf"; +import { + VerwendungsFehler, + publicVerwendungAnlegen, + trockenlaufStarten, +} from "@/server/services/verwendungen"; +import { + WiderrufFehler, + widerrufAbschliessen, + widerrufEinleiten, + widerrufZielkontenEntfernen, + zielkontoKandidaten, +} from "@/server/services/widerruf"; +import { datenbankLeeren } from "./hilfen/db"; +import { datenbankAlsText, warteschlangeStoppen } from "./hilfen/vorgang"; +import { ed25519Blob, zeile } from "./ssh/hilfen"; + +/** Widerruf und Massenwiderruf (#024): Zustaende, Rechte, Protokoll. */ + +let admin: { id: string; name: string }; +let a: { id: string; name: string }; +let b: { id: string; name: string }; +let zaehler = 0; + +afterAll(async () => { + await warteschlangeStoppen(); +}); + +beforeEach(async () => { + await datenbankLeeren(); + admin = await prisma.benutzer.create({ + data: { name: "Ada Admin", rolle: "ADMIN" }, + select: { id: true, name: true }, + }); + a = await prisma.host.create({ + data: { name: "srv-a", adresse: "10.0.0.1" }, + select: { id: true, name: true }, + }); + b = await prisma.host.create({ + data: { name: "srv-b", adresse: "10.0.0.2" }, + select: { id: true, name: true }, + }); +}); + +async function schluessel(name: string, daten: { kategorie?: "PERSON" | "DIENST" | "DEPLOYMENT" } = {}) { + zaehler += 1; + return prisma.schluessel.create({ + data: { + name, + slug: `k-${zaehler}`, + typ: "ED25519", + kategorie: daten.kategorie ?? "DIENST", + publicKey: zeile("ssh-ed25519", ed25519Blob(), `k-${zaehler}`), + fingerprint: `SHA256:test${zaehler}`, + kommentar: `k-${zaehler}`, + }, + select: { id: true, name: true, fingerprint: true }, + }); +} + +/** Public auf B (ausgerollt) und Private auf A (ausgerollt) – wie nach dem Assistenten. */ +async function ausgerollt(schluesselId: string, benutzerB = "deploy") { + const jetzt = new Date(); + const oeffentlich = await prisma.publicVerwendung.create({ + data: { + schluesselId, + hostId: b.id, + zielbenutzer: benutzerB, + zustand: "AUSGEROLLT", + zuletztAngewendetAm: jetzt, + }, + }); + const privat = await prisma.privateVerwendung.create({ + data: { + schluesselId, + hostId: a.id, + zielbenutzer: "root", + pfad: `~/.ssh/id_${schluesselId}`, + sshConfig: { alias: "zu-b", hostName: "srv-b", benutzer: benutzerB, port: 22 }, + zustand: "AUSGEROLLT", + zuletztAngewendetAm: jetzt, + }, + }); + return { oeffentlich, privat }; +} + +async function fehlerVon(versuch: Promise): Promise { + const fehler = await versuch.then( + () => undefined, + (f: unknown) => f, + ); + expect(fehler).toBeInstanceOf(Error); + return fehler as Error; +} + +describe("Widerruf einleiten", () => { + it("merkt alle Verwendungen zum Entfernen vor und laesst den Status noch AKTIV", async () => { + const s = await schluessel("Backup"); + const { oeffentlich, privat } = await ausgerollt(s.id); + // Nie ausgerollt: auf dem Host steht nichts, die Verwendung ist sofort erledigt. + const geplant = await prisma.publicVerwendung.create({ + data: { schluesselId: s.id, hostId: a.id, zielbenutzer: "root" }, + }); + + await widerrufEinleiten([s.id], admin, { privatErlaubt: true }); + + const nachher = await prisma.schluessel.findUniqueOrThrow({ + where: { id: s.id }, + select: { status: true, widerrufEingeleitetAm: true }, + }); + expect(nachher.status).toBe("AKTIV"); + expect(nachher.widerrufEingeleitetAm).not.toBeNull(); + expect((await prisma.publicVerwendung.findUniqueOrThrow({ where: { id: oeffentlich.id } })).zustand).toBe( + "ENTFERNEN", + ); + expect((await prisma.publicVerwendung.findUniqueOrThrow({ where: { id: geplant.id } })).zustand).toBe( + "ENTFERNT", + ); + expect((await prisma.privateVerwendung.findUniqueOrThrow({ where: { id: privat.id } })).zustand).toBe( + "ENTFERNEN", + ); + + const eintrag = await prisma.protokoll.findFirstOrThrow({ + where: { aktion: "schluessel.widerruf.eingeleitet" }, + }); + expect(eintrag.fingerprint).toBe(s.fingerprint); + expect(eintrag.akteurName).toBe("Ada Admin"); + expect(eintrag.details).toMatchObject({ public: 2, privat: 1, ohneHostZugriff: 1 }); + expect(await datenbankAlsText()).not.toContain("PRIVATE KEY"); + }); + + it("ist idempotent und sperrt neue Zuweisungen", async () => { + const s = await schluessel("Backup"); + await ausgerollt(s.id); + await widerrufEinleiten([s.id], admin, { privatErlaubt: true }); + const erstes = (await prisma.schluessel.findUniqueOrThrow({ where: { id: s.id } })).widerrufEingeleitetAm; + await widerrufEinleiten([s.id], admin, { privatErlaubt: true }); + expect( + (await prisma.schluessel.findUniqueOrThrow({ where: { id: s.id } })).widerrufEingeleitetAm, + ).toEqual(erstes); + const fehler = await fehlerVon( + publicVerwendungAnlegen({ schluesselId: s.id, hostId: a.id, zielbenutzer: "backup" }, admin), + ); + expect(fehler).toBeInstanceOf(VerwendungsFehler); + expect(fehler.message).toMatch(/widerrufen/); + }); + + it("verlangt fuer Private Keys das Admin-Recht", async () => { + const s = await schluessel("Backup"); + await ausgerollt(s.id); + const fehler = await fehlerVon(widerrufEinleiten([s.id], admin, { privatErlaubt: false })); + expect(fehler).toBeInstanceOf(WiderrufFehler); + expect(fehler.message).toMatch(/nur ein Admin/); + // Nichts vorgemerkt. + expect(await prisma.publicVerwendung.count({ where: { zustand: "ENTFERNEN" } })).toBe(0); + + // Ein reiner Public-Key-Widerruf geht auch ohne. + const person = await schluessel("Laptop Alice", { kategorie: "PERSON" }); + await prisma.publicVerwendung.create({ + data: { schluesselId: person.id, hostId: b.id, zielbenutzer: "alice", zustand: "AUSGEROLLT" }, + }); + await widerrufEinleiten([person.id], admin, { privatErlaubt: false }); + }); + + it("weist den Deployment-Key und bereits widerrufene Schluessel ab", async () => { + const deployment = await schluessel("Deployment", { kategorie: "DEPLOYMENT" }); + expect( + (await fehlerVon(widerrufEinleiten([deployment.id], admin, { privatErlaubt: true }))).message, + ).toMatch(/Deployment-Key/); + const alt = await schluessel("Alt"); + await prisma.schluessel.update({ where: { id: alt.id }, data: { status: "WIDERRUFEN" } }); + expect((await fehlerVon(widerrufEinleiten([alt.id], admin, { privatErlaubt: true }))).message).toMatch( + /bereits widerrufen/, + ); + }); + + it("erledigt Verwendungen auf archivierten Hosts sofort", async () => { + const s = await schluessel("Backup"); + const v = await prisma.publicVerwendung.create({ + data: { schluesselId: s.id, hostId: b.id, zielbenutzer: "deploy", zustand: "AUSGEROLLT" }, + }); + await prisma.host.update({ where: { id: b.id }, data: { archiviertAm: new Date() } }); + await widerrufEinleiten([s.id], admin, { privatErlaubt: true }); + expect((await prisma.publicVerwendung.findUniqueOrThrow({ where: { id: v.id } })).zustand).toBe( + "ENTFERNT", + ); + const eintrag = await prisma.protokoll.findFirstOrThrow({ + where: { aktion: "schluessel.widerruf.eingeleitet" }, + }); + expect(eintrag.details).toMatchObject({ archivierteHosts: ["srv-b"] }); + }); +}); + +describe("Dry-Run fuer den Widerruf", () => { + it("nimmt die zum Entfernen vorgemerkten Ziele mehrerer Schluessel in einen Trockenlauf", async () => { + const eins = await schluessel("Eins"); + const zwei = await schluessel("Zwei"); + await ausgerollt(eins.id); + await prisma.publicVerwendung.create({ + data: { schluesselId: zwei.id, hostId: b.id, zielbenutzer: "deploy", zustand: "AUSGEROLLT" }, + }); + await widerrufEinleiten([eins.id, zwei.id], admin, { privatErlaubt: true }); + + const id = await trockenlaufStarten({ schluesselIds: [eins.id, zwei.id] }, admin, true); + const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id } }); + expect(vorgang.art).toBe("TROCKENLAUF"); + expect(vorgang.schluesselId).toBeNull(); + const ziele = (vorgang.eingabe as { ziele: { hostId: string; zielbenutzer: string; art?: string }[] }) + .ziele; + // Beide Schluessel auf B/deploy ergeben ein Ziel, dazu der Private Key auf A. + expect(ziele).toHaveLength(2); + expect(ziele).toContainEqual({ hostId: b.id, zielbenutzer: "deploy" }); + expect(ziele).toContainEqual({ hostId: a.id, zielbenutzer: "root", art: "privat" }); + }); +}); + +describe("Widerruf abschliessen", () => { + it("bleibt im Zwischenzustand, solange eine Entfernung offen ist", async () => { + const s = await schluessel("Backup"); + const { oeffentlich, privat } = await ausgerollt(s.id); + await widerrufEinleiten([s.id], admin, { privatErlaubt: true }); + + // B war nicht erreichbar: der Private Key auf A ist weg, der Public Key auf B noch nicht. + await prisma.privateVerwendung.update({ where: { id: privat.id }, data: { zustand: "ENTFERNT" } }); + const [ergebnis] = await widerrufAbschliessen([s.id], admin); + expect(ergebnis?.status).toBe("OFFEN"); + expect(ergebnis?.offen.map((o) => o.ziel)).toEqual(["deploy@srv-b:~/.ssh/authorized_keys"]); + expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: s.id } })).status).toBe("AKTIV"); + expect((await widerrufUebersicht([s.id])).offen).toHaveLength(1); + + await prisma.publicVerwendung.update({ where: { id: oeffentlich.id }, data: { zustand: "ENTFERNT" } }); + const [fertig] = await widerrufAbschliessen([s.id], admin); + expect(fertig?.status).toBe("WIDERRUFEN"); + expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: s.id } })).status).toBe("WIDERRUFEN"); + const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "schluessel.widerrufen" } }); + expect(eintrag.fingerprint).toBe(s.fingerprint); + + // Ein zweiter Abschluss aendert nichts und protokolliert nicht doppelt. + await widerrufAbschliessen([s.id], admin); + expect(await prisma.protokoll.count({ where: { aktion: "schluessel.widerrufen" } })).toBe(1); + }); + + it("schliesst ohne eingeleiteten Widerruf nicht ab", async () => { + const s = await schluessel("Backup"); + expect((await fehlerVon(widerrufAbschliessen([s.id], admin))).message).toMatch( + /kein Widerruf eingeleitet/, + ); + }); + + it("setzt ROTIERT, wenn ein aktiver Nachfolger existiert (#023)", async () => { + const alt = await schluessel("Alt"); + const neu = await schluessel("Neu"); + await prisma.schluessel.update({ where: { id: neu.id }, data: { vorgaengerId: alt.id } }); + await widerrufEinleiten([alt.id], admin, { privatErlaubt: true }); + const [ergebnis] = await widerrufAbschliessen([alt.id], admin); + expect(ergebnis?.status).toBe("ROTIERT"); + expect(await prisma.protokoll.count({ where: { aktion: "schluessel.rotiert" } })).toBe(1); + }); +}); + +describe("Zielkonten beim Widerruf", () => { + it("bietet nur von sshkv angelegte Konten an und nennt weitere Nutzer", async () => { + const s = await schluessel("Deploy A"); + const andere = await schluessel("Deploy C"); + await ausgerollt(s.id); + const konto = await prisma.zielkonto.create({ data: { hostId: b.id, benutzername: "deploy" } }); + // Ein vorhandenes, nicht von sshkv angelegtes Konto taucht nicht auf. + await prisma.zielkonto.create({ data: { hostId: a.id, benutzername: "root2", vonSshkvAngelegt: false } }); + + expect(await zielkontoKandidaten([s.id])).toEqual([ + expect.objectContaining({ + id: konto.id, + benutzername: "deploy", + hostName: "srv-b", + nochGenutztVon: [], + }), + ]); + + await prisma.publicVerwendung.create({ + data: { schluesselId: andere.id, hostId: b.id, zielbenutzer: "deploy", zustand: "AUSGEROLLT" }, + }); + expect((await zielkontoKandidaten([s.id]))[0]?.nochGenutztVon).toEqual(["Deploy C"]); + // Beide gemeinsam widerrufen: niemand nutzt das Konto weiter. + expect((await zielkontoKandidaten([s.id, andere.id]))[0]?.nochGenutztVon).toEqual([]); + }); + + it("entfernt Zielkonten erst nach den Schluesseln und nie ein noch genutztes", async () => { + const s = await schluessel("Deploy A"); + const { oeffentlich, privat } = await ausgerollt(s.id); + const konto = await prisma.zielkonto.create({ data: { hostId: b.id, benutzername: "deploy" } }); + await widerrufEinleiten([s.id], admin, { privatErlaubt: true }); + + expect((await fehlerVon(widerrufZielkontenEntfernen([s.id], [konto.id], admin))).message).toMatch( + /erst entfernt, wenn die Schlüssel/, + ); + + await prisma.publicVerwendung.update({ where: { id: oeffentlich.id }, data: { zustand: "ENTFERNT" } }); + await prisma.privateVerwendung.update({ where: { id: privat.id }, data: { zustand: "ENTFERNT" } }); + const andere = await schluessel("Deploy C"); + const fremd = await prisma.publicVerwendung.create({ + data: { schluesselId: andere.id, hostId: b.id, zielbenutzer: "deploy", zustand: "AUSGEROLLT" }, + }); + expect((await fehlerVon(widerrufZielkontenEntfernen([s.id], [konto.id], admin))).message).toMatch( + /noch von „Deploy C“ genutzt/, + ); + + await prisma.publicVerwendung.delete({ where: { id: fremd.id } }); + const vorgaenge = await widerrufZielkontenEntfernen([s.id], [konto.id], admin); + expect(vorgaenge).toEqual([{ id: expect.any(String), beschriftung: "deploy@srv-b" }]); + const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgaenge[0]!.id } }); + expect(vorgang).toMatchObject({ + art: "ZIELKONTO_ENTFERNEN", + hostId: b.id, + eingabe: { benutzer: "deploy" }, + }); + }); +}); + +describe("Rechte und Ablauf", () => { + it("Massenwiderruf und Zielkonten nur fuer Admins mit frischer Bestaetigung", () => { + expect(RECHTE.massenwiderruf).toEqual(["ADMIN"]); + expect(BESTAETIGUNG_NOETIG.has("massenwiderruf")).toBe(true); + expect(BESTAETIGUNG_NOETIG.has("zielkontenVerwalten")).toBe(true); + }); + + it("zeigt aktive Schluessel mit vergangenem Ablaufdatum unter „abgelaufen“", async () => { + const s = await schluessel("Projekt X"); + const frisch = await schluessel("Projekt Y"); + await prisma.schluessel.update({ + where: { id: s.id }, + data: { ablaufAm: new Date("2020-01-01T00:00:00Z") }, + }); + await prisma.schluessel.update({ + where: { id: frisch.id }, + data: { ablaufAm: new Date("2999-01-01T00:00:00Z") }, + }); + expect((await schluesselListe({ status: "ABGELAUFEN" })).map((k) => k.name)).toEqual(["Projekt X"]); + expect((await schluesselListe({ status: "AKTIV" })).map((k) => k.name)).toEqual(["Projekt Y"]); + // Automatisch entfernt wird nichts: der Status in der Datenbank bleibt AKTIV. + expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: s.id } })).status).toBe("AKTIV"); + }); +});