Merge branch 'phase/032-nacharbeiten'

This commit is contained in:
2026-10-12 17:56:33 +02:00
27 changed files with 789 additions and 113 deletions
+72 -23
View File
@@ -23,7 +23,8 @@ import { widerrufUebersicht } from "@/server/queries/widerruf";
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
import { WiderrufKnopf } from "@/components/widerruf/WiderrufKnopf";
import { ablaufZustand } from "@/lib/schluessel";
import { rotationGesperrt } from "@/server/services/rotation";
import { RotationAbbrechen } from "@/components/rotation/RotationAbbrechen";
import { rotationAbgebrochen, rotationGesperrt } from "@/server/services/rotation";
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
@@ -216,9 +217,17 @@ export default async function SchluesselSeite({
const jetzt = new Date();
const abgelaufen = s.status === "ABGELAUFEN" || ablaufZustand(s.ablaufAm, jetzt).art === "abgelaufen";
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 =
!imWiderruf &&
!(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.status === "AKTIV" || s.status === "ABGELAUFEN") &&
rechte.public &&
@@ -235,13 +244,19 @@ export default async function SchluesselSeite({
privatZuweisungen: aktivePrivat,
});
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 rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf));
// Auf dem Nachfolger: die laufende Rotation gehoert zum Vorgaenger.
const rotationDesVorgaengers =
s.vorgaenger && (s.vorgaenger.status === "AKTIV" || s.vorgaenger.status === "ABGELAUFEN")
? s.vorgaenger
: null;
// Nach einem abgeschlossenen Abbruch (#032) bleibt der Nachfolger in der Kette; ein zweiter
// passt nicht hinein (vorgaengerId ist eindeutig) – deshalb kein „Rotieren“ mehr.
const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf && !s.nachfolger));
// Abbrechen auf dem Nachfolger: gleiche Rechte wie die Rotation, Private Keys nur mit Recht.
const darfAbbrechen =
Boolean(rotationDesVorgaengers && !rotationDesVorgaengers.widerrufEingeleitetAm) &&
!imWiderruf &&
s.status === "AKTIV" &&
verwaltenRecht &&
rechte.public &&
(s.privateVerwendungen.length === 0 || rechte.privat);
const vorauswahl = (
Array.isArray(zielkontenParameter) ? zielkontenParameter.join(",") : (zielkontenParameter ?? "")
)
@@ -308,20 +323,41 @@ export default async function SchluesselSeite({
}
/>
{rotationDesVorgaengers && (
<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>
</Karte>
)}
{rotationDesVorgaengers &&
(imWiderruf || s.status === "WIDERRUFEN" ? (
<Karte titel="Rotation abgebrochen">
<p className="text-muted text-[14px]">
Dieser Schlüssel sollte „{rotationDesVorgaengers.name}“ ablösen; die Rotation wurde abgebrochen.
{imWiderruf ? " Er wird gerade entfernt; " : " Er ist von allen Hosts entfernt; "}
<Link href={`/schluessel/${rotationDesVorgaengers.id}`} className="text-accent hover:underline">
{rotationDesVorgaengers.name}
</Link>{" "}
bleibt aktiv.
</p>
</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 && (
<Karte
@@ -336,7 +372,7 @@ export default async function SchluesselSeite({
vorauswahl={vorauswahl}
ruecksprung={`/schluessel/${s.id}`}
darfPrivat={rechte.privat}
ohneZielkonten={rotationLaeuft}
ohneZielkonten={rotationLaeuft || Boolean(rotationDesVorgaengers)}
/>
</Karte>
)}
@@ -367,7 +403,8 @@ export default async function SchluesselSeite({
<Karte titel={s.status === "WIDERRUFEN" ? "Widerrufen" : "Rotiert"}>
<p className="text-muted text-[14px]">
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{" "}
@@ -457,6 +494,10 @@ export default async function SchluesselSeite({
!imWiderruf &&
(s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && (
<Karte
// Sprungziel fuer „Zum Dry-Run“ aus der Zuweisungsmatrix; scroll-mt haelt die Karte
// unter der festen Kopfleiste der schmalen Ansicht (#032).
id="dry-run"
className="scroll-mt-20"
titel="Dry-Run-Vorschau"
untertitel="Erst prüfen, dann anwenden – auf den Hosts ändert sich vorher nichts"
>
@@ -498,6 +539,14 @@ export default async function SchluesselSeite({
))}
</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 && (
<p className={`text-muted text-[14px] ${kette.length > 1 ? "mt-3" : ""}`}>
{rotationsSperre.grund}
@@ -59,9 +59,14 @@ export default async function RotierenSeite({ params }: { params: Promise<{ id:
}
const widerruf =
stand.neu && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT"
stand.neu && !stand.abgebrochen && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT"
? await offeneEntfernungen([alt.id])
: 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 (
<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,
tests: stand.neu ? stand.tests : [],
widerruf,
abbruch,
darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"),
}}
/>
+3 -10
View File
@@ -9,6 +9,7 @@ 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 { idsAusSuchparameter } from "@/lib/schluessel";
import { darf } from "@/server/auth/rechte";
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { widerrufUebersicht } from "@/server/queries/widerruf";
@@ -17,14 +18,6 @@ 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
@@ -32,7 +25,7 @@ function liste(wert: string | string[] | undefined): string[] {
*/
export default async function MassenwiderrufSeite({ searchParams }: { searchParams: Suchparameter }) {
const parameter = await searchParams;
const ids = liste(parameter.id);
const ids = idsAusSuchparameter(parameter.id);
const selbst = `/schluessel/widerruf?${ids.map((id) => `id=${id}`).join("&")}`;
const { benutzer } = await sitzungErforderlich("massenwiderruf", selbst);
@@ -61,7 +54,7 @@ export default async function MassenwiderrufSeite({ searchParams }: { searchPara
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);
const vorauswahl = idsAusSuchparameter(parameter.zielkonten);
return (
<div className="flex flex-col gap-6 sm:gap-7">
+45
View File
@@ -9,6 +9,7 @@ import { Button, Formularfehler, Karte } from "@/components/ui";
import { Schrittleiste } from "@/components/ui/Schrittleiste";
import { warten } from "@/components/vorgang/warten";
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
import { RotationAbbrechen } from "./RotationAbbrechen";
import {
rotationAltWiderrufenAktion,
rotationStartenAktion,
@@ -47,6 +48,11 @@ export type RotationsDaten = {
/** Offene Entfernungen des alten Schluessels, sobald sein Widerruf laeuft. */
widerruf: OffeneEntfernung[] | null;
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 }) {
@@ -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 (
<div className="flex flex-col gap-6">
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} beschriftung="Schritte der Rotation" />
@@ -225,6 +265,11 @@ export function Rotation({ daten }: { daten: RotationsDaten }) {
</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 && (
<Karte titel="Rotation abgeschlossen">
<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>
);
}
+4
View File
@@ -13,6 +13,7 @@ export function Karte({
className,
innen = "normal",
as: Element = "section",
id,
}: {
titel?: ReactNode;
untertitel?: ReactNode;
@@ -22,9 +23,12 @@ export function Karte({
/** „ohne“ für Inhalte, die bis an den Rand reichen (z. B. Tabellen mit eigenem Abstand). */
innen?: "normal" | "ohne";
as?: "section" | "div" | "article";
/** Sprungziel für Links von anderen Seiten (z. B. `#dry-run`, #032). */
id?: string;
}) {
return (
<Element
id={id}
className={clsx(
"border-border bg-surface min-w-0 rounded-karte border",
innen === "normal" && "p-5 sm:p-6",
+4 -6
View File
@@ -32,6 +32,7 @@ export function WiderrufFormular({
const [laeuft, starten] = useTransition();
const gesperrt = uebersicht.schluessel.filter((s) => s.gesperrt);
const mehrere = uebersicht.schluessel.length > 1;
const knopfText = mehrere ? `${uebersicht.schluessel.length} Schlüssel widerrufen` : "Widerruf vorbereiten";
const ziel = (() => {
const url = new URL(ruecksprung, "http://x");
@@ -100,7 +101,8 @@ export function WiderrufFormular({
href={`/bestaetigen?ziel=${encodeURIComponent(ruecksprung)}`}
className="text-accent inline-flex min-h-11 items-center text-[14px] font-medium"
>
Anmeldung bestätigen und danach erneut „Widerruf vorbereiten“ wählen
{/* Nennt den Knopf, den es hier wirklich gibt – beim Massenwiderruf heisst er anders (#032). */}
Anmeldung bestätigen und danach erneut „{knopfText}“ wählen
</a>
)}
<div className="flex flex-wrap justify-end gap-2.5">
@@ -124,11 +126,7 @@ export function WiderrufFormular({
})
}
>
{laeuft
? "Wird vorgemerkt …"
: mehrere
? `${uebersicht.schluessel.length} Schlüssel widerrufen`
: "Widerruf vorbereiten"}
{laeuft ? "Wird vorgemerkt …" : knopfText}
</Button>
</div>
</div>
+1 -1
View File
@@ -175,7 +175,7 @@ export function ZellenDialog({
zeigt und schreibt die Änderung.
</p>
<Button
href={`/schluessel/${schluessel.id}`}
href={`/schluessel/${schluessel.id}#dry-run`}
klein
className="self-start"
icon={<ArrowRight className="size-4" />}
+1
View File
@@ -86,6 +86,7 @@ export const AKTIONEN: Readonly<Record<string, Beschreibung>> = {
"schluessel.widerrufen": a("Schlüssel widerrufen", "widerruf"),
"schluessel.rotiert": a("Schlüssel rotiert", "widerruf"),
"schluessel.rotation.uebernommen": a("Rotation: Zuweisungen übernommen", "widerruf"),
"schluessel.rotation.abgebrochen": a("Rotation abgebrochen", "widerruf"),
// Zielkonten (#018)
"zielkonto.angelegt": a("Zielkonto angelegt", "zielkonto"),
+11
View File
@@ -293,3 +293,14 @@ export function publicKeyZeileMitKommentar(zeile: string, kommentar: string): st
const [typ, base64] = zeile.trim().split(/\s+/);
return kommentar ? `${typ} ${base64} ${kommentar}` : `${typ} ${base64}`;
}
/**
* IDs aus einem Suchparameter (`?id=a&id=b` oder `?id=a,b`), etwa fuer den Massenwiderruf (#024).
* Erlaubt Bindestriche: Vom Worker erzeugte Schluessel tragen eine UUID, nur Prisma-Defaults sind
* cuids – ohne Bindestrich fielen alle erzeugten Schluessel still aus der Auswahl (#032).
*/
export function idsAusSuchparameter(wert: string | string[] | undefined, hoechstens = 200): string[] {
const roh = Array.isArray(wert) ? wert : wert ? [wert] : [];
const gueltig = roh.flatMap((w) => w.split(",")).filter((w) => /^[a-z0-9-]{1,64}$/i.test(w));
return [...new Set(gueltig)].slice(0, hoechstens);
}
+25 -1
View File
@@ -6,7 +6,12 @@ import { akteurVon } from "@/server/actions/formular";
import { darf } from "@/server/auth/rechte";
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { FachFehler } from "@/server/services/fachfehler";
import { rotationAltWiderrufen, rotationStarten, rotationUebernehmen } from "@/server/services/rotation";
import {
rotationAbbrechen,
rotationAltWiderrufen,
rotationStarten,
rotationUebernehmen,
} from "@/server/services/rotation";
import { VerwendungsFehler } from "@/server/services/verwendungen";
/**
@@ -69,3 +74,22 @@ export async function rotationAltWiderrufenAktion(schluesselId: string): Promise
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 } },
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 } },
vorgaenger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } },
// Ein Nachfolger im Widerruf ist eine abgebrochene Rotation (#032).
nachfolger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } },
publicVerwendungen: {
where: { zustand: { not: "ENTFERNT" } },
orderBy: { angelegtAm: "asc" },
+66 -3
View File
@@ -45,7 +45,7 @@ const ALT_SELECT = {
vorlageId: true,
masterKeyVersion: 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] } } } } },
} satisfies Prisma.SchluesselSelect;
@@ -92,6 +92,13 @@ function gesperrtPruefen(alt: Alt) {
export async function rotationStarten(altId: string, akteur: Akteur): Promise<string> {
const alt = await altLaden(altId);
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.");
const name = rotationsName(alt.name, rotationsDatum().anzeige, NAME_MAX);
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 });
}
/**
* 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) {
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.");
return alt.nachfolger;
}
@@ -255,7 +273,8 @@ export async function rotationsStand(altId: string) {
const alt = await altLaden(altId);
const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen });
const neu = alt.nachfolger;
if (!neu) return { alt, gesperrt, neu: null, schritt: 0 as const };
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([
prisma.publicVerwendung.count({ where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } } }),
@@ -287,7 +306,7 @@ export async function rotationsStand(altId: string) {
: !testsGruen
? (3 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);
if (!stand.neu) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen.");
if (stand.schritt === 5) return;
if (stand.abgebrochen) throw new RotationsFehler("Die Rotation wurde abgebrochen.");
if (stand.schritt < 4) {
throw new RotationsFehler(
stand.schritt === 3
@@ -311,3 +331,46 @@ export async function rotationAltWiderrufen(
}
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,
fingerprint: true,
widerrufEingeleitetAm: true,
nachfolger: { select: { id: true, status: true } },
nachfolger: { select: { id: true, status: true, widerrufEingeleitetAm: true } },
} satisfies Prisma.SchluesselSelect;
type SchluesselKopf = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>;
@@ -167,6 +167,11 @@ export async function zielkontoKandidaten(schluesselIds: readonly string[]): Pro
export type EinleitenOptionen = {
/** Darf der Akteur Private Keys entfernen (privateKeyAusrollen)? Prueft die Aktion per darf(). */
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,
);
}
await optionen.zusaetzlich?.(tx);
});
}
@@ -406,7 +412,9 @@ export async function widerrufAbschliessen(
continue;
}
// 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) => {
const geaendert = await tx.schluessel.updateMany({
where: { id: s.id, status: { in: ["AKTIV", "ABGELAUFEN"] } },