feat(rotation): nach Abbruch erneut rotieren (#033)

Bisher war vorgaengerId eindeutig: nach einer abgebrochenen Rotation
passte kein zweiter Nachfolger in die Kette, und der Schlüssel ließ sich
nur über Neuanlage, Zuweisen und Widerruf ersetzen.

Jetzt ist die Relation 1:n. Ein partieller Index erlaubt je Vorgänger
nur einen Nachfolger, der weder widerrufen ist noch gerade widerrufen
wird. Maßgeblich ist der jüngste. Erst wenn der Abbruch abgeschlossen
ist, beginnt die Rotation wieder bei Schritt 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 20:09:42 +02:00
co-authored by Claude Opus 5.5
parent c91e882159
commit c06c051637
17 changed files with 183 additions and 48 deletions
+10 -5
View File
@@ -246,9 +246,12 @@ export default async function SchluesselSeite({
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);
// 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));
// Nach einem abgeschlossenen Abbruch (#032) darf erneut rotiert werden (#033); solange der
// abgebrochene Nachfolger noch entfernt wird, nicht.
const rotierbar =
darfRotieren &&
(rotationLaeuft ||
(!rotationsSperre && !imWiderruf && (!s.nachfolger || s.nachfolger.status === "WIDERRUFEN")));
// Abbrechen auf dem Nachfolger: gleiche Rechte wie die Rotation, Private Keys nur mit Recht.
const darfAbbrechen =
Boolean(rotationDesVorgaengers && !rotationDesVorgaengers.widerrufEingeleitetAm) &&
@@ -543,8 +546,10 @@ export default async function SchluesselSeite({
<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.
dieser Schlüssel bleibt aktiv
{s.nachfolger.status === "WIDERRUFEN"
? " und lässt sich erneut rotieren."
: ". Danach lässt er sich erneut rotieren."}
</p>
)}
{rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && (
@@ -80,6 +80,10 @@ export default async function RotierenSeite({ params }: { params: Promise<{ id:
tests: stand.neu ? stand.tests : [],
widerruf,
abbruch,
letzterAbbruch:
!stand.neu && stand.alt.nachfolger?.status === "WIDERRUFEN"
? { id: stand.alt.nachfolger.id, name: stand.alt.nachfolger.name }
: null,
darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"),
}}
/>
+19 -16
View File
@@ -53,6 +53,8 @@ export type RotationsDaten = {
* solange sein Widerruf laeuft; null, wenn er abgeschlossen ist.
*/
abbruch: { offen: OffeneEntfernung[] | null } | null;
/** Zuletzt abgebrochener Nachfolger, wenn die Rotation danach wieder am Anfang steht (#033). */
letzterAbbruch: { id: string; name: string } | null;
};
export function Rotation({ daten }: { daten: RotationsDaten }) {
@@ -115,8 +117,8 @@ export function Rotation({ daten }: { daten: RotationsDaten }) {
</>
);
if (daten.abbruch && neu) {
return daten.abbruch.offen ? (
if (daten.abbruch?.offen && neu) {
return (
<Karte
titel="Rotation wird abgebrochen"
untertitel={`Der neue Schlüssel wird entfernt – „${alt.name}“ bleibt aktiv und unverändert.`}
@@ -132,25 +134,26 @@ export function Rotation({ daten }: { daten: RotationsDaten }) {
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">
{daten.letzterAbbruch && schritt === 0 && (
<Karte titel="Rotation abgebrochen">
<p className="text-[14px]">
Der neue Schlüssel{" "}
<Link href={`/schluessel/${daten.letzterAbbruch.id}`} className="text-accent hover:underline">
{daten.letzterAbbruch.name}
</Link>{" "}
ist widerrufen und von allen Hosts entfernt.{" "}
<Link href={`/schluessel/${alt.id}`} className="text-accent hover:underline">
{alt.name}
</Link>{" "}
bleibt aktiv und lässt sich erneut rotieren.
</p>
</Karte>
)}
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} beschriftung="Schritte der Rotation" />
{schritt <= 1 && (
+17
View File
@@ -35,3 +35,20 @@ export function rotationsPfad(pfad: string, datumKompakt: string, belegt: Readon
for (let nummer = 2; belegt.has(kandidat); nummer += 1) kandidat = `${basis}-r${datumKompakt}-${nummer}`;
return kandidat;
}
/** Laedt nur den juengsten Nachfolger – nach einem Abbruch kann es mehrere geben (#033). */
export const JUENGSTER = { orderBy: { angelegtAm: "desc" }, take: 1 } as const;
/**
* Macht aus `nachfolgerListe` (hoechstens ein Element, siehe JUENGSTER) wieder `nachfolger`.
* Massgeblich ist immer der juengste: ein laufender oder – wenn keiner laeuft – der zuletzt
* abgebrochene.
*/
export type MitNachfolger<T extends { nachfolgerListe: readonly unknown[] }> = Omit<T, "nachfolgerListe"> & {
nachfolger: T["nachfolgerListe"][number] | null;
};
export function mitNachfolger<T extends { nachfolgerListe: readonly unknown[] }>(s: T): MitNachfolger<T> {
const { nachfolgerListe, ...rest } = s;
return { ...rest, nachfolger: nachfolgerListe[0] ?? null };
}
+9 -5
View File
@@ -1,6 +1,7 @@
import type { Prisma } from "@/generated/prisma/client";
import type { SchluesselKategorie, SchluesselStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { JUENGSTER, mitNachfolger } from "@/lib/rotation";
/**
* Lesemodelle fuer Schluessel (#012). Alles hier darf jede angemeldete Rolle sehen.
@@ -60,7 +61,10 @@ export const SCHLUESSEL_DETAIL_SELECT = {
erstelltVon: { select: { name: 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 } },
nachfolgerListe: {
select: { id: true, name: true, status: true, widerrufEingeleitetAm: true },
...JUENGSTER,
},
publicVerwendungen: {
where: { zustand: { not: "ENTFERNT" } },
orderBy: { angelegtAm: "asc" },
@@ -164,7 +168,7 @@ export type SchluesselListenEintrag = Awaited<ReturnType<typeof schluesselListe>
export async function schluesselDetail(id: string) {
const schluessel = await prisma.schluessel.findUnique({ where: { id }, select: SCHLUESSEL_DETAIL_SELECT });
if (!schluessel) return null;
const { masterKeyVersion, ...rest } = schluessel;
const { masterKeyVersion, ...rest } = mitNachfolger(schluessel);
return { ...rest, privatGespeichert: masterKeyVersion !== null };
}
@@ -210,7 +214,7 @@ export async function rotationsKette(id: string, maximal = 25): Promise<KettenGl
name: true,
status: true,
vorgaengerId: true,
nachfolger: { select: { id: true } },
nachfolgerListe: { select: { id: true }, ...JUENGSTER },
},
});
const start = await glied(id);
@@ -224,12 +228,12 @@ export async function rotationsKette(id: string, maximal = 25): Promise<KettenGl
kette.unshift({ id: g.id, name: g.name, status: g.status });
davor = g.vorgaengerId;
}
for (let danach = start.nachfolger?.id; danach && kette.length < maximal && !gesehen.has(danach);) {
for (let danach = start.nachfolgerListe[0]?.id; danach && kette.length < maximal && !gesehen.has(danach);) {
const g = await glied(danach);
if (!g) break;
gesehen.add(g.id);
kette.push({ id: g.id, name: g.name, status: g.status });
danach = g.nachfolger?.id;
danach = g.nachfolgerListe[0]?.id;
}
return kette;
}
+23 -10
View File
@@ -1,6 +1,13 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation";
import {
JUENGSTER,
type MitNachfolger,
mitNachfolger,
rotationsDatum,
rotationsName,
rotationsPfad,
} from "@/lib/rotation";
import { NAME_MAX, type SchluesselVorgangEingabe } from "@/lib/schluessel";
import { eindeutigerSlug } from "@/server/services/schluessel";
import { protokollieren } from "@/server/services/protokoll";
@@ -45,11 +52,15 @@ const ALT_SELECT = {
vorlageId: true,
masterKeyVersion: true,
widerrufEingeleitetAm: true,
nachfolger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } },
nachfolgerListe: {
select: { id: true, name: true, status: true, widerrufEingeleitetAm: true },
...JUENGSTER,
},
_count: { select: { privateVerwendungen: { where: { zustand: { in: [...AKTIVE] } } } } },
} satisfies Prisma.SchluesselSelect;
type Alt = Prisma.SchluesselGetPayload<{ select: typeof ALT_SELECT }>;
type AltRoh = Prisma.SchluesselGetPayload<{ select: typeof ALT_SELECT }>;
type Alt = MitNachfolger<AltRoh>;
/**
* Warum sshkv diesen Schluessel nicht selbst rotieren kann – oder null. Ein Personen-Key (oder
@@ -77,7 +88,7 @@ export function rotationGesperrt(
async function altLaden(id: string): Promise<Alt> {
const alt = await prisma.schluessel.findUnique({ where: { id }, select: ALT_SELECT });
if (!alt) throw new RotationsFehler("Diesen Schlüssel gibt es nicht.");
return alt;
return mitNachfolger(alt);
}
function gesperrtPruefen(alt: Alt) {
@@ -92,14 +103,15 @@ 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.
// Ein vollstaendig abgebrochener (widerrufener) Nachfolger bleibt in der Historie; danach darf
// ein neuer folgen (#033). Der partielle Index haelt die Kette trotzdem eindeutig.
if (alt.nachfolger && alt.nachfolger.status !== "WIDERRUFEN") {
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.",
rotationAbgebrochen(alt.nachfolger)
? "Der Abbruch der letzten Rotation läuft noch – bitte erst den neuen Schlüssel fertig entfernen (Dry-Run und Anwenden)."
: "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 slug = await eindeutigerSlug(null, name);
// Ein vergangenes Ablaufdatum wuerde der Nachfolger sonst erben – er waere sofort abgelaufen.
@@ -272,7 +284,8 @@ export type RotationsStand = Awaited<ReturnType<typeof rotationsStand>>;
export async function rotationsStand(altId: string) {
const alt = await altLaden(altId);
const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen });
const neu = alt.nachfolger;
// Nach einem abgeschlossenen Abbruch beginnt die Rotation von vorn (#033).
const neu = alt.nachfolger?.status === "WIDERRUFEN" && alt.status !== "ROTIERT" ? null : alt.nachfolger;
if (!neu) return { alt, gesperrt, neu: null, schritt: 0 as const, abgebrochen: false };
const abgebrochen = rotationAbgebrochen(neu);
+5 -3
View File
@@ -3,6 +3,7 @@ import { prisma } from "@/lib/db";
import { protokollieren } from "@/server/services/protokoll";
import { VerwendungsFehler } from "@/server/services/verwendungen";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
import { JUENGSTER, type MitNachfolger, mitNachfolger } from "@/lib/rotation";
/**
* Widerruf und Massenwiderruf (#024).
@@ -37,10 +38,11 @@ const SCHLUESSEL_KOPF = {
status: true,
fingerprint: true,
widerrufEingeleitetAm: true,
nachfolger: { select: { id: true, status: true, widerrufEingeleitetAm: true } },
nachfolgerListe: { select: { id: true, status: true, widerrufEingeleitetAm: true }, ...JUENGSTER },
} satisfies Prisma.SchluesselSelect;
type SchluesselKopf = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>;
type KopfRoh = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>;
type SchluesselKopf = MitNachfolger<KopfRoh>;
/** Warum ein Schluessel nicht widerrufen werden kann – oder null. */
export function widerrufGesperrt(s: Pick<SchluesselKopf, "kategorie" | "status" | "name">): string | null {
@@ -61,7 +63,7 @@ async function schluesselLaden(ids: readonly string[]): Promise<SchluesselKopf[]
orderBy: { name: "asc" },
});
if (liste.length !== eindeutig.length) throw new WiderrufFehler("Einen der Schlüssel gibt es nicht.");
return liste;
return liste.map(mitNachfolger);
}
// ---------------------------------------------------------------- Zielkonten
+2 -1
View File
@@ -110,7 +110,8 @@ export const schluesselErzeugenHandler: Handler = async ({ vorgang, geheim }) =>
});
} catch (fehler) {
if (istEindeutigkeitsFehler(fehler)) {
// vorgaengerId ist eindeutig: zwei Rotationen desselben Schluessels gleichzeitig (#023).
// Je Vorgaenger nur ein offener Nachfolger (partieller Index, #023/#033): zwei Rotationen
// desselben Schluessels gleichzeitig.
if (eingabe.vorgaengerId) throw new VorgangsFehler("Für diesen Schlüssel läuft bereits eine Rotation.");
throw new VorgangsFehler(
`Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut erzeugen.`,