From 5d03681f3c4d71f4a9c7578da45e01b97dadaa1b Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 22:52:32 +0200 Subject: [PATCH] feat(deploymentkey): Rotation vor dem Umstellen abbrechen (#036) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 2 ++ docs/anleitung/README.md | 3 +- docs/issues/036-deploymentkey-abbrechen.md | 25 ++++++++++++++++ src/app/(app)/deployment-key/page.tsx | 24 +++++++++++++++- src/lib/protokoll-aktionen.ts | 1 + src/server/actions/deploymentkey.ts | 4 +-- src/server/services/deploymentkey.ts | 17 +++++++++-- src/worker/ablaeufe/deploymentkey.ts | 25 ++++++++++++++++ tests/e2e/deploymentkey.spec.ts | 33 ++++++++++++++++++++++ tests/e2e/lokale-konten.spec.ts | 8 ++---- tests/integration/deploymentkey.test.ts | 31 +++++++++++++++++++- 11 files changed, 160 insertions(+), 13 deletions(-) create mode 100644 docs/issues/036-deploymentkey-abbrechen.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d134b86..52adb5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,12 +12,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). - Laufende Schlüsselrotation abbrechen – der Nachfolger wird per Dry-Run wieder entfernt (#032) - „Zum Dry-Run“ in der Zuweisungsmatrix springt direkt zur Dry-Run-Karte (#032) - Nach einer abgebrochenen Rotation lässt sich derselbe Schlüssel erneut rotieren (#033) +- Rotation des Deployment-Keys lässt sich vor dem Umstellen abbrechen; der neue Key wird verworfen und per Dry-Run wieder entfernt (#036) - Lokale Konten mit Passwort und Pflicht-TOTP neben oder statt authentik: Admins legen sie unter Einstellungen → Benutzer an (Einmal-Passwort, Rollen, Sperren, Zurücksetzen); abschaltbar mit `SSHKV_LOKALE_KONTEN=0` (#035) ### Geändert - Protokoll-IDs werden lückenlos vergeben (#032) ### Behoben +- Aktionen der Benutzerverwaltung erscheinen im Audit-Log unter einer eigenen Gruppe „Benutzer“ statt unter „Anmeldung“ (#035) - Massenwiderruf lehnte im Browser erzeugte Schlüssel ab (IDs mit Bindestrich) (#032) - Hinweis nach verweigerter Bestätigung nannte beim Massenwiderruf den falschen Knopf (#032) diff --git a/docs/anleitung/README.md b/docs/anleitung/README.md index e51f2bf..5cd5be6 100644 --- a/docs/anleitung/README.md +++ b/docs/anleitung/README.md @@ -178,7 +178,8 @@ auswählen (nur Admin). **erzeugen**, per Dry-Run auf alle Hosts **verteilen** (er steht dann neben dem alten), die Anmeldung mit dem neuen Key **prüfen**, **umstellen** und zuletzt den alten per Dry-Run **aufräumen**. Scheitert die Prüfung auf einem Host, lässt er sich bewusst übergehen; sshkv erreicht ihn danach nur noch nach -einem neuen Bootstrap. +einem neuen Bootstrap. Solange noch nicht umgestellt ist, lässt sich die +Rotation **abbrechen**: Der neue Key wird verworfen, ein Dry-Run nimmt ihn wieder von den Hosts. ![Deployment-Key rotieren](bilder/deployment-key-desktop.png) diff --git a/docs/issues/036-deploymentkey-abbrechen.md b/docs/issues/036-deploymentkey-abbrechen.md new file mode 100644 index 0000000..9ef39d1 --- /dev/null +++ b/docs/issues/036-deploymentkey-abbrechen.md @@ -0,0 +1,25 @@ +# #036 Rotation des Deployment-Keys abbrechen +**Status:** erledigt +**Meilenstein:** v1.0.0 +**Labels:** sicherheit, backend +**Geschätzt:** 0,5 h **Tatsächlich:** 0 h 25 min + +## Ziel +Eine begonnene Rotation des Deployment-Keys (#031) zurücknehmen können, etwa wenn ein Host den neuen Key dauerhaft nicht annimmt – bisher blieb nur, Hosts beim Umstellen zu übergehen. + +## Aufgaben +- [x] Worker-Schritt `abbrechen`: Nachfolger WIDERRUFEN, Private Key sofort verwerfen, protokollieren +- [x] Nur vor dem Umstellen; danach meldet sich sshkv schon mit dem neuen Key an +- [x] Seite: Knopf „Rotation abbrechen“, danach Karte „Abgebrochene Rotation aufräumen“ mit Dry-Run +- [x] Neue Rotation sofort möglich (partieller Index aus #033) + +## Akzeptanzkriterien +- [x] Integrationstest: verteilen → abbrechen → Dry-Run entfernt den neuen Key, alter arbeitet weiter → neue Rotation startet +- [x] E2E: abbrechen und aufräumen im Browser; nimmt auch eine liegengebliebene Rotation mit + +## Abhängigkeiten +#031, #033 + +## Notizen / Entscheidungen +- Gefunden, weil ein fehlgeschlagener E2E-Lauf eine Rotation auf verwaisten Testhosts hängen ließ: Das Umstellen verweigerte sich zu Recht, aber es gab keinen Weg zurück. +- Der verworfene Key bleibt bis zum Dry-Run in den Blöcken stehen (harmlos – sein Private Key existiert nicht mehr); die Aufräum-Karte zeigt das. diff --git a/src/app/(app)/deployment-key/page.tsx b/src/app/(app)/deployment-key/page.tsx index bc57396..42777d5 100644 --- a/src/app/(app)/deployment-key/page.tsx +++ b/src/app/(app)/deployment-key/page.tsx @@ -1,4 +1,4 @@ -import { CheckCheck, KeyRound, ShieldCheck } from "lucide-react"; +import { CheckCheck, KeyRound, ShieldCheck, Undo2 } from "lucide-react"; import type { Metadata } from "next"; import Link from "next/link"; import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel"; @@ -81,6 +81,16 @@ export default async function DeploymentKeySeite() { )} + {stand.phase === "bereit" && stand.abgebrochen && ( + +

{stand.abgebrochen.fingerprint}

+ +
+ )} + {stand.phase === "bereit" && stand.aktuell && ( )} + + } + /> + )} diff --git a/src/lib/protokoll-aktionen.ts b/src/lib/protokoll-aktionen.ts index b57185e..1305bb3 100644 --- a/src/lib/protokoll-aktionen.ts +++ b/src/lib/protokoll-aktionen.ts @@ -91,6 +91,7 @@ export const AKTIONEN: Readonly> = { "deploymentkey.erzeugt": a("Deployment-Key erzeugt", "schluessel"), "deploymentkey.geprueft": a("Neuer Deployment-Key geprüft", "widerruf", false), "deploymentkey.umgestellt": a("Deployment-Key umgestellt", "widerruf"), + "deploymentkey.abgebrochen": a("Rotation des Deployment-Keys abgebrochen", "widerruf"), "deploymentkey.abgeschlossen": a("Rotation des Deployment-Keys abgeschlossen", "widerruf"), // Widerruf und Rotation (#023, #024) diff --git a/src/server/actions/deploymentkey.ts b/src/server/actions/deploymentkey.ts index bf9bf76..fb95703 100644 --- a/src/server/actions/deploymentkey.ts +++ b/src/server/actions/deploymentkey.ts @@ -21,11 +21,11 @@ function alsAntwort(fehler: unknown): { fehler: string } { } export async function rotationsSchrittAktion( - schritt: "erzeugen" | "pruefen" | "abschliessen", + schritt: "erzeugen" | "pruefen" | "abbrechen" | "abschliessen", ): Promise { try { const { benutzer } = await rechtErforderlich("deploymentKeyVerwalten"); - const geprueft = z.enum(["erzeugen", "pruefen", "abschliessen"]).parse(schritt); + const geprueft = z.enum(["erzeugen", "pruefen", "abbrechen", "abschliessen"]).parse(schritt); return { vorgangId: await rotationsSchrittAnfordern(geprueft, akteurVon(benutzer)) }; } catch (fehler) { return alsAntwort(fehler); diff --git a/src/server/services/deploymentkey.ts b/src/server/services/deploymentkey.ts index a9e08ac..9befb14 100644 --- a/src/server/services/deploymentkey.ts +++ b/src/server/services/deploymentkey.ts @@ -85,11 +85,21 @@ export async function rotationsStand() { : vorgaenger ? "aufraeumen" : "bereit"; - return { aktuell, nachfolger, vorgaenger, phase, hosts, pruefung }; + // Zuletzt abgebrochener Nachfolger des aktuellen Keys (#036): kann noch in Bloecken stehen, + // bis ein Dry-Run ihn entfernt. + const abgebrochen = + phase === "bereit" && aktuell + ? await prisma.schluessel.findFirst({ + where: { kategorie: "DEPLOYMENT", vorgaengerId: aktuell.id, status: "WIDERRUFEN" }, + orderBy: { angelegtAm: "desc" }, + select: { id: true, fingerprint: true }, + }) + : null; + return { aktuell, nachfolger, vorgaenger, phase, hosts, pruefung, abgebrochen }; } export async function rotationsSchrittAnfordern( - schritt: "erzeugen" | "pruefen" | "abschliessen", + schritt: "erzeugen" | "pruefen" | "abbrechen" | "abschliessen", akteur: Akteur, ): Promise { const stand = await rotationsStand(); @@ -98,6 +108,9 @@ export async function rotationsSchrittAnfordern( } if (schritt === "pruefen" && !stand.nachfolger) throw new DeploymentKeyFehler("Es gibt keinen neuen Deployment-Key zu prüfen."); + // Abbrechen nur vor dem Umstellen – danach meldet sich sshkv schon mit dem neuen Key an. + if (schritt === "abbrechen" && !stand.nachfolger) + throw new DeploymentKeyFehler("Es läuft keine Rotation, die sich abbrechen ließe."); if (schritt === "abschliessen" && !stand.vorgaenger) throw new DeploymentKeyFehler("Es gibt nichts abzuschließen."); return vorgangEinreihen({ art: "ROTATION", angefordertVonId: akteur.id, eingabe: { schritt } }); diff --git a/src/worker/ablaeufe/deploymentkey.ts b/src/worker/ablaeufe/deploymentkey.ts index 6643367..ae7e35a 100644 --- a/src/worker/ablaeufe/deploymentkey.ts +++ b/src/worker/ablaeufe/deploymentkey.ts @@ -95,6 +95,30 @@ async function pruefen(angefordertVonId: string | null) { return { ergebnis: { hosts } }; } +/** + * Rotation abbrechen (#036), nur vor dem Umstellen: der Nachfolger wird widerrufen und sein + * Private Key sofort verworfen – sshkv hat sich mit ihm nie produktiv angemeldet. Aus den + * Bloecken verschwindet er beim naechsten Dry-Run, weil `sollFuer()` nur aktive Nachfolger kennt. + */ +async function abbrechen(angefordertVonId: string | null) { + const stand = await rotationsStand(); + if (!stand.nachfolger) throw new VorgangsFehler("Es läuft keine Rotation, die sich abbrechen ließe."); + await prisma.schluessel.update({ + where: { id: stand.nachfolger.id }, + data: { status: "WIDERRUFEN", widerrufEingeleitetAm: new Date() }, + select: { id: true }, + }); + await privateKeyVerwerfen(stand.nachfolger.id); + await protokollieren({ + akteur: await akteurVon(angefordertVonId), + aktion: "deploymentkey.abgebrochen", + ergebnis: "ERFOLG", + fingerprint: stand.nachfolger.fingerprint, + details: { aktuell: stand.aktuell?.fingerprint ?? null }, + }); + return { ergebnis: { fingerprint: stand.nachfolger.fingerprint } }; +} + /** * Prueft auf jedem Instanz-Host, dass der alte Key nicht mehr im Block steht, und verwirft * erst dann seinen Private Key. Nicht erreichbare Hosts verhindern den Abschluss nicht – sie @@ -143,6 +167,7 @@ export const deploymentRotationHandler: Handler = async ({ vorgang }) => { const schritt = (vorgang.eingabe as { schritt?: unknown } | null)?.schritt; if (schritt === "erzeugen") return erzeugen(vorgang.angefordertVonId); if (schritt === "pruefen") return pruefen(vorgang.angefordertVonId); + if (schritt === "abbrechen") return abbrechen(vorgang.angefordertVonId); if (schritt === "abschliessen") return abschliessen(vorgang.angefordertVonId); throw new VorgangsFehler("Unbekannter Schritt der Rotation."); }; diff --git a/tests/e2e/deploymentkey.spec.ts b/tests/e2e/deploymentkey.spec.ts index a2bf4e5..decee6e 100644 --- a/tests/e2e/deploymentkey.spec.ts +++ b/tests/e2e/deploymentkey.spec.ts @@ -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}`; diff --git a/tests/e2e/lokale-konten.spec.ts b/tests/e2e/lokale-konten.spec.ts index 3c3dece..e2276eb 100644 --- a/tests/e2e/lokale-konten.spec.ts +++ b/tests/e2e/lokale-konten.spec.ts @@ -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$/); diff --git a/tests/integration/deploymentkey.test.ts b/tests/integration/deploymentkey.test.ts index 6fa12f8..599b5b6 100644 --- a/tests/integration/deploymentkey.test.ts +++ b/tests/integration/deploymentkey.test.ts @@ -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");