feat(deploymentkey): Rotation vor dem Umstellen abbrechen (#036)

Eine begonnene Rotation ließ sich nicht zurücknehmen; nahm ein Host den
neuen Key dauerhaft nicht an, blieb nur das Übergehen. Abbrechen
verwirft jetzt den neuen Key samt Private Key, ein Dry-Run nimmt ihn
wieder von den Hosts, und eine neue Rotation kann sofort starten.

Nebenbei: Aktionen der Benutzerverwaltung (#035) bekommen im Audit-Log
eine eigene Gruppe, statt den Filter „Anmeldung“ zu verwässern; der
E2E-Test für lokale Konten hängt nicht mehr von der Reihenfolge ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 22:52:32 +02:00
co-authored by Claude Opus 5.5
parent 68dbc7cadb
commit 5d03681f3c
11 changed files with 160 additions and 13 deletions
+33
View File
@@ -19,6 +19,39 @@ async function bestaetigen(page: Page) {
await expect(page.getByRole("heading", { name: "Deployment-Key rotieren" })).toBeVisible();
}
test("Rotation des Deployment-Keys abbrechen und aufräumen (#036)", async ({ page }) => {
const kennung = Date.now().toString(36);
const hostA = `zz-e2e-a-${kennung}`;
await anmelden(page);
await hostEinrichten(page, hostA, PORT.a);
await page.goto("/deployment-key");
await bestaetigen(page);
// Eine liegengebliebene Rotation (etwa aus einem abgebrochenen Testlauf) wird mit abgebrochen.
const erzeugen = page.getByRole("button", { name: "Neuen Deployment-Key erzeugen" });
if (await erzeugen.isVisible()) {
await erzeugen.click();
await expect(page.getByText("Neuer Key (noch nicht Standard)")).toBeVisible({ timeout: 30_000 });
}
await page.getByRole("button", { name: "Dry-Run starten" }).click();
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
await expect(page.getByText("Ausgerollt.", { exact: true })).toBeVisible({ timeout: 60_000 });
expect(aufZiel("ziel-a", "grep -c sshkv-deployment /home/sshkv/.ssh/authorized_keys").trim()).toBe("2");
await page.getByRole("button", { name: "Rotation abbrechen" }).click();
await expect(page.getByRole("heading", { name: "Abgebrochene Rotation aufräumen" })).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "Dry-Run starten" }).click();
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
await expect(page.getByText("Ausgerollt.", { exact: true })).toBeVisible({ timeout: 60_000 });
expect(aufZiel("ziel-a", "grep -c sshkv-deployment /home/sshkv/.ssh/authorized_keys").trim()).toBe("1");
// Ein neuer Anlauf ist sofort moeglich.
await expect(page.getByRole("button", { name: "Neuen Deployment-Key erzeugen" })).toBeVisible();
await archivieren(page, hostA);
});
test("Deployment-Key rotieren", async ({ page }) => {
const kennung = Date.now().toString(36);
const hostA = `zz-e2e-a-${kennung}`;
+2 -6
View File
@@ -17,12 +17,8 @@ test("lokales Konto anlegen, erste Anmeldung, sperren", async ({ page, browser }
await page.getByRole("link", { name: "Konten verwalten" }).click();
await expect(page.getByRole("heading", { name: "Benutzer", exact: true })).toBeVisible();
await page.getByLabel("Anmeldename").fill(anmeldename);
await page.getByLabel("Name", { exact: true }).fill("E2E Lokal");
await page.getByLabel("Rolle", { exact: true }).selectOption("OPERATOR");
await page.getByRole("button", { name: "Konto anlegen" }).click();
// Ohne frische Bestaetigung lehnt der Server ab; danach klappt es.
await expect(page.getByText(/erneute Bestätigung/)).toBeVisible();
// Frische Bestaetigung immer selbst holen – ob ein frueherer Test sie schon gesetzt hat,
// haengt von der Reihenfolge ab. Dass das Recht sie verlangt, pruefen die Unit-Tests.
await page.goto("/bestaetigen?ziel=%2Feinstellungen%2Fbenutzer");
await oidcBestaetigen(page);
await expect(page).toHaveURL(/\/einstellungen\/benutzer$/);
+30 -1
View File
@@ -41,7 +41,7 @@ beforeEach(async () => {
AKTEUR.id = (await prisma.benutzer.create({ data: { name: "Test-Admin", rolle: "ADMIN" } })).id;
});
const schritt = (s: "erzeugen" | "pruefen" | "abschliessen") =>
const schritt = (s: "erzeugen" | "pruefen" | "abbrechen" | "abschliessen") =>
vorgangLaufen("ROTATION", { eingabe: { schritt: s }, angefordertVonId: AKTEUR.id });
async function trockenlaufUndAusrollen() {
@@ -136,6 +136,35 @@ describe("Rotation des Deployment-Keys", () => {
expect(await prisma.abweichung.count({ where: { hostId: a.id, behobenAm: null } })).toBe(0);
});
it("bricht vor dem Umstellen ab und laesst danach eine neue Rotation zu (#036)", async () => {
const a = await eingerichteterHost(zielA, "ziel-a");
const alt = await prisma.schluessel.findFirstOrThrow({ where: { instanzStandard: true } });
expect((await schritt("erzeugen")).status).toBe("ERLEDIGT");
const neu = (await rotationsStand()).nachfolger!;
await trockenlaufUndAusrollen();
expect(await fingerprintsAuf(a)).toContain(neu.fingerprint);
expect((await schritt("abbrechen")).status).toBe("ERLEDIGT");
const verworfen = await prisma.schluessel.findUniqueOrThrow({ where: { id: neu.id } });
expect(verworfen).toMatchObject({ status: "WIDERRUFEN", privatVerschluesselt: null });
expect(await rotationsStand()).toMatchObject({ phase: "bereit", abgebrochen: { id: neu.id } });
// Ohne laufende Rotation gibt es nichts mehr abzubrechen.
expect((await schritt("abbrechen")).status).toBe("FEHLGESCHLAGEN");
// Der Dry-Run nimmt den verworfenen Key wieder heraus; der alte arbeitet unveraendert.
await trockenlaufUndAusrollen();
const fingerprints = await fingerprintsAuf(a);
expect(fingerprints).toContain(alt.fingerprint);
expect(fingerprints).not.toContain(neu.fingerprint);
expect(await anmeldungKlappt(a, alt.id)).toBe(true);
// Eine neue Rotation beginnt von vorn.
expect((await schritt("erzeugen")).status).toBe("ERLEDIGT");
const zweiter = (await rotationsStand()).nachfolger!;
expect(zweiter.id).not.toBe(neu.id);
expect(zweiter.vorgaengerId).toBe(alt.id);
});
it("laesst Hosts mit eigenem Deployment-Key aus", async () => {
const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b");