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
+36 -1
View File
@@ -97,8 +97,10 @@ async function alterSchluessel() {
async function nachfolgerErzeugen(altId: string): Promise<string> {
const vorgangId = await rotationStarten(altId, admin);
await vorgangImWorker(vorgangId);
// Nach einem Abbruch gibt es mehrere Nachfolger; der juengste ist der gerade erzeugte (#033).
const neu = await prisma.schluessel.findFirstOrThrow({
where: { vorgaengerId: altId },
orderBy: { angelegtAm: "desc" },
select: { id: true },
});
return neu.id;
@@ -311,6 +313,9 @@ describe("Rotation abbrechen (#032)", () => {
});
expect(eintrag).toMatchObject({ fingerprint: "SHA256:alt", details: { nachfolgerId: neuId } });
// Solange der Abbruch laeuft, keine neue Rotation (#033).
await expect(rotationStarten(altId, admin)).rejects.toThrow(/läuft noch/);
// Nach dem Anwenden: der Nachfolger ist WIDERRUFEN (nicht ROTIERT), die Kette bleibt.
await prisma.publicVerwendung.updateMany({
where: { schluesselId: neuId },
@@ -329,7 +334,37 @@ describe("Rotation abbrechen (#032)", () => {
(await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId }, select: { vorgaengerId: true } }))
.vorgaengerId,
).toBe(altId);
await expect(rotationStarten(altId, admin)).rejects.toThrow(/abgebrochen/);
// Danach laesst sich derselbe Schluessel erneut rotieren (#033).
expect(await rotationsStand(altId)).toMatchObject({ neu: null, schritt: 0, abgebrochen: false });
const neu2 = await nachfolgerErzeugen(altId);
expect(neu2).not.toBe(neuId);
expect(await rotationsStand(altId)).toMatchObject({ neu: { id: neu2 }, schritt: 1, abgebrochen: false });
expect((await rotationsKette(altId)).map((g) => g.id)).toEqual([altId, neu2]);
await expect(rotationStarten(altId, admin)).rejects.toThrow(/schon einen Nachfolger/);
});
it("erlaubt in der Datenbank nur einen offenen Nachfolger je Schluessel (#033)", async () => {
const altId = await alterSchluessel();
const nachfolger = (n: string, daten: object = {}) =>
prisma.schluessel.create({
data: {
name: n,
slug: n,
typ: "ED25519",
kategorie: "DIENST",
publicKey: zeile("ssh-ed25519", ed25519Blob(), n),
fingerprint: `SHA256:${n}`,
kommentar: n,
vorgaengerId: altId,
...daten,
},
select: { id: true },
});
await nachfolger("abgebrochen", { status: "WIDERRUFEN", widerrufEingeleitetAm: new Date() });
await nachfolger("im-abbruch", { widerrufEingeleitetAm: new Date() });
await nachfolger("offen");
await expect(nachfolger("zweiter-offen")).rejects.toThrow();
});
it("setzt nie ausgerollte Zuweisungen des neuen Schluessels sofort auf ENTFERNT", async () => {