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 && (
-
}>
- Bearbeiten
-
+ (verwalten || (widerrufbar && widerruf)) && (
+ <>
+ {verwalten && (
+
}>
+ Bearbeiten
+
+ )}
+ {widerrufbar && widerruf && (
+
+ )}
+ >
)
}
/>
+ {imWiderruf && widerruf && (
+
+
+
+ )}
+
+ {abgelaufen && widerrufbar && widerruf && (
+
+
+
+ {s.ablaufAm ? (
+ <>
+ Das Ablaufdatum ({datum(s.ablaufAm)} ) 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={Zur Schlüsselliste }
+ />
+
+ );
+ }
+
+ 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) => (
-
-
- {index < schritt ? : index + 1}
-
- {titel}
-
- ))}
-
+
{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 && (
@@ -124,7 +143,11 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
})
}
>
- {laeuftNoch(ausrollen.stand) ? "Wird ausgerollt …" : `Änderung anwenden (${aenderungen})`}
+ {laeuftNoch(ausrollen.stand)
+ ? "Wird ausgerollt …"
+ : nurZustand
+ ? "Zustand übernehmen"
+ : `Änderung anwenden (${aenderungen})`}
setTrockenlaufId(undefined)}>
Verwerfen
@@ -132,7 +155,7 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
)}
- {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) => (
+
+
+ {index < aktuell ? : index + 1}
+
+ {titel}
+
+ ))}
+
+ );
+}
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 (
+
+ {
+ const kaestchen = document.querySelectorAll(
+ `input[type="checkbox"][form="${formular}"]:not(:disabled)`,
+ );
+ for (const k of kaestchen) k.checked = e.target.checked;
+ }}
+ />
+ Alle auswählen
+
+ );
+}
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 && (
+
+ }
+ disabled={Boolean(laeuft)}
+ onClick={() => void abschliessen()}
+ >
+ Widerruf abschließen
+
+
+ )}
+
+ );
+}
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
+
+
+ Die Zuweisungen werden zum Entfernen vorgemerkt; der Schlüssel lässt sich nicht mehr zuweisen.
+
+ Der Dry-Run zeigt jede Änderung. Erst „Anwenden“ ändert etwas auf den Hosts.
+
+ 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.
+
+
+
+
+ {gesperrt.map((s) => (
+
+ {s.gesperrt}
+
+ ))}
+
+ {bestaetigen && (
+
+ Anmeldung bestätigen und danach erneut „Widerruf vorbereiten“ wählen
+
+ )}
+
+ {abbrechen && Abbrechen }
+ }
+ disabled={laeuft || gesperrt.length > 0}
+ onClick={() =>
+ starten(async () => {
+ setFehler(undefined);
+ const antwort = await widerrufEinleitenAktion(uebersicht.schluessel.map((s) => s.id));
+ if (antwort.bestaetigen) setBestaetigen(true);
+ if (antwort.fehler) {
+ setFehler(antwort.fehler);
+ return;
+ }
+ abbrechen?.();
+ router.replace(ziel);
+ router.refresh();
+ })
+ }
+ >
+ {laeuft
+ ? "Wird vorgemerkt …"
+ : mehrere
+ ? `${uebersicht.schluessel.length} Schlüssel widerrufen`
+ : "Widerruf vorbereiten"}
+
+
+
+ );
+}
+
+/** 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 (
+ <>
+ } onClick={() => setOffen(true)}>
+ {beschriftung}
+
+
+
+
+ „{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");
+ });
+});