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:
@@ -26,6 +26,7 @@ const REGELN = [
|
||||
|
||||
const INDIZES = [
|
||||
"schluessel_ein_instanzstandard",
|
||||
"schluessel_ein_offener_nachfolger",
|
||||
"publicverwendung_eindeutig_host",
|
||||
"publicverwendung_eindeutig_gruppe",
|
||||
"privateverwendung_eindeutig",
|
||||
|
||||
@@ -105,12 +105,16 @@ test("Rotation abbrechen, nachdem der neue Schlüssel ausgerollt ist (#032)", as
|
||||
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.
|
||||
// Der alte bleibt aktiv; die Kette zeigt den widerrufenen Nachfolger – und er laesst sich
|
||||
// erneut rotieren (#033).
|
||||
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 expect(page.getByText(/Die Rotation zu „.*“ wurde abgebrochen.*erneut rotieren/)).toBeVisible();
|
||||
await page.getByRole("link", { name: "Rotieren" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Neuen Schlüssel erzeugen und Zuweisungen übernehmen" }),
|
||||
).toBeVisible();
|
||||
|
||||
await archivieren(page, hostA);
|
||||
await archivieren(page, hostB);
|
||||
|
||||
@@ -174,6 +174,7 @@ describe("Rotation", () => {
|
||||
...(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);
|
||||
// Abbruch abgeschlossen: die Rotation steht wieder am Anfang (#033).
|
||||
expect(await rotationsStand(alt.id)).toMatchObject({ neu: null, schritt: 0, abgebrochen: false });
|
||||
});
|
||||
});
|
||||
|
||||
+36
-1
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user