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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"] } },
|
||||
|
||||
Reference in New Issue
Block a user