diff --git a/docs/anleitung/bilder/widerruf-desktop.png b/docs/anleitung/bilder/widerruf-desktop.png new file mode 100644 index 0000000..b44abab 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..9824d18 Binary files /dev/null and b/docs/anleitung/bilder/widerruf-dry-run-desktop.png differ 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 38d6161..bcf6a22 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..4c15fc4 100644 --- a/src/app/(app)/schluessel/[id]/page.tsx +++ b/src/app/(app)/schluessel/[id]/page.tsx @@ -19,6 +19,10 @@ import { schluesselDetail, type SchluesselDetail } from "@/server/queries/schlue 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 { PrivateAusrollenAktion } from "./PrivateAusrollenAktion"; import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen"; import { PublicAusrollenAktion } from "./PublicAusrollenAktion"; @@ -187,18 +191,41 @@ 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 widerrufbar = + !imWiderruf && + 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; + 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 +233,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 +254,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 +267,78 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id } aktionen={ - verwalten && ( - + (verwalten || (widerrufbar && widerruf)) && ( + <> + {verwalten && ( + + )} + {widerrufbar && widerruf && ( + + )} + ) } /> + {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 +412,7 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
{rechte.public && + !imWiderruf && (s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && ( + + + ); +} + +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/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/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..5c87d4e 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: [ 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/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/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/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/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/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"); + }); +});