feat(rotation): laufende Rotation abbrechen (#032)

Bisher liess sich eine angefangene Rotation nur zu Ende fuehren. Wer
merkt, dass der neue Schluessel nicht passt, braucht einen Weg zurueck,
ohne den alten anzufassen.

"Rotation abbrechen" (Rotationsseite und Seite des Nachfolgers) merkt
den neuen Schluessel ueber den bestehenden Widerruf aus #024 vor; er
kann schon ausgerollt sein, deshalb folgen Dry-Run und Anwenden wie
sonst. Der alte bleibt aktiv und unveraendert. Wo beide denselben
Alias tragen, zeigt die config danach wieder auf die alte Datei, weil
privat.ts nur die verbleibenden Zuweisungen in den Block schreibt.

Zustandsmodell: kein neues Feld. Ein Nachfolger im Widerruf (oder
widerrufen) ist eine abgebrochene Rotation; vorgaengerId bleibt fuer
die Historie, der Abbruch steht als schluessel.rotation.abgebrochen im
Protokoll (in derselben Transaktion wie das Vormerken, dafuer nimmt
widerrufEinleiten optional einen Zusatzschritt an). Abbrechen geht nur,
solange der alte Schluessel noch nicht entfernt wird. widerrufAbschliessen
macht aus dem Widerruf des alten Schluessels kein ROTIERT mehr, wenn
der Nachfolger selbst im Widerruf ist. Weil vorgaengerId eindeutig
ist, gibt es nach einem Abbruch keinen zweiten Nachfolger; Seite und
Fehlermeldung sagen das.

Tests: DB-Tests fuer Abbruch, Idempotenz, Sperren und Status;
Integrationstest gegen echte Ziele (neuer Key ausgerollt, abbrechen,
neuer weg, alter in authorized_keys, ssh zu-b von A geht); E2E fuer
den Ablauf im Browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 17:45:11 +02:00
co-authored by Claude Opus 5.5
parent fb56fd36c5
commit f18fd4ec06
12 changed files with 549 additions and 61 deletions
+68 -23
View File
@@ -23,7 +23,8 @@ import { widerrufUebersicht } from "@/server/queries/widerruf";
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf"; import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
import { WiderrufKnopf } from "@/components/widerruf/WiderrufKnopf"; import { WiderrufKnopf } from "@/components/widerruf/WiderrufKnopf";
import { ablaufZustand } from "@/lib/schluessel"; import { ablaufZustand } from "@/lib/schluessel";
import { rotationGesperrt } from "@/server/services/rotation"; import { RotationAbbrechen } from "@/components/rotation/RotationAbbrechen";
import { rotationAbgebrochen, rotationGesperrt } from "@/server/services/rotation";
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion"; import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen"; import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
import { PublicAusrollenAktion } from "./PublicAusrollenAktion"; import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
@@ -216,9 +217,17 @@ export default async function SchluesselSeite({
const jetzt = new Date(); const jetzt = new Date();
const abgelaufen = s.status === "ABGELAUFEN" || ablaufZustand(s.ablaufAm, jetzt).art === "abgelaufen"; const abgelaufen = s.status === "ABGELAUFEN" || ablaufZustand(s.ablaufAm, jetzt).art === "abgelaufen";
const verwaltenRecht = darf(benutzer.rolle, "schluesselVerwalten"); const verwaltenRecht = darf(benutzer.rolle, "schluesselVerwalten");
// Auf dem Nachfolger: die laufende Rotation gehoert zum Vorgaenger.
const rotationDesVorgaengers =
s.vorgaenger && (s.vorgaenger.status === "AKTIV" || s.vorgaenger.status === "ABGELAUFEN")
? s.vorgaenger
: null;
const widerrufbar = const widerrufbar =
!imWiderruf && !imWiderruf &&
!(s.nachfolger?.status === "AKTIV") && !(s.nachfolger?.status === "AKTIV") &&
// Ein Nachfolger mitten in der Rotation wird ueber „Rotation abbrechen“ widerrufen (#032) –
// so steht der Abbruch im Protokoll und die Rotationsseite kennt den Stand.
!rotationDesVorgaengers &&
s.kategorie !== "DEPLOYMENT" && s.kategorie !== "DEPLOYMENT" &&
(s.status === "AKTIV" || s.status === "ABGELAUFEN") && (s.status === "AKTIV" || s.status === "ABGELAUFEN") &&
rechte.public && rechte.public &&
@@ -235,13 +244,19 @@ export default async function SchluesselSeite({
privatZuweisungen: aktivePrivat, privatZuweisungen: aktivePrivat,
}); });
const rotationLaeuft = s.nachfolger?.status === "AKTIV" && s.status !== "ROTIERT"; const rotationLaeuft = s.nachfolger?.status === "AKTIV" && s.status !== "ROTIERT";
const abgebrocheneRotation = s.status !== "ROTIERT" && rotationAbgebrochen(s.nachfolger);
const darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat); const darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat);
const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf)); // Nach einem abgeschlossenen Abbruch (#032) bleibt der Nachfolger in der Kette; ein zweiter
// Auf dem Nachfolger: die laufende Rotation gehoert zum Vorgaenger. // passt nicht hinein (vorgaengerId ist eindeutig) – deshalb kein „Rotieren“ mehr.
const rotationDesVorgaengers = const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf && !s.nachfolger));
s.vorgaenger && (s.vorgaenger.status === "AKTIV" || s.vorgaenger.status === "ABGELAUFEN") // Abbrechen auf dem Nachfolger: gleiche Rechte wie die Rotation, Private Keys nur mit Recht.
? s.vorgaenger const darfAbbrechen =
: null; Boolean(rotationDesVorgaengers && !rotationDesVorgaengers.widerrufEingeleitetAm) &&
!imWiderruf &&
s.status === "AKTIV" &&
verwaltenRecht &&
rechte.public &&
(s.privateVerwendungen.length === 0 || rechte.privat);
const vorauswahl = ( const vorauswahl = (
Array.isArray(zielkontenParameter) ? zielkontenParameter.join(",") : (zielkontenParameter ?? "") Array.isArray(zielkontenParameter) ? zielkontenParameter.join(",") : (zielkontenParameter ?? "")
) )
@@ -308,20 +323,41 @@ export default async function SchluesselSeite({
} }
/> />
{rotationDesVorgaengers && ( {rotationDesVorgaengers &&
<Karte titel="Rotation läuft" className="border-accent"> (imWiderruf || s.status === "WIDERRUFEN" ? (
<p className="text-muted text-[14px]"> <Karte titel="Rotation abgebrochen">
Dieser Schlüssel löst „{rotationDesVorgaengers.name}“ ab. Die Rotation ist erst fertig, wenn der <p className="text-muted text-[14px]">
neue Schlüssel ausgerollt und getestet und der alte entfernt ist.{" "} Dieser Schlüssel sollte „{rotationDesVorgaengers.name}“ ablösen; die Rotation wurde abgebrochen.
<Link {imWiderruf ? " Er wird gerade entfernt; " : " Er ist von allen Hosts entfernt; "}
href={`/schluessel/${rotationDesVorgaengers.id}/rotieren`} <Link href={`/schluessel/${rotationDesVorgaengers.id}`} className="text-accent hover:underline">
className="text-accent hover:underline" {rotationDesVorgaengers.name}
> </Link>{" "}
Rotation fortsetzen bleibt aktiv.
</Link> </p>
</p> </Karte>
</Karte> ) : (
)} <Karte titel="Rotation läuft" className="border-accent">
<p className="text-muted text-[14px]">
Dieser Schlüssel löst „{rotationDesVorgaengers.name}“ ab. Die Rotation ist erst fertig, wenn der
neue Schlüssel ausgerollt und getestet und der alte entfernt ist.{" "}
<Link
href={`/schluessel/${rotationDesVorgaengers.id}/rotieren`}
className="text-accent hover:underline"
>
Rotation fortsetzen
</Link>
</p>
{darfAbbrechen && (
<div className="mt-4">
<RotationAbbrechen
altId={rotationDesVorgaengers.id}
altName={rotationDesVorgaengers.name}
neuName={s.name}
/>
</div>
)}
</Karte>
))}
{imWiderruf && widerruf && ( {imWiderruf && widerruf && (
<Karte <Karte
@@ -336,7 +372,7 @@ export default async function SchluesselSeite({
vorauswahl={vorauswahl} vorauswahl={vorauswahl}
ruecksprung={`/schluessel/${s.id}`} ruecksprung={`/schluessel/${s.id}`}
darfPrivat={rechte.privat} darfPrivat={rechte.privat}
ohneZielkonten={rotationLaeuft} ohneZielkonten={rotationLaeuft || Boolean(rotationDesVorgaengers)}
/> />
</Karte> </Karte>
)} )}
@@ -367,7 +403,8 @@ export default async function SchluesselSeite({
<Karte titel={s.status === "WIDERRUFEN" ? "Widerrufen" : "Rotiert"}> <Karte titel={s.status === "WIDERRUFEN" ? "Widerrufen" : "Rotiert"}>
<p className="text-muted text-[14px]"> <p className="text-muted text-[14px]">
Von allen Hosts entfernt; der Schlüssel lässt sich nicht mehr zuweisen. Von allen Hosts entfernt; der Schlüssel lässt sich nicht mehr zuweisen.
{s.nachfolger && ( {/* Ein widerrufener Nachfolger (abgebrochene Rotation, #032) ersetzt nichts. */}
{s.nachfolger && s.status === "ROTIERT" && (
<> <>
{" "} {" "}
Ersetzt durch{" "} Ersetzt durch{" "}
@@ -502,6 +539,14 @@ export default async function SchluesselSeite({
))} ))}
</ol> </ol>
)} )}
{abgebrocheneRotation && s.nachfolger && (
<p className="text-muted mt-3 text-[14px]">
Die Rotation zu „{s.nachfolger.name}“ wurde abgebrochen
{s.nachfolger.status === "WIDERRUFEN" ? "" : " – der neue Schlüssel wird gerade entfernt"};
dieser Schlüssel bleibt aktiv. Ein zweiter Nachfolger ist nicht vorgesehen: für eine neue
Rotation einen neuen Schlüssel anlegen, genauso zuweisen und diesen danach widerrufen.
</p>
)}
{rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && ( {rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && (
<p className={`text-muted text-[14px] ${kette.length > 1 ? "mt-3" : ""}`}> <p className={`text-muted text-[14px] ${kette.length > 1 ? "mt-3" : ""}`}>
{rotationsSperre.grund} {rotationsSperre.grund}
@@ -59,9 +59,14 @@ export default async function RotierenSeite({ params }: { params: Promise<{ id:
} }
const widerruf = const widerruf =
stand.neu && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT" stand.neu && !stand.abgebrochen && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT"
? await offeneEntfernungen([alt.id]) ? await offeneEntfernungen([alt.id])
: null; : null;
// Abbruch (#032): solange der neue Schluessel nicht widerrufen ist, laeuft sein Widerruf hier weiter.
const abbruch =
stand.neu && stand.abgebrochen
? { offen: stand.neu.status === "WIDERRUFEN" ? null : await offeneEntfernungen([stand.neu.id]) }
: null;
return ( return (
<div className="flex flex-col gap-6 sm:gap-7"> <div className="flex flex-col gap-6 sm:gap-7">
@@ -74,6 +79,7 @@ export default async function RotierenSeite({ params }: { params: Promise<{ id:
neuOffen: stand.neu ? stand.neuOffen : 0, neuOffen: stand.neu ? stand.neuOffen : 0,
tests: stand.neu ? stand.tests : [], tests: stand.neu ? stand.tests : [],
widerruf, widerruf,
abbruch,
darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"), darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"),
}} }}
/> />
+45
View File
@@ -9,6 +9,7 @@ import { Button, Formularfehler, Karte } from "@/components/ui";
import { Schrittleiste } from "@/components/ui/Schrittleiste"; import { Schrittleiste } from "@/components/ui/Schrittleiste";
import { warten } from "@/components/vorgang/warten"; import { warten } from "@/components/vorgang/warten";
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf"; import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
import { RotationAbbrechen } from "./RotationAbbrechen";
import { import {
rotationAltWiderrufenAktion, rotationAltWiderrufenAktion,
rotationStartenAktion, rotationStartenAktion,
@@ -47,6 +48,11 @@ export type RotationsDaten = {
/** Offene Entfernungen des alten Schluessels, sobald sein Widerruf laeuft. */ /** Offene Entfernungen des alten Schluessels, sobald sein Widerruf laeuft. */
widerruf: OffeneEntfernung[] | null; widerruf: OffeneEntfernung[] | null;
darfPrivat: boolean; darfPrivat: boolean;
/**
* Abgebrochene Rotation (#032): `offen` sind die offenen Entfernungen des neuen Schluessels,
* solange sein Widerruf laeuft; null, wenn er abgeschlossen ist.
*/
abbruch: { offen: OffeneEntfernung[] | null } | null;
}; };
export function Rotation({ daten }: { daten: RotationsDaten }) { export function Rotation({ daten }: { daten: RotationsDaten }) {
@@ -109,6 +115,40 @@ export function Rotation({ daten }: { daten: RotationsDaten }) {
</> </>
); );
if (daten.abbruch && neu) {
return daten.abbruch.offen ? (
<Karte
titel="Rotation wird abgebrochen"
untertitel={`Der neue Schlüssel wird entfernt – „${alt.name}“ bleibt aktiv und unverändert.`}
className="border-danger-border"
>
<WiderrufAblauf
schluesselIds={[neu.id]}
offen={daten.abbruch.offen}
zielkonten={[]}
vorauswahl={[]}
ruecksprung={`/schluessel/${alt.id}/rotieren`}
darfPrivat={daten.darfPrivat}
ohneZielkonten
/>
</Karte>
) : (
<Karte titel="Rotation abgebrochen">
<p className="text-[14px]">
Der neue Schlüssel{" "}
<Link href={`/schluessel/${neu.id}`} className="text-accent hover:underline">
{neu.name}
</Link>{" "}
ist widerrufen und von allen Hosts entfernt.{" "}
<Link href={`/schluessel/${alt.id}`} className="text-accent hover:underline">
{alt.name}
</Link>{" "}
bleibt aktiv.
</p>
</Karte>
);
}
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} beschriftung="Schritte der Rotation" /> <Schrittleiste schritte={SCHRITTE} aktuell={schritt} beschriftung="Schritte der Rotation" />
@@ -225,6 +265,11 @@ export function Rotation({ daten }: { daten: RotationsDaten }) {
</Karte> </Karte>
)} )}
{/* Abbrechen geht, solange der alte Schluessel noch nicht entfernt wird (#032). */}
{neu && schritt >= 1 && schritt <= 4 && !daten.widerruf && (
<RotationAbbrechen altId={alt.id} altName={alt.name} neuName={neu.name} />
)}
{schritt === 5 && neu && ( {schritt === 5 && neu && (
<Karte titel="Rotation abgeschlossen"> <Karte titel="Rotation abgeschlossen">
<p className="text-[14px]"> <p className="text-[14px]">
@@ -0,0 +1,80 @@
"use client";
import { Undo2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button, Formularfehler } from "@/components/ui";
import { rotationAbbrechenAktion } from "@/server/actions/rotation";
/**
* „Rotation abbrechen“ (#032) – auf der Rotationsseite und auf der Seite des Nachfolgers. Der
* Klick merkt den neuen Schluessel nur zum Widerruf vor; entfernt wird er erst nach Dry-Run und
* „Anwenden“. Deshalb erst eine Rueckfrage, die genau das sagt.
*/
export function RotationAbbrechen({
altId,
altName,
neuName,
}: {
altId: string;
altName: string;
neuName: string;
}) {
const router = useRouter();
const [offen, setOffen] = useState(false);
const [fehler, setFehler] = useState<string>();
const [laeuft, starten] = useTransition();
if (!offen) {
return (
<div>
<Button icon={<Undo2 className="size-4" />} onClick={() => setOffen(true)}>
Rotation abbrechen
</Button>
</div>
);
}
return (
<div
role="group"
aria-label="Rotation abbrechen?"
className="border-danger-border bg-bg rounded-box flex flex-col gap-3 border px-4 py-3 text-[14px]"
>
<p className="font-medium">Rotation abbrechen?</p>
<ul className="text-muted list-disc space-y-1 pl-5 text-[13.5px]">
<li>
Der neue Schlüssel „{neuName}“ wird widerrufen – wo er schon ausgerollt ist, zeigt der Dry-Run, was
entfernt wird.
</li>
<li>„{altName}“ bleibt aktiv und unverändert; ein config-Alias zeigt danach wieder auf ihn.</li>
<li>Die Verknüpfung bleibt in der Rotationskette sichtbar, der Abbruch steht im Protokoll.</li>
</ul>
<Formularfehler text={fehler} />
<div className="flex flex-wrap justify-end gap-2.5">
<Button disabled={laeuft} onClick={() => setOffen(false)}>
Zurück
</Button>
<Button
variante="gefahr"
icon={<Undo2 className="size-4" />}
disabled={laeuft}
onClick={() =>
starten(async () => {
setFehler(undefined);
const antwort = await rotationAbbrechenAktion(altId);
if (antwort.fehler) {
setFehler(antwort.fehler);
return;
}
setOffen(false);
router.refresh();
})
}
>
{laeuft ? "Wird vorgemerkt …" : "Neuen Schlüssel widerrufen"}
</Button>
</div>
</div>
);
}
+1
View File
@@ -83,6 +83,7 @@ export const AKTIONEN: Readonly<Record<string, Beschreibung>> = {
"schluessel.widerrufen": a("Schlüssel widerrufen", "widerruf"), "schluessel.widerrufen": a("Schlüssel widerrufen", "widerruf"),
"schluessel.rotiert": a("Schlüssel rotiert", "widerruf"), "schluessel.rotiert": a("Schlüssel rotiert", "widerruf"),
"schluessel.rotation.uebernommen": a("Rotation: Zuweisungen übernommen", "widerruf"), "schluessel.rotation.uebernommen": a("Rotation: Zuweisungen übernommen", "widerruf"),
"schluessel.rotation.abgebrochen": a("Rotation abgebrochen", "widerruf"),
// Zielkonten (#018) // Zielkonten (#018)
"zielkonto.angelegt": a("Zielkonto angelegt", "zielkonto"), "zielkonto.angelegt": a("Zielkonto angelegt", "zielkonto"),
+25 -1
View File
@@ -6,7 +6,12 @@ import { akteurVon } from "@/server/actions/formular";
import { darf } from "@/server/auth/rechte"; import { darf } from "@/server/auth/rechte";
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { FachFehler } from "@/server/services/fachfehler"; import { FachFehler } from "@/server/services/fachfehler";
import { rotationAltWiderrufen, rotationStarten, rotationUebernehmen } from "@/server/services/rotation"; import {
rotationAbbrechen,
rotationAltWiderrufen,
rotationStarten,
rotationUebernehmen,
} from "@/server/services/rotation";
import { VerwendungsFehler } from "@/server/services/verwendungen"; import { VerwendungsFehler } from "@/server/services/verwendungen";
/** /**
@@ -69,3 +74,22 @@ export async function rotationAltWiderrufenAktion(schluesselId: string): Promise
return alsAntwort(fehler); return alsAntwort(fehler);
} }
} }
/**
* Laufende Rotation abbrechen (#032): merkt den neuen Schluessel zum Widerruf vor. Gleiche Rechte
* wie die Rotation selbst; Private Keys entfernt nur, wer sie ausrollen darf (Service).
*/
export async function rotationAbbrechenAktion(schluesselId: string): Promise<RotationsAntwort> {
try {
const { benutzer } = await berechtigt();
const altId = id.parse(schluesselId);
await rotationAbbrechen(altId, akteurVon(benutzer), {
privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"),
});
revalidatePath(`/schluessel/${altId}`);
revalidatePath(`/schluessel/${altId}/rotieren`);
return {};
} catch (fehler) {
return alsAntwort(fehler);
}
}
+3 -2
View File
@@ -58,8 +58,9 @@ export const SCHLUESSEL_DETAIL_SELECT = {
person: { select: { id: true, name: true } }, person: { select: { id: true, name: true } },
vorlage: { select: { id: true, name: true } }, vorlage: { select: { id: true, name: true } },
erstelltVon: { select: { name: true } }, erstelltVon: { select: { name: true } },
vorgaenger: { select: { id: true, name: true, status: true } }, vorgaenger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } },
nachfolger: { select: { id: true, name: true, status: true } }, // Ein Nachfolger im Widerruf ist eine abgebrochene Rotation (#032).
nachfolger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } },
publicVerwendungen: { publicVerwendungen: {
where: { zustand: { not: "ENTFERNT" } }, where: { zustand: { not: "ENTFERNT" } },
orderBy: { angelegtAm: "asc" }, orderBy: { angelegtAm: "asc" },
+66 -3
View File
@@ -45,7 +45,7 @@ const ALT_SELECT = {
vorlageId: true, vorlageId: true,
masterKeyVersion: true, masterKeyVersion: true,
widerrufEingeleitetAm: true, widerrufEingeleitetAm: true,
nachfolger: { select: { id: true, name: true, status: true } }, nachfolger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } },
_count: { select: { privateVerwendungen: { where: { zustand: { in: [...AKTIVE] } } } } }, _count: { select: { privateVerwendungen: { where: { zustand: { in: [...AKTIVE] } } } } },
} satisfies Prisma.SchluesselSelect; } satisfies Prisma.SchluesselSelect;
@@ -92,6 +92,13 @@ function gesperrtPruefen(alt: Alt) {
export async function rotationStarten(altId: string, akteur: Akteur): Promise<string> { export async function rotationStarten(altId: string, akteur: Akteur): Promise<string> {
const alt = await altLaden(altId); const alt = await altLaden(altId);
gesperrtPruefen(alt); gesperrtPruefen(alt);
if (alt.nachfolger && rotationAbgebrochen(alt.nachfolger)) {
// vorgaengerId ist eindeutig und bleibt fuer die Historie stehen (#032): ein zweiter
// Nachfolger passt nicht in die Kette.
throw new RotationsFehler(
"Die Rotation dieses Schlüssels wurde abgebrochen; ein zweiter Nachfolger ist nicht vorgesehen. Bitte einen neuen Schlüssel anlegen, genauso zuweisen und diesen danach widerrufen.",
);
}
if (alt.nachfolger) throw new RotationsFehler("Für diesen Schlüssel gibt es schon einen Nachfolger."); if (alt.nachfolger) throw new RotationsFehler("Für diesen Schlüssel gibt es schon einen Nachfolger.");
const name = rotationsName(alt.name, rotationsDatum().anzeige, NAME_MAX); const name = rotationsName(alt.name, rotationsDatum().anzeige, NAME_MAX);
const slug = await eindeutigerSlug(null, name); const slug = await eindeutigerSlug(null, name);
@@ -111,8 +118,19 @@ export async function rotationStarten(altId: string, akteur: Akteur): Promise<st
return vorgangEinreihen({ art: "SCHLUESSEL_ERZEUGEN", angefordertVonId: akteur.id, eingabe }); return vorgangEinreihen({ art: "SCHLUESSEL_ERZEUGEN", angefordertVonId: akteur.id, eingabe });
} }
/**
* Ein Nachfolger im Widerruf (oder widerrufen) heisst: die Rotation wurde abgebrochen (#032).
* Ein eigenes Feld braucht es dafuer nicht – der Widerruf des Nachfolgers ist der Abbruch.
*/
export function rotationAbgebrochen(
neu: { readonly status: string; readonly widerrufEingeleitetAm: Date | null } | null,
): boolean {
return neu !== null && (neu.status === "WIDERRUFEN" || neu.widerrufEingeleitetAm !== null);
}
async function nachfolgerVon(alt: Alt) { async function nachfolgerVon(alt: Alt) {
if (!alt.nachfolger) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen."); if (!alt.nachfolger) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen.");
if (rotationAbgebrochen(alt.nachfolger)) throw new RotationsFehler("Die Rotation wurde abgebrochen.");
if (alt.nachfolger.status !== "AKTIV") throw new RotationsFehler("Der neue Schlüssel ist nicht aktiv."); if (alt.nachfolger.status !== "AKTIV") throw new RotationsFehler("Der neue Schlüssel ist nicht aktiv.");
return alt.nachfolger; return alt.nachfolger;
} }
@@ -255,7 +273,8 @@ export async function rotationsStand(altId: string) {
const alt = await altLaden(altId); const alt = await altLaden(altId);
const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen }); const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen });
const neu = alt.nachfolger; const neu = alt.nachfolger;
if (!neu) return { alt, gesperrt, neu: null, schritt: 0 as const }; if (!neu) return { alt, gesperrt, neu: null, schritt: 0 as const, abgebrochen: false };
const abgebrochen = rotationAbgebrochen(neu);
const [altPublic, altPrivat, neuPublic, neuPrivat, tests] = await Promise.all([ const [altPublic, altPrivat, neuPublic, neuPrivat, tests] = await Promise.all([
prisma.publicVerwendung.count({ where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } } }), prisma.publicVerwendung.count({ where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } } }),
@@ -287,7 +306,7 @@ export async function rotationsStand(altId: string) {
: !testsGruen : !testsGruen
? (3 as const) ? (3 as const)
: (4 as const); : (4 as const);
return { alt, gesperrt, neu, schritt, neuOffen, tests, testsGruen }; return { alt, gesperrt, neu, schritt, neuOffen, tests, testsGruen, abgebrochen };
} }
/** /**
@@ -302,6 +321,7 @@ export async function rotationAltWiderrufen(
const stand = await rotationsStand(altId); const stand = await rotationsStand(altId);
if (!stand.neu) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen."); if (!stand.neu) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen.");
if (stand.schritt === 5) return; if (stand.schritt === 5) return;
if (stand.abgebrochen) throw new RotationsFehler("Die Rotation wurde abgebrochen.");
if (stand.schritt < 4) { if (stand.schritt < 4) {
throw new RotationsFehler( throw new RotationsFehler(
stand.schritt === 3 stand.schritt === 3
@@ -311,3 +331,46 @@ export async function rotationAltWiderrufen(
} }
await widerrufEinleiten([altId], akteur, optionen); await widerrufEinleiten([altId], akteur, optionen);
} }
/**
* Laufende Rotation abbrechen (#032): der neue Schluessel wird ueber den normalen Widerruf (#024)
* entfernt – mit Dry-Run, denn er kann schon ausgerollt sein. Der alte bleibt aktiv und
* unveraendert; wo beide denselben Alias tragen, zeigt die config nach dem Anwenden wieder auf
* die alte Datei (juengste *verbleibende* Zuweisung gewinnt, src/worker/ablaeufe/privat.ts).
*
* `vorgaengerId` des neuen Schluessels bleibt fuer die Historie stehen; dass abgebrochen wurde,
* erkennt man am Widerruf des Nachfolgers und am Protokolleintrag. Ist der alte Schluessel schon
* im Widerruf (Schritt 5), gibt es kein Zurueck mehr – sonst waeren am Ende beide halb entfernt.
*/
export async function rotationAbbrechen(
altId: string,
akteur: Akteur,
optionen: { readonly privatErlaubt: boolean },
): Promise<void> {
const alt = await altLaden(altId);
const neu = alt.nachfolger;
if (!neu) throw new RotationsFehler("Für diesen Schlüssel läuft keine Rotation.");
if (alt.status === "ROTIERT") throw new RotationsFehler("Die Rotation ist bereits abgeschlossen.");
// Idempotent: ein zweiter Klick (oder ein Neuladen) setzt den laufenden Abbruch nur fort.
if (rotationAbgebrochen(neu)) return;
if (alt.widerrufEingeleitetAm) {
throw new RotationsFehler(
"Der alte Schlüssel wird bereits entfernt – die Rotation lässt sich nicht mehr abbrechen.",
);
}
await widerrufEinleiten([neu.id], akteur, {
privatErlaubt: optionen.privatErlaubt,
zusaetzlich: async (tx) => {
await protokollieren(
{
akteur,
aktion: "schluessel.rotation.abgebrochen",
ergebnis: "ERFOLG",
fingerprint: alt.fingerprint,
details: { name: alt.name, nachfolgerId: neu.id, nachfolger: neu.name },
},
tx,
);
},
});
}
+10 -2
View File
@@ -37,7 +37,7 @@ const SCHLUESSEL_KOPF = {
status: true, status: true,
fingerprint: true, fingerprint: true,
widerrufEingeleitetAm: true, widerrufEingeleitetAm: true,
nachfolger: { select: { id: true, status: true } }, nachfolger: { select: { id: true, status: true, widerrufEingeleitetAm: true } },
} satisfies Prisma.SchluesselSelect; } satisfies Prisma.SchluesselSelect;
type SchluesselKopf = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>; type SchluesselKopf = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>;
@@ -167,6 +167,11 @@ export async function zielkontoKandidaten(schluesselIds: readonly string[]): Pro
export type EinleitenOptionen = { export type EinleitenOptionen = {
/** Darf der Akteur Private Keys entfernen (privateKeyAusrollen)? Prueft die Aktion per darf(). */ /** Darf der Akteur Private Keys entfernen (privateKeyAusrollen)? Prueft die Aktion per darf(). */
readonly privatErlaubt: boolean; readonly privatErlaubt: boolean;
/**
* Laeuft in derselben Transaktion wie das Vormerken – etwa der Protokolleintrag eines
* Rotationsabbruchs (#032), damit es keinen Abbruch ohne Eintrag gibt.
*/
readonly zusaetzlich?: (tx: Prisma.TransactionClient) => Promise<void>;
}; };
/** /**
@@ -264,6 +269,7 @@ export async function widerrufEinleiten(
tx, tx,
); );
} }
await optionen.zusaetzlich?.(tx);
}); });
} }
@@ -406,7 +412,9 @@ export async function widerrufAbschliessen(
continue; continue;
} }
// Ein aktiver Nachfolger heisst: der Widerruf ist der letzte Schritt einer Rotation (#023). // Ein aktiver Nachfolger heisst: der Widerruf ist der letzte Schritt einer Rotation (#023).
const status = s.nachfolger?.status === "AKTIV" ? "ROTIERT" : "WIDERRUFEN"; // Ein Nachfolger im Widerruf ist eine abgebrochene Rotation (#032) – ersetzt ist dann nichts.
const status =
s.nachfolger?.status === "AKTIV" && !s.nachfolger.widerrufEingeleitetAm ? "ROTIERT" : "WIDERRUFEN";
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
const geaendert = await tx.schluessel.updateMany({ const geaendert = await tx.schluessel.updateMany({
where: { id: s.id, status: { in: ["AKTIV", "ABGELAUFEN"] } }, where: { id: s.id, status: { in: ["AKTIV", "ABGELAUFEN"] } },
+46
View File
@@ -69,3 +69,49 @@ test("Rotation mit Schrittanzeige", async ({ page }) => {
await archivieren(page, hostA); await archivieren(page, hostA);
await archivieren(page, hostB); await archivieren(page, hostB);
}); });
test("Rotation abbrechen, nachdem der neue Schlüssel ausgerollt ist (#032)", async ({ page }) => {
const kennung = Date.now().toString(36);
const hostA = `zz-e2e-a-${kennung}`;
const hostB = `zz-e2e-b-${kennung}`;
const zweck = `zz-e2e Abbruch ${kennung}`;
zieleZuruecksetzen();
await anmelden(page);
await hostEinrichten(page, hostA, PORT.a);
await hostEinrichten(page, hostB, PORT.b);
const schluesselSeite = await kernAufbauen(page, zweck, hostA, hostB);
await page.reload();
await page.getByRole("link", { name: "Rotieren" }).click();
await page.getByRole("button", { name: "Neuen Schlüssel erzeugen und Zuweisungen übernehmen" }).click();
await page.getByRole("button", { name: "Dry-Run starten" }).click({ timeout: 60_000 });
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Verbindungen testen" })).toBeVisible({ timeout: 60_000 });
expect(aufZiel("ziel-a", "cat /root/.ssh/config")).toMatch(/IdentityFile ~\/\.ssh\/.*-r\d{8}$/m);
// Abbrechen: Rueckfrage, dann Widerruf des neuen Schluessels mit Dry-Run.
await page.getByRole("button", { name: "Rotation abbrechen" }).click();
const rueckfrage = page.getByRole("group", { name: "Rotation abbrechen?" });
await expect(rueckfrage.getByText(`„${zweck}“ bleibt aktiv und unverändert`)).toBeVisible();
await rueckfrage.getByRole("button", { name: "Neuen Schlüssel widerrufen" }).click();
await expect(page.getByRole("heading", { name: "Rotation wird abgebrochen" })).toBeVisible();
await page.getByRole("button", { name: "Dry-Run starten" }).click();
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
await expect(page.getByRole("heading", { name: "Rotation abgebrochen" })).toBeVisible({ timeout: 60_000 });
// Auf den Hosts: wie vorher. Der Alias geht wieder mit dem alten Schluessel.
expect(aufZiel("ziel-a", "ls /root/.ssh")).not.toMatch(/-r\d{8}/);
expect(aufZiel("ziel-a", "cat /root/.ssh/config")).not.toMatch(/-r\d{8}/);
expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root");
// Der alte bleibt aktiv; die Kette zeigt den widerrufenen Nachfolger, kein „Rotieren“ mehr.
await page.goto(schluesselSeite);
const kette = page.getByRole("list", { name: "Rotationskette" });
await expect(kette.getByText("widerrufen", { exact: true })).toBeVisible();
await expect(page.getByText(/Die Rotation zu „.*“ wurde abgebrochen/)).toBeVisible();
await expect(page.getByRole("link", { name: "Rotieren" })).toHaveCount(0);
await archivieren(page, hostA);
await archivieren(page, hostB);
});
+81 -28
View File
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { rotationsDatum } from "@/lib/rotation"; import { rotationsDatum } from "@/lib/rotation";
import { import {
rotationAbbrechen,
rotationAltWiderrufen, rotationAltWiderrufen,
rotationStarten, rotationStarten,
rotationUebernehmen, rotationUebernehmen,
@@ -55,38 +56,44 @@ async function trockenlaufUndAusrollen(schluesselId: string) {
expect(ausrollen.fehler).toBeNull(); expect(ausrollen.fehler).toBeNull();
} }
/** Dienst-Key wie nach dem Assistenten: Private Key auf A (Alias zu-b), Public Key auf B, ausgerollt. */
async function alterSchluesselAusgerollt(aId: string, bId: string) {
const paar = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:backup");
const alt = await prisma.schluessel.create({
data: {
id: paar.id,
name: "Backup",
slug: "backup",
typ: "ED25519",
kategorie: "DIENST",
publicKey: paar.publicKeyZeile,
fingerprint: paar.fingerprint,
kommentar: "sshkv:backup",
...paar.versiegelt,
},
select: { id: true, publicKey: true },
});
await privateVerwendungAnlegen(
{
schluesselId: alt.id,
hostId: aId,
zielbenutzer: "root",
pfad: "~/.ssh/id_backup",
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 },
knownHostsFuer: [bId],
},
AKTEUR,
);
await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: bId, zielbenutzer: "root" }, AKTEUR);
await trockenlaufUndAusrollen(alt.id);
return alt;
}
describe("Rotation", () => { describe("Rotation", () => {
it("tauscht den Schluessel aus, ohne dass der Alias aufhoert zu funktionieren", async () => { it("tauscht den Schluessel aus, ohne dass der Alias aufhoert zu funktionieren", async () => {
const a = await eingerichteterHost(zielA, "ziel-a"); const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b"); const b = await eingerichteterHost(zielB, "ziel-b");
const paar = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:backup"); const alt = await alterSchluesselAusgerollt(a.id, b.id);
const alt = await prisma.schluessel.create({
data: {
id: paar.id,
name: "Backup",
slug: "backup",
typ: "ED25519",
kategorie: "DIENST",
publicKey: paar.publicKeyZeile,
fingerprint: paar.fingerprint,
kommentar: "sshkv:backup",
...paar.versiegelt,
},
select: { id: true, publicKey: true },
});
await privateVerwendungAnlegen(
{
schluesselId: alt.id,
hostId: a.id,
zielbenutzer: "root",
pfad: "~/.ssh/id_backup",
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 },
knownHostsFuer: [b.id],
},
AKTEUR,
);
await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: b.id, zielbenutzer: "root" }, AKTEUR);
await trockenlaufUndAusrollen(alt.id);
// 1–2: Nachfolger erzeugen, Zuweisungen uebernehmen. // 1–2: Nachfolger erzeugen, Zuweisungen uebernehmen.
await vorgangImWorker(await rotationStarten(alt.id, AKTEUR)); await vorgangImWorker(await rotationStarten(alt.id, AKTEUR));
@@ -123,4 +130,50 @@ describe("Rotation", () => {
const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un"); const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
expect(ssh.stdout.trim()).toBe("root"); expect(ssh.stdout.trim()).toBe("root");
}); });
it("bricht ab: der ausgerollte neue Schluessel verschwindet, der alte arbeitet weiter (#032)", async () => {
const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b");
const alt = await alterSchluesselAusgerollt(a.id, b.id);
const altVorher = await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } });
await vorgangImWorker(await rotationStarten(alt.id, AKTEUR));
await rotationUebernehmen(alt.id, AKTEUR, { privatErlaubt: true });
const neu = await prisma.schluessel.findFirstOrThrow({
where: { vorgaengerId: alt.id },
select: { id: true, publicKey: true },
});
await trockenlaufUndAusrollen(neu.id);
const neuerPfad = `id_backup-r${rotationsDatum().kompakt}`;
expect((await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout).toContain(
`IdentityFile ~/.ssh/${neuerPfad}`,
);
// Abbrechen = Widerruf des neuen Schluessels mit Dry-Run und Anwenden.
await rotationAbbrechen(alt.id, AKTEUR, { privatErlaubt: true });
await trockenlaufUndAusrollen(neu.id);
expect((await widerrufAbschliessen([neu.id], AKTEUR))[0]?.status).toBe("WIDERRUFEN");
const authorized = (await befehlAlsRoot(zielB, "cat /root/.ssh/authorized_keys")).stdout;
expect(authorized).not.toContain(neu.publicKey.split(" ")[1]!);
expect(authorized).toContain(alt.publicKey.split(" ")[1]!);
const dateien = (await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout.split("\n");
expect(dateien).toContain("id_backup");
expect(dateien).not.toContain(neuerPfad);
// Der Alias zeigt wieder auf die alte Datei – und funktioniert.
const config = (await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout;
expect(config.match(/^Host zu-b$/gm)).toHaveLength(1);
expect(config).toContain("IdentityFile ~/.ssh/id_backup\n");
const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
expect(ssh.stdout.trim()).toBe("root");
// Der alte Schluessel ist unveraendert, seine Zuweisungen sind ausgerollt.
expect(await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } })).toEqual(altVorher);
const zustaende = [
...(await prisma.publicVerwendung.findMany({ where: { schluesselId: alt.id } })),
...(await prisma.privateVerwendung.findMany({ where: { schluesselId: alt.id } })),
].map((v) => v.zustand);
expect(zustaende).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
expect((await rotationsStand(alt.id)).abgebrochen).toBe(true);
});
}); });
+117 -1
View File
@@ -8,13 +8,14 @@ import { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation";
import { rotationsKette } from "@/server/queries/schluessel"; import { rotationsKette } from "@/server/queries/schluessel";
import { import {
RotationsFehler, RotationsFehler,
rotationAbbrechen,
rotationAltWiderrufen, rotationAltWiderrufen,
rotationGesperrt, rotationGesperrt,
rotationStarten, rotationStarten,
rotationUebernehmen, rotationUebernehmen,
rotationsStand, rotationsStand,
} from "@/server/services/rotation"; } from "@/server/services/rotation";
import { widerrufAbschliessen } from "@/server/services/widerruf"; import { widerrufAbschliessen, widerrufEinleiten } from "@/server/services/widerruf";
import { masterKeyVergessen } from "@/worker/geheimnisse"; import { masterKeyVergessen } from "@/worker/geheimnisse";
import { datenbankLeeren } from "./hilfen/db"; import { datenbankLeeren } from "./hilfen/db";
import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang"; import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang";
@@ -258,3 +259,118 @@ describe("Rotation Schritt fuer Schritt", () => {
await expect(rotationStarten(person.id, admin)).rejects.toThrow(/neuen Public Key/); await expect(rotationStarten(person.id, admin)).rejects.toThrow(/neuen Public Key/);
}); });
}); });
describe("Rotation abbrechen (#032)", () => {
/** Rotation bis Schritt 3: Nachfolger uebernommen und ausgerollt. */
async function laufendeRotation() {
const altId = await alterSchluessel();
const neuId = await nachfolgerErzeugen(altId);
await rotationUebernehmen(altId, admin, { privatErlaubt: true });
const jetzt = new Date();
await prisma.publicVerwendung.updateMany({
where: { schluesselId: neuId },
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
});
await prisma.privateVerwendung.updateMany({
where: { schluesselId: neuId },
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
});
return { altId, neuId };
}
async function verwendungen(schluesselId: string) {
const [oeffentlich, privat] = await Promise.all([
prisma.publicVerwendung.findMany({ where: { schluesselId }, select: { zustand: true } }),
prisma.privateVerwendung.findMany({ where: { schluesselId }, select: { zustand: true } }),
]);
return [...oeffentlich, ...privat].map((v) => v.zustand).sort();
}
it("widerruft den neuen Schluessel, der alte bleibt unveraendert", async () => {
const { altId, neuId } = await laufendeRotation();
const altVorher = await prisma.schluessel.findUniqueOrThrow({ where: { id: altId } });
await expect(rotationAbbrechen(altId, admin, { privatErlaubt: false })).rejects.toThrow(/Admin/);
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
// Ausgerollt heisst: erst Dry-Run und Anwenden entfernen ihn – vorgemerkt, nicht sofort weg.
expect(await verwendungen(neuId)).toEqual(["ENTFERNEN", "ENTFERNEN"]);
expect(await verwendungen(altId)).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
expect(await prisma.schluessel.findUniqueOrThrow({ where: { id: altId } })).toEqual(altVorher);
const stand = await rotationsStand(altId);
expect(stand.abgebrochen).toBe(true);
// Kein Weiterdrehen mehr, ein zweiter Abbruch aendert nichts.
await expect(rotationUebernehmen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/abgebrochen/);
await expect(rotationAltWiderrufen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/abgebrochen/);
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.rotation.abgebrochen" } })).toBe(1);
const eintrag = await prisma.protokoll.findFirstOrThrow({
where: { aktion: "schluessel.rotation.abgebrochen" },
select: { fingerprint: true, details: true },
});
expect(eintrag).toMatchObject({ fingerprint: "SHA256:alt", details: { nachfolgerId: neuId } });
// Nach dem Anwenden: der Nachfolger ist WIDERRUFEN (nicht ROTIERT), die Kette bleibt.
await prisma.publicVerwendung.updateMany({
where: { schluesselId: neuId },
data: { zustand: "ENTFERNT" },
});
await prisma.privateVerwendung.updateMany({
where: { schluesselId: neuId },
data: { zustand: "ENTFERNT" },
});
expect((await widerrufAbschliessen([neuId], admin))[0]?.status).toBe("WIDERRUFEN");
expect((await rotationsKette(altId)).map((g) => [g.id, g.status])).toEqual([
[altId, "AKTIV"],
[neuId, "WIDERRUFEN"],
]);
expect(
(await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId }, select: { vorgaengerId: true } }))
.vorgaengerId,
).toBe(altId);
await expect(rotationStarten(altId, admin)).rejects.toThrow(/abgebrochen/);
});
it("setzt nie ausgerollte Zuweisungen des neuen Schluessels sofort auf ENTFERNT", async () => {
const altId = await alterSchluessel();
const neuId = await nachfolgerErzeugen(altId);
await rotationUebernehmen(altId, admin, { privatErlaubt: true });
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
expect(await verwendungen(neuId)).toEqual(["ENTFERNT", "ENTFERNT"]);
expect((await widerrufAbschliessen([neuId], admin))[0]?.status).toBe("WIDERRUFEN");
expect(await verwendungen(altId)).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
});
it("geht nicht mehr, sobald der alte Schluessel entfernt wird", async () => {
const { altId } = await laufendeRotation();
const privat = await prisma.privateVerwendung.findFirstOrThrow({
where: { schluessel: { vorgaengerId: altId } },
select: { id: true },
});
await prisma.vorgang.create({
data: { art: "VERBINDUNGSTEST", status: "ERLEDIGT", eingabe: { privatVerwendungId: privat.id } },
});
await rotationAltWiderrufen(altId, admin, { privatErlaubt: true });
await expect(rotationAbbrechen(altId, admin, { privatErlaubt: true })).rejects.toThrow(
/nicht mehr abbrechen/,
);
await expect(rotationAbbrechen(b.id, admin, { privatErlaubt: true })).rejects.toThrow(/gibt es nicht/);
});
it("macht aus dem Widerruf des alten Schluessels kein ROTIERT, solange der Abbruch laeuft", async () => {
const { altId, neuId } = await laufendeRotation();
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
await widerrufEinleiten([altId], admin, { privatErlaubt: true });
await prisma.publicVerwendung.updateMany({
where: { schluesselId: altId },
data: { zustand: "ENTFERNT" },
});
await prisma.privateVerwendung.updateMany({
where: { schluesselId: altId },
data: { zustand: "ENTFERNT" },
});
expect((await widerrufAbschliessen([altId], admin))[0]?.status).toBe("WIDERRUFEN");
expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId } })).status).toBe("AKTIV");
});
});