feat(deploymentkey): Rotation vor dem Umstellen abbrechen (#036)

Eine begonnene Rotation ließ sich nicht zurücknehmen; nahm ein Host den
neuen Key dauerhaft nicht an, blieb nur das Übergehen. Abbrechen
verwirft jetzt den neuen Key samt Private Key, ein Dry-Run nimmt ihn
wieder von den Hosts, und eine neue Rotation kann sofort starten.

Nebenbei: Aktionen der Benutzerverwaltung (#035) bekommen im Audit-Log
eine eigene Gruppe, statt den Filter „Anmeldung“ zu verwässern; der
E2E-Test für lokale Konten hängt nicht mehr von der Reihenfolge ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 22:52:32 +02:00
co-authored by Claude Opus 5.5
parent 68dbc7cadb
commit 5d03681f3c
11 changed files with 160 additions and 13 deletions
+23 -1
View File
@@ -1,4 +1,4 @@
import { CheckCheck, KeyRound, ShieldCheck } from "lucide-react";
import { CheckCheck, KeyRound, ShieldCheck, Undo2 } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
@@ -81,6 +81,16 @@ export default async function DeploymentKeySeite() {
)}
</Karte>
{stand.phase === "bereit" && stand.abgebrochen && (
<Karte
titel="Abgebrochene Rotation aufräumen"
untertitel="Der verworfene Key kann noch in den Blöcken stehen. Ein Dry-Run zeigt es; steht alles auf Soll, ist nichts mehr zu tun."
>
<p className="text-muted mb-4 font-mono text-[13px] break-all">{stand.abgebrochen.fingerprint}</p>
<AusrollenPanel auswahl={{ deploymentKey: true }} offen={0} />
</Karte>
)}
{stand.phase === "bereit" && stand.aktuell && (
<Karte
titel="1 · Neuen Key erzeugen"
@@ -136,6 +146,18 @@ export default async function DeploymentKeySeite() {
<Umstellen fehlgeschlagen={fehlgeschlagen} />
</Karte>
)}
<Karte
titel="Rotation abbrechen"
untertitel="Solange nicht umgestellt ist: der neue Key wird verworfen, der alte bleibt unverändert. Danach per Dry-Run den neuen wieder von den Hosts entfernen."
className="border-danger-border"
>
<VorgangsKnopf
aktion={rotationsSchrittAktion.bind(null, "abbrechen")}
beschriftung="Rotation abbrechen"
variante="gefahr"
icon={<Undo2 className="size-4" />}
/>
</Karte>
</>
)}
+1
View File
@@ -91,6 +91,7 @@ export const AKTIONEN: Readonly<Record<string, Beschreibung>> = {
"deploymentkey.erzeugt": a("Deployment-Key erzeugt", "schluessel"),
"deploymentkey.geprueft": a("Neuer Deployment-Key geprüft", "widerruf", false),
"deploymentkey.umgestellt": a("Deployment-Key umgestellt", "widerruf"),
"deploymentkey.abgebrochen": a("Rotation des Deployment-Keys abgebrochen", "widerruf"),
"deploymentkey.abgeschlossen": a("Rotation des Deployment-Keys abgeschlossen", "widerruf"),
// Widerruf und Rotation (#023, #024)
+2 -2
View File
@@ -21,11 +21,11 @@ function alsAntwort(fehler: unknown): { fehler: string } {
}
export async function rotationsSchrittAktion(
schritt: "erzeugen" | "pruefen" | "abschliessen",
schritt: "erzeugen" | "pruefen" | "abbrechen" | "abschliessen",
): Promise<VorgangsAntwort> {
try {
const { benutzer } = await rechtErforderlich("deploymentKeyVerwalten");
const geprueft = z.enum(["erzeugen", "pruefen", "abschliessen"]).parse(schritt);
const geprueft = z.enum(["erzeugen", "pruefen", "abbrechen", "abschliessen"]).parse(schritt);
return { vorgangId: await rotationsSchrittAnfordern(geprueft, akteurVon(benutzer)) };
} catch (fehler) {
return alsAntwort(fehler);
+15 -2
View File
@@ -85,11 +85,21 @@ export async function rotationsStand() {
: vorgaenger
? "aufraeumen"
: "bereit";
return { aktuell, nachfolger, vorgaenger, phase, hosts, pruefung };
// Zuletzt abgebrochener Nachfolger des aktuellen Keys (#036): kann noch in Bloecken stehen,
// bis ein Dry-Run ihn entfernt.
const abgebrochen =
phase === "bereit" && aktuell
? await prisma.schluessel.findFirst({
where: { kategorie: "DEPLOYMENT", vorgaengerId: aktuell.id, status: "WIDERRUFEN" },
orderBy: { angelegtAm: "desc" },
select: { id: true, fingerprint: true },
})
: null;
return { aktuell, nachfolger, vorgaenger, phase, hosts, pruefung, abgebrochen };
}
export async function rotationsSchrittAnfordern(
schritt: "erzeugen" | "pruefen" | "abschliessen",
schritt: "erzeugen" | "pruefen" | "abbrechen" | "abschliessen",
akteur: Akteur,
): Promise<string> {
const stand = await rotationsStand();
@@ -98,6 +108,9 @@ export async function rotationsSchrittAnfordern(
}
if (schritt === "pruefen" && !stand.nachfolger)
throw new DeploymentKeyFehler("Es gibt keinen neuen Deployment-Key zu prüfen.");
// Abbrechen nur vor dem Umstellen – danach meldet sich sshkv schon mit dem neuen Key an.
if (schritt === "abbrechen" && !stand.nachfolger)
throw new DeploymentKeyFehler("Es läuft keine Rotation, die sich abbrechen ließe.");
if (schritt === "abschliessen" && !stand.vorgaenger)
throw new DeploymentKeyFehler("Es gibt nichts abzuschließen.");
return vorgangEinreihen({ art: "ROTATION", angefordertVonId: akteur.id, eingabe: { schritt } });
+25
View File
@@ -95,6 +95,30 @@ async function pruefen(angefordertVonId: string | null) {
return { ergebnis: { hosts } };
}
/**
* Rotation abbrechen (#036), nur vor dem Umstellen: der Nachfolger wird widerrufen und sein
* Private Key sofort verworfen – sshkv hat sich mit ihm nie produktiv angemeldet. Aus den
* Bloecken verschwindet er beim naechsten Dry-Run, weil `sollFuer()` nur aktive Nachfolger kennt.
*/
async function abbrechen(angefordertVonId: string | null) {
const stand = await rotationsStand();
if (!stand.nachfolger) throw new VorgangsFehler("Es läuft keine Rotation, die sich abbrechen ließe.");
await prisma.schluessel.update({
where: { id: stand.nachfolger.id },
data: { status: "WIDERRUFEN", widerrufEingeleitetAm: new Date() },
select: { id: true },
});
await privateKeyVerwerfen(stand.nachfolger.id);
await protokollieren({
akteur: await akteurVon(angefordertVonId),
aktion: "deploymentkey.abgebrochen",
ergebnis: "ERFOLG",
fingerprint: stand.nachfolger.fingerprint,
details: { aktuell: stand.aktuell?.fingerprint ?? null },
});
return { ergebnis: { fingerprint: stand.nachfolger.fingerprint } };
}
/**
* Prueft auf jedem Instanz-Host, dass der alte Key nicht mehr im Block steht, und verwirft
* erst dann seinen Private Key. Nicht erreichbare Hosts verhindern den Abschluss nicht – sie
@@ -143,6 +167,7 @@ export const deploymentRotationHandler: Handler = async ({ vorgang }) => {
const schritt = (vorgang.eingabe as { schritt?: unknown } | null)?.schritt;
if (schritt === "erzeugen") return erzeugen(vorgang.angefordertVonId);
if (schritt === "pruefen") return pruefen(vorgang.angefordertVonId);
if (schritt === "abbrechen") return abbrechen(vorgang.angefordertVonId);
if (schritt === "abschliessen") return abschliessen(vorgang.angefordertVonId);
throw new VorgangsFehler("Unbekannter Schritt der Rotation.");
};