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:
+117
-1
@@ -8,13 +8,14 @@ import { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation";
|
||||
import { rotationsKette } from "@/server/queries/schluessel";
|
||||
import {
|
||||
RotationsFehler,
|
||||
rotationAbbrechen,
|
||||
rotationAltWiderrufen,
|
||||
rotationGesperrt,
|
||||
rotationStarten,
|
||||
rotationUebernehmen,
|
||||
rotationsStand,
|
||||
} from "@/server/services/rotation";
|
||||
import { widerrufAbschliessen } from "@/server/services/widerruf";
|
||||
import { widerrufAbschliessen, widerrufEinleiten } from "@/server/services/widerruf";
|
||||
import { masterKeyVergessen } from "@/worker/geheimnisse";
|
||||
import { datenbankLeeren } from "./hilfen/db";
|
||||
import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang";
|
||||
@@ -258,3 +259,118 @@ describe("Rotation Schritt fuer Schritt", () => {
|
||||
await expect(rotationStarten(person.id, admin)).rejects.toThrow(/neuen Public Key/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rotation abbrechen (#032)", () => {
|
||||
/** Rotation bis Schritt 3: Nachfolger uebernommen und ausgerollt. */
|
||||
async function laufendeRotation() {
|
||||
const altId = await alterSchluessel();
|
||||
const neuId = await nachfolgerErzeugen(altId);
|
||||
await rotationUebernehmen(altId, admin, { privatErlaubt: true });
|
||||
const jetzt = new Date();
|
||||
await prisma.publicVerwendung.updateMany({
|
||||
where: { schluesselId: neuId },
|
||||
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
|
||||
});
|
||||
await prisma.privateVerwendung.updateMany({
|
||||
where: { schluesselId: neuId },
|
||||
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
|
||||
});
|
||||
return { altId, neuId };
|
||||
}
|
||||
|
||||
async function verwendungen(schluesselId: string) {
|
||||
const [oeffentlich, privat] = await Promise.all([
|
||||
prisma.publicVerwendung.findMany({ where: { schluesselId }, select: { zustand: true } }),
|
||||
prisma.privateVerwendung.findMany({ where: { schluesselId }, select: { zustand: true } }),
|
||||
]);
|
||||
return [...oeffentlich, ...privat].map((v) => v.zustand).sort();
|
||||
}
|
||||
|
||||
it("widerruft den neuen Schluessel, der alte bleibt unveraendert", async () => {
|
||||
const { altId, neuId } = await laufendeRotation();
|
||||
const altVorher = await prisma.schluessel.findUniqueOrThrow({ where: { id: altId } });
|
||||
|
||||
await expect(rotationAbbrechen(altId, admin, { privatErlaubt: false })).rejects.toThrow(/Admin/);
|
||||
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
|
||||
|
||||
// Ausgerollt heisst: erst Dry-Run und Anwenden entfernen ihn – vorgemerkt, nicht sofort weg.
|
||||
expect(await verwendungen(neuId)).toEqual(["ENTFERNEN", "ENTFERNEN"]);
|
||||
expect(await verwendungen(altId)).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
|
||||
expect(await prisma.schluessel.findUniqueOrThrow({ where: { id: altId } })).toEqual(altVorher);
|
||||
const stand = await rotationsStand(altId);
|
||||
expect(stand.abgebrochen).toBe(true);
|
||||
|
||||
// Kein Weiterdrehen mehr, ein zweiter Abbruch aendert nichts.
|
||||
await expect(rotationUebernehmen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/abgebrochen/);
|
||||
await expect(rotationAltWiderrufen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/abgebrochen/);
|
||||
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
|
||||
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.rotation.abgebrochen" } })).toBe(1);
|
||||
const eintrag = await prisma.protokoll.findFirstOrThrow({
|
||||
where: { aktion: "schluessel.rotation.abgebrochen" },
|
||||
select: { fingerprint: true, details: true },
|
||||
});
|
||||
expect(eintrag).toMatchObject({ fingerprint: "SHA256:alt", details: { nachfolgerId: neuId } });
|
||||
|
||||
// Nach dem Anwenden: der Nachfolger ist WIDERRUFEN (nicht ROTIERT), die Kette bleibt.
|
||||
await prisma.publicVerwendung.updateMany({
|
||||
where: { schluesselId: neuId },
|
||||
data: { zustand: "ENTFERNT" },
|
||||
});
|
||||
await prisma.privateVerwendung.updateMany({
|
||||
where: { schluesselId: neuId },
|
||||
data: { zustand: "ENTFERNT" },
|
||||
});
|
||||
expect((await widerrufAbschliessen([neuId], admin))[0]?.status).toBe("WIDERRUFEN");
|
||||
expect((await rotationsKette(altId)).map((g) => [g.id, g.status])).toEqual([
|
||||
[altId, "AKTIV"],
|
||||
[neuId, "WIDERRUFEN"],
|
||||
]);
|
||||
expect(
|
||||
(await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId }, select: { vorgaengerId: true } }))
|
||||
.vorgaengerId,
|
||||
).toBe(altId);
|
||||
await expect(rotationStarten(altId, admin)).rejects.toThrow(/abgebrochen/);
|
||||
});
|
||||
|
||||
it("setzt nie ausgerollte Zuweisungen des neuen Schluessels sofort auf ENTFERNT", async () => {
|
||||
const altId = await alterSchluessel();
|
||||
const neuId = await nachfolgerErzeugen(altId);
|
||||
await rotationUebernehmen(altId, admin, { privatErlaubt: true });
|
||||
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
|
||||
expect(await verwendungen(neuId)).toEqual(["ENTFERNT", "ENTFERNT"]);
|
||||
expect((await widerrufAbschliessen([neuId], admin))[0]?.status).toBe("WIDERRUFEN");
|
||||
expect(await verwendungen(altId)).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
|
||||
});
|
||||
|
||||
it("geht nicht mehr, sobald der alte Schluessel entfernt wird", async () => {
|
||||
const { altId } = await laufendeRotation();
|
||||
const privat = await prisma.privateVerwendung.findFirstOrThrow({
|
||||
where: { schluessel: { vorgaengerId: altId } },
|
||||
select: { id: true },
|
||||
});
|
||||
await prisma.vorgang.create({
|
||||
data: { art: "VERBINDUNGSTEST", status: "ERLEDIGT", eingabe: { privatVerwendungId: privat.id } },
|
||||
});
|
||||
await rotationAltWiderrufen(altId, admin, { privatErlaubt: true });
|
||||
await expect(rotationAbbrechen(altId, admin, { privatErlaubt: true })).rejects.toThrow(
|
||||
/nicht mehr abbrechen/,
|
||||
);
|
||||
await expect(rotationAbbrechen(b.id, admin, { privatErlaubt: true })).rejects.toThrow(/gibt es nicht/);
|
||||
});
|
||||
|
||||
it("macht aus dem Widerruf des alten Schluessels kein ROTIERT, solange der Abbruch laeuft", async () => {
|
||||
const { altId, neuId } = await laufendeRotation();
|
||||
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
|
||||
await widerrufEinleiten([altId], admin, { privatErlaubt: true });
|
||||
await prisma.publicVerwendung.updateMany({
|
||||
where: { schluesselId: altId },
|
||||
data: { zustand: "ENTFERNT" },
|
||||
});
|
||||
await prisma.privateVerwendung.updateMany({
|
||||
where: { schluesselId: altId },
|
||||
data: { zustand: "ENTFERNT" },
|
||||
});
|
||||
expect((await widerrufAbschliessen([altId], admin))[0]?.status).toBe("WIDERRUFEN");
|
||||
expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId } })).status).toBe("AKTIV");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user