From b4804390c4fe5f7d281b718f525aa2c9364b262b Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 17:19:14 +0200 Subject: [PATCH 1/5] fix(protokoll): IDs ohne Zweierschritte vergeben (#032) Spalten-Default (BIGSERIAL) und Trigger protokoll_verketten zogen je eine Nummer aus der Sequenz, jede Zeile verbrauchte zwei IDs. Die Nummer aus dem Default darf der Trigger aber nicht einfach uebernehmen, denn sie wird vor der Advisory-Sperre gezogen: Eine parallele Transaktion mit groesserer ID koennte zuerst verketten, und die Kette liesse sich in ID-Reihenfolge nicht mehr pruefen. Deshalb zieht nur noch der Trigger unter der Sperre; der Default ist ein Platzhalter 0 (Prisma: @default(0)), damit create ohne ID funktioniert. Die Sequenz bleibt der Spalte zugeordnet. Bestehende Zeilen werden nicht umnummeriert, weil die ID in die Hash-Eingabe eingeht. Tests: drei Eintraege nacheinander ergeben n, n+1, n+2; eine mitgegebene ID wird ueberschrieben; 20 parallele Eintraege belegen genau 20 aufeinanderfolgende IDs bei intakter Kette. Co-Authored-By: Claude Opus 5.5 --- .../migration.sql | 15 ++++++++++++ prisma/schema.prisma | 4 +++- tests/datenbankregeln.test.ts | 24 ++++++++++++++++++- tests/protokoll-ansicht.test.ts | 2 +- 4 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20261012173000_protokoll_id_lueckenlos/migration.sql diff --git a/prisma/migrations/20261012173000_protokoll_id_lueckenlos/migration.sql b/prisma/migrations/20261012173000_protokoll_id_lueckenlos/migration.sql new file mode 100644 index 0000000..5fe034d --- /dev/null +++ b/prisma/migrations/20261012173000_protokoll_id_lueckenlos/migration.sql @@ -0,0 +1,15 @@ +-- #032: Protokoll-IDs ohne Zweierschritte. +-- +-- Bisher zog der Spalten-Default (BIGSERIAL) eine Nummer aus der Sequenz und der Trigger +-- `protokoll_verketten` gleich noch eine – jede Zeile verbrauchte zwei IDs. Die ID muss aber +-- im Trigger unter der Advisory-Sperre vergeben werden: Eine vorher gezogene Nummer koennte +-- kleiner sein als die einer parallelen Transaktion, die zuerst verkettet, und die Kette liesse +-- sich in ID-Reihenfolge nicht mehr pruefen. Deshalb verzichtet der Default auf die Sequenz. +-- +-- 0 ist nur ein Platzhalter, damit `prisma.protokoll.create` ohne ID auskommt; der Trigger +-- ueberschreibt jede ID (auch eine mitgegebene). Die Sequenz bleibt der Spalte zugeordnet +-- (OWNED BY), deshalb findet `pg_get_serial_sequence` sie im Trigger weiterhin. +-- +-- Bestehende Zeilen bleiben, wie sie sind: Die ID ist Teil der Hash-Eingabe, Umnummerieren +-- wuerde die Kette brechen. Die alten Luecken bleiben also stehen. +ALTER TABLE "Protokoll" ALTER COLUMN "id" SET DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 07af2e3..f7f103e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -483,7 +483,9 @@ model Vorgang { /// Revisionssicheres Protokoll (#005). Append-only per Trigger; `hash` verkettet jede Zeile /// mit ihrer Vorgaengerin und wird in der Datenbank berechnet. model Protokoll { - id BigInt @id @default(autoincrement()) + /// Vergibt der Trigger `protokoll_verketten` unter der Advisory-Sperre aus der Sequenz; der + /// Default 0 ist nur ein Platzhalter, damit `create` ohne ID auskommt (#032). + id BigInt @id @default(0) zeitpunkt DateTime @default(now()) akteurId String? /// Name zum Zeitpunkt der Aktion – bleibt lesbar, auch wenn das Konto spaeter umbenannt wird. diff --git a/tests/datenbankregeln.test.ts b/tests/datenbankregeln.test.ts index 6e3b69b..a78b077 100644 --- a/tests/datenbankregeln.test.ts +++ b/tests/datenbankregeln.test.ts @@ -236,8 +236,30 @@ describe("Protokoll", () => { expect(pruefung?.ergebnis).toBe(zweiter.id); }); + it("vergibt IDs ohne Zweierschritte (#032)", async () => { + // Frueher zogen Spalten-Default und Trigger je eine Nummer aus der Sequenz. + const [a, b, c] = [await eintragen("eins"), await eintragen("zwei"), await eintragen("drei")]; + expect(b.id).toBe(a.id + 1n); + expect(c.id).toBe(a.id + 2n); + const [pruefung] = await prisma.$queryRaw< + { ergebnis: bigint | null }[] + >`SELECT protokoll_kette_pruefen() AS ergebnis`; + expect(pruefung?.ergebnis).toBeNull(); + }); + + it("ueberschreibt eine mitgegebene ID unter der Sperre (#032)", async () => { + const erster = await eintragen("eins"); + const zweiter = await prisma.protokoll.create({ + data: { id: 999n, akteurName: "Test", aktion: "zwei", ergebnis: "ERFOLG" }, + }); + expect(zweiter.id).toBe(erster.id + 1n); + expect(zweiter.vorgaengerHash).toBe(erster.hash); + }); + it("bleibt bei gleichzeitigem Schreiben eine lueckenlose Kette", async () => { - await Promise.all(Array.from({ length: 20 }, (_, i) => eintragen(`parallel-${i}`))); + const zeilen = await Promise.all(Array.from({ length: 20 }, (_, i) => eintragen(`parallel-${i}`))); + const ids = zeilen.map((z) => z.id).sort((x, y) => (x < y ? -1 : 1)); + expect(ids.at(-1)! - ids[0]!).toBe(19n); const [pruefung] = await prisma.$queryRaw< { ergebnis: bigint | null }[] >`SELECT protokoll_kette_pruefen() AS ergebnis`; diff --git a/tests/protokoll-ansicht.test.ts b/tests/protokoll-ansicht.test.ts index 9c2c649..653adae 100644 --- a/tests/protokoll-ansicht.test.ts +++ b/tests/protokoll-ansicht.test.ts @@ -75,7 +75,7 @@ beforeEach(async () => { describe("protokollSeite", () => { it("blaettert ueber die ID, neueste zuerst, ohne Luecken und Doppelte", async () => { - // IDs sind nicht lueckenlos (der Trigger zieht die Sequenz selbst) – nur die Ordnung zaehlt. + // IDs koennen Luecken haben (Rollbacks, alte Zeilen vor #032) – nur die Ordnung zaehlt. const ids: string[] = []; for (let i = 0; i < SEITENGROESSE + 7; i += 1) ids.unshift(String((await eintragen({ aktion: "host.angelegt" })).id)); From be9837ba506b4ae9bdf02d32dd7d386edb7c5b30 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 17:21:57 +0200 Subject: [PATCH 2/5] feat(zuweisungen): Zum Dry-Run springt zur Dry-Run-Karte (#032) Der Link im Zell-Dialog der Zuweisungsmatrix fuehrte nur auf den Kopf der Schluesselseite; die Dry-Run-Karte liegt weit unten, und wer aus der Matrix kommt, will genau dorthin. Die Karte traegt jetzt id="dry-run" (Karte nimmt dafuer eine optionale id an), der Link zeigt auf /schluessel/#dry-run. scroll-mt haelt die Karte unter der festen Kopfleiste der schmalen Ansicht. E2E: der Matrix-Test klickt den Link und prueft Adresse und Sichtbarkeit der Karte. Co-Authored-By: Claude Opus 5.5 --- src/app/(app)/schluessel/[id]/page.tsx | 4 ++++ src/components/ui/Karte.tsx | 4 ++++ src/components/zuweisungen/ZellenDialog.tsx | 2 +- tests/e2e/zuweisungen.spec.ts | 8 ++++++++ 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/app/(app)/schluessel/[id]/page.tsx b/src/app/(app)/schluessel/[id]/page.tsx index 836e6f7..46cb372 100644 --- a/src/app/(app)/schluessel/[id]/page.tsx +++ b/src/app/(app)/schluessel/[id]/page.tsx @@ -457,6 +457,10 @@ export default async function SchluesselSeite({ !imWiderruf && (s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && ( diff --git a/src/components/ui/Karte.tsx b/src/components/ui/Karte.tsx index c1b6bfd..f988ec3 100644 --- a/src/components/ui/Karte.tsx +++ b/src/components/ui/Karte.tsx @@ -13,6 +13,7 @@ export function Karte({ className, innen = "normal", as: Element = "section", + id, }: { titel?: ReactNode; untertitel?: ReactNode; @@ -22,9 +23,12 @@ export function Karte({ /** „ohne“ für Inhalte, die bis an den Rand reichen (z. B. Tabellen mit eigenem Abstand). */ innen?: "normal" | "ohne"; as?: "section" | "div" | "article"; + /** Sprungziel für Links von anderen Seiten (z. B. `#dry-run`, #032). */ + id?: string; }) { return ( diff --git a/src/lib/schluessel.ts b/src/lib/schluessel.ts index dfcb0ae..2c6197d 100644 --- a/src/lib/schluessel.ts +++ b/src/lib/schluessel.ts @@ -293,3 +293,14 @@ export function publicKeyZeileMitKommentar(zeile: string, kommentar: string): st const [typ, base64] = zeile.trim().split(/\s+/); return kommentar ? `${typ} ${base64} ${kommentar}` : `${typ} ${base64}`; } + +/** + * IDs aus einem Suchparameter (`?id=a&id=b` oder `?id=a,b`), etwa fuer den Massenwiderruf (#024). + * Erlaubt Bindestriche: Vom Worker erzeugte Schluessel tragen eine UUID, nur Prisma-Defaults sind + * cuids – ohne Bindestrich fielen alle erzeugten Schluessel still aus der Auswahl (#032). + */ +export function idsAusSuchparameter(wert: string | string[] | undefined, hoechstens = 200): string[] { + const roh = Array.isArray(wert) ? wert : wert ? [wert] : []; + const gueltig = roh.flatMap((w) => w.split(",")).filter((w) => /^[a-z0-9-]{1,64}$/i.test(w)); + return [...new Set(gueltig)].slice(0, hoechstens); +} diff --git a/tests/e2e/hilfen.ts b/tests/e2e/hilfen.ts index edcb594..5ff39e6 100644 --- a/tests/e2e/hilfen.ts +++ b/tests/e2e/hilfen.ts @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process"; import { expect, type Page } from "@playwright/test"; +import pg from "pg"; import "dotenv/config"; /** @@ -35,6 +36,30 @@ export async function anmelden(page: Page): Promise { await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible(); } +/** Direkter Zugriff auf die Datenbank des gebauten Stands – fuer Vorbedingungen und Pruefungen. */ +export async function datenbank(arbeit: (client: pg.Client) => Promise): Promise { + const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] }); + await client.connect(); + try { + return await arbeit(client); + } finally { + await client.end(); + } +} + +/** + * Erneute Anmeldung beim Mock-OIDC (#014). Der Mock uebernimmt nur die Claims aus dem + * Formular; `auth_time` muss deshalb mitgeschickt werden, sonst lehnt sshkv die Bestaetigung ab. + */ +export async function oidcBestaetigen(page: Page): Promise { + await expect(page).toHaveURL(/\/bestaetigen\?ziel=/); + await page.getByRole("link", { name: /erneut anmelden/ }).click(); + await page.locator('button[data-rolle="admin"]').click(); + const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record; + await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) })); + await page.getByRole("button", { name: "Anmelden", exact: true }).click(); +} + export async function hostEinrichten(page: Page, name: string, port: string): Promise { await page.goto("/hosts/neu"); await page.getByLabel("Name").fill(name); diff --git a/tests/e2e/schluessel.spec.ts b/tests/e2e/schluessel.spec.ts index 330747d..cb00e85 100644 --- a/tests/e2e/schluessel.spec.ts +++ b/tests/e2e/schluessel.spec.ts @@ -1,7 +1,7 @@ import { expect, test, type Page } from "@playwright/test"; -import pg from "pg"; import "dotenv/config"; import { schluesselpaarErzeugen } from "../../src/lib/ssh/openssh"; +import { datenbank, oidcBestaetigen } from "./hilfen"; /** * Schluessel im gebauten Stand mit laufendem Worker (#012–#014). Testdaten heissen zz-e2e-… @@ -17,16 +17,6 @@ async function oidcAnmelden(page: Page, rolle: "admin" | "operator" | "reader") await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible(); } -async function datenbank(arbeit: (client: pg.Client) => Promise): Promise { - const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] }); - await client.connect(); - try { - return await arbeit(client); - } finally { - await client.end(); - } -} - function eindeutig(): string { return `zz-e2e-${Date.now().toString(36)}`; } @@ -104,19 +94,6 @@ test("Leser hinterlegt einen eigenen Public Key und entfernt ihn wieder", async await expect(page.locator("li", { hasText: name })).toHaveCount(0); }); -/** - * Erneute Anmeldung beim Mock-OIDC (#014). Der Mock uebernimmt nur die Claims aus dem - * Formular; `auth_time` muss deshalb mitgeschickt werden, sonst lehnt sshkv die Bestaetigung ab. - */ -async function oidcBestaetigen(page: Page) { - await expect(page).toHaveURL(/\/bestaetigen\?ziel=/); - await page.getByRole("link", { name: /erneut anmelden/ }).click(); - await page.locator('button[data-rolle="admin"]').click(); - const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record; - await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) })); - await page.getByRole("button", { name: "Anmelden", exact: true }).click(); -} - test("Admin zeigt den Private Key nach Bestätigung an, lädt ihn herunter und löscht ihn", async ({ page, }) => { diff --git a/tests/e2e/widerruf.spec.ts b/tests/e2e/widerruf.spec.ts index dda76c4..b402e70 100644 --- a/tests/e2e/widerruf.spec.ts +++ b/tests/e2e/widerruf.spec.ts @@ -1,10 +1,12 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; import { anmelden, archivieren, aufZiel, + datenbank, hostEinrichten, kernAufbauen, + oidcBestaetigen, PORT, zieleZuruecksetzen, } from "./hilfen"; @@ -59,3 +61,90 @@ test("Widerruf mit einem Klick", async ({ page }) => { await archivieren(page, hostA); await archivieren(page, hostB); }); + +/** Schluessel erzeugen, Public Key auf `host` zuweisen und ausrollen. Liefert den Key-Teil. */ +async function publicKeyAusrollen(page: Page, zweck: string, host: string): Promise { + await page.goto("/schluessel/neu"); + await page.getByLabel("Zweck").fill(zweck); + await page.getByRole("button", { name: "Schlüssel erzeugen" }).click(); + await expect(page.getByRole("heading", { name: zweck })).toBeVisible({ timeout: 30_000 }); + const zeile = (await page.getByLabel("Public Key", { exact: true }).textContent()) ?? ""; + const keyTeil = zeile.trim().split(/\s+/)[1] ?? ""; + expect(keyTeil).toMatch(/^AAAA/); + + await page.getByRole("button", { name: "Ausrollen" }).nth(1).click(); + const oeffentlich = page.getByRole("dialog", { name: "Public Key zuweisen" }); + await oeffentlich.getByLabel("Ziel", { exact: true }).selectOption({ label: host }); + await oeffentlich.getByRole("button", { name: "Zuweisen" }).click(); + await expect(oeffentlich).toBeHidden(); + 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.")).toBeVisible({ timeout: 60_000 }); + return keyTeil; +} + +test("Massenwiderruf mit frischer Bestätigung (#032)", async ({ page }) => { + const kennung = Date.now().toString(36); + const hostB = `zz-e2e-mb-${kennung}`; + const praefix = `zz-e2e Massen ${kennung}`; + const zwecke = [`${praefix} eins`, `${praefix} zwei`]; + + await anmelden(page); + // Eine Bestaetigung aus der Anmeldung eben oder einem frueheren Lauf soll nicht mitzaehlen. + await datenbank((client) => + client.query(`UPDATE "Benutzer" SET "bestaetigtAm" = NULL WHERE "email" = 'admin@sshkv.test'`), + ); + await hostEinrichten(page, hostB, PORT.b); + const keys = []; + for (const zweck of zwecke) keys.push(await publicKeyAusrollen(page, zweck, hostB)); + const vorher = aufZiel("ziel-b", "cat /root/.ssh/authorized_keys"); + for (const key of keys) expect(vorher).toContain(key); + + // Auswahl in der Liste – ein GET-Formular auf die Massenwiderruf-Seite. + await page.goto(`/schluessel?q=${encodeURIComponent(praefix)}`); + for (const zweck of zwecke) + await page.getByRole("checkbox", { name: `${zweck} für den Massenwiderruf auswählen` }).check(); + await page.getByRole("button", { name: "Ausgewählte widerrufen" }).click(); + await expect(page).toHaveURL(/\/schluessel\/widerruf\?id=/); + await expect(page.getByRole("heading", { name: "2 Schlüssel widerrufen?" })).toBeVisible(); + for (const zweck of zwecke) + await expect( + page.getByRole("region", { name: `Widerruf von ${zweck}` }).getByText(`root@${hostB}`), + ).toBeVisible(); + + // Ohne frische Bestaetigung merkt sshkv nichts vor und schickt zur erneuten Anmeldung. + await page.getByRole("button", { name: "2 Schlüssel widerrufen" }).click(); + await page.getByRole("link", { name: /Anmeldung bestätigen/ }).click(); + await oidcBestaetigen(page); + await expect(page).toHaveURL(/\/schluessel\/widerruf\?id=/); + const eingeleitet = await datenbank((client) => + client.query( + `SELECT count(*)::int AS n FROM "Schluessel" WHERE "name" = ANY($1) AND "widerrufEingeleitetAm" IS NOT NULL`, + [zwecke], + ), + ); + expect(eingeleitet.rows[0]).toEqual({ n: 0 }); + + await page.getByRole("button", { name: "2 Schlüssel widerrufen" }).click(); + await expect(page.getByRole("heading", { name: "Widerruf läuft" })).toBeVisible(); + + // Ein gemeinsamer Dry-Run fuer beide Schluessel, dann anwenden; der Abschluss laeuft danach. + await page.getByRole("button", { name: "Dry-Run starten" }).click(); + await expect(page.getByText(`root@${hostB}:/root/.ssh/authorized_keys`)).toBeVisible({ timeout: 30_000 }); + await page.getByRole("button", { name: /Änderung anwenden/ }).click(); + await expect(page.getByRole("heading", { name: "Widerruf läuft" })).toBeHidden({ timeout: 60_000 }); + const gewaehlt = page.locator("section", { + has: page.getByRole("heading", { name: "Gewählte Schlüssel" }), + }); + await expect(gewaehlt.getByText("widerrufen", { exact: true })).toHaveCount(2); + await expect(gewaehlt.getByText("Widerruf unvollständig")).toHaveCount(0); + + const nachher = aufZiel("ziel-b", "cat /root/.ssh/authorized_keys"); + for (const key of keys) expect(nachher).not.toContain(key); + const status = await datenbank((client) => + client.query(`SELECT "status" FROM "Schluessel" WHERE "name" = ANY($1)`, [zwecke]), + ); + expect(status.rows).toEqual([{ status: "WIDERRUFEN" }, { status: "WIDERRUFEN" }]); + + await archivieren(page, hostB); +}); diff --git a/tests/schluessel.test.ts b/tests/schluessel.test.ts index 364b750..994d118 100644 --- a/tests/schluessel.test.ts +++ b/tests/schluessel.test.ts @@ -9,6 +9,7 @@ import { ablaufZustand, fingerprintKurz, freierSlug, + idsAusSuchparameter, slugAus, typAnzeige, erzeugenSchema, @@ -66,6 +67,15 @@ function kern(privateKey: string): string { } describe("Regeln (rein)", () => { + it("liest IDs aus dem Suchparameter, auch UUIDs vom Worker (#032)", () => { + const uuid = "5f703ff1-3877-4af4-af08-84338237fcd8"; + expect(idsAusSuchparameter([uuid, "cm1abc"])).toEqual([uuid, "cm1abc"]); + expect(idsAusSuchparameter(`a,b,a`)).toEqual(["a", "b"]); + expect(idsAusSuchparameter(["x y", "../etc", ""])).toEqual([]); + expect(idsAusSuchparameter(undefined)).toEqual([]); + expect(idsAusSuchparameter(["a", "b", "c"], 2)).toEqual(["a", "b"]); + }); + it.each([ ["Backup srv-backup01 → srv-pve01", "backup-srv-backup01-srv-pve01"], ["Größter Schlüssel für Ärzte", "groesster-schluessel-fuer-aerzte"], From f18fd4ec06574ff9127cc589a5007ba39443e562 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 17:45:11 +0200 Subject: [PATCH 4/5] 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 --- src/app/(app)/schluessel/[id]/page.tsx | 91 ++++++++++---- .../(app)/schluessel/[id]/rotieren/page.tsx | 8 +- src/components/rotation/Rotation.tsx | 45 +++++++ src/components/rotation/RotationAbbrechen.tsx | 80 ++++++++++++ src/lib/protokoll-aktionen.ts | 1 + src/server/actions/rotation.ts | 26 +++- src/server/queries/schluessel.ts | 5 +- src/server/services/rotation.ts | 69 +++++++++- src/server/services/widerruf.ts | 12 +- tests/e2e/rotation.spec.ts | 46 +++++++ tests/integration/rotation.test.ts | 109 +++++++++++----- tests/rotation.test.ts | 118 +++++++++++++++++- 12 files changed, 549 insertions(+), 61 deletions(-) create mode 100644 src/components/rotation/RotationAbbrechen.tsx diff --git a/src/app/(app)/schluessel/[id]/page.tsx b/src/app/(app)/schluessel/[id]/page.tsx index 46cb372..d1cb64e 100644 --- a/src/app/(app)/schluessel/[id]/page.tsx +++ b/src/app/(app)/schluessel/[id]/page.tsx @@ -23,7 +23,8 @@ import { widerrufUebersicht } from "@/server/queries/widerruf"; import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf"; import { WiderrufKnopf } from "@/components/widerruf/WiderrufKnopf"; import { ablaufZustand } from "@/lib/schluessel"; -import { rotationGesperrt } from "@/server/services/rotation"; +import { RotationAbbrechen } from "@/components/rotation/RotationAbbrechen"; +import { rotationAbgebrochen, rotationGesperrt } from "@/server/services/rotation"; import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion"; import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen"; import { PublicAusrollenAktion } from "./PublicAusrollenAktion"; @@ -216,9 +217,17 @@ export default async function SchluesselSeite({ const jetzt = new Date(); const abgelaufen = s.status === "ABGELAUFEN" || ablaufZustand(s.ablaufAm, jetzt).art === "abgelaufen"; const verwaltenRecht = darf(benutzer.rolle, "schluesselVerwalten"); + // Auf dem Nachfolger: die laufende Rotation gehoert zum Vorgaenger. + const rotationDesVorgaengers = + s.vorgaenger && (s.vorgaenger.status === "AKTIV" || s.vorgaenger.status === "ABGELAUFEN") + ? s.vorgaenger + : null; const widerrufbar = !imWiderruf && !(s.nachfolger?.status === "AKTIV") && + // Ein Nachfolger mitten in der Rotation wird ueber „Rotation abbrechen“ widerrufen (#032) – + // so steht der Abbruch im Protokoll und die Rotationsseite kennt den Stand. + !rotationDesVorgaengers && s.kategorie !== "DEPLOYMENT" && (s.status === "AKTIV" || s.status === "ABGELAUFEN") && rechte.public && @@ -235,13 +244,19 @@ export default async function SchluesselSeite({ privatZuweisungen: aktivePrivat, }); const rotationLaeuft = s.nachfolger?.status === "AKTIV" && s.status !== "ROTIERT"; + const abgebrocheneRotation = s.status !== "ROTIERT" && rotationAbgebrochen(s.nachfolger); const darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat); - const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf)); - // Auf dem Nachfolger: die laufende Rotation gehoert zum Vorgaenger. - const rotationDesVorgaengers = - s.vorgaenger && (s.vorgaenger.status === "AKTIV" || s.vorgaenger.status === "ABGELAUFEN") - ? s.vorgaenger - : null; + // Nach einem abgeschlossenen Abbruch (#032) bleibt der Nachfolger in der Kette; ein zweiter + // passt nicht hinein (vorgaengerId ist eindeutig) – deshalb kein „Rotieren“ mehr. + const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf && !s.nachfolger)); + // Abbrechen auf dem Nachfolger: gleiche Rechte wie die Rotation, Private Keys nur mit Recht. + const darfAbbrechen = + Boolean(rotationDesVorgaengers && !rotationDesVorgaengers.widerrufEingeleitetAm) && + !imWiderruf && + s.status === "AKTIV" && + verwaltenRecht && + rechte.public && + (s.privateVerwendungen.length === 0 || rechte.privat); const vorauswahl = ( Array.isArray(zielkontenParameter) ? zielkontenParameter.join(",") : (zielkontenParameter ?? "") ) @@ -308,20 +323,41 @@ export default async function SchluesselSeite({ } /> - {rotationDesVorgaengers && ( - -

- Dieser Schlüssel löst „{rotationDesVorgaengers.name}“ ab. Die Rotation ist erst fertig, wenn der - neue Schlüssel ausgerollt und getestet und der alte entfernt ist.{" "} - - Rotation fortsetzen - -

-
- )} + {rotationDesVorgaengers && + (imWiderruf || s.status === "WIDERRUFEN" ? ( + +

+ Dieser Schlüssel sollte „{rotationDesVorgaengers.name}“ ablösen; die Rotation wurde abgebrochen. + {imWiderruf ? " Er wird gerade entfernt; " : " Er ist von allen Hosts entfernt; "} + + {rotationDesVorgaengers.name} + {" "} + bleibt aktiv. +

+
+ ) : ( + +

+ Dieser Schlüssel löst „{rotationDesVorgaengers.name}“ ab. Die Rotation ist erst fertig, wenn der + neue Schlüssel ausgerollt und getestet und der alte entfernt ist.{" "} + + Rotation fortsetzen + +

+ {darfAbbrechen && ( +
+ +
+ )} +
+ ))} {imWiderruf && widerruf && ( )} @@ -367,7 +403,8 @@ export default async function SchluesselSeite({

Von allen Hosts entfernt; der Schlüssel lässt sich nicht mehr zuweisen. - {s.nachfolger && ( + {/* Ein widerrufener Nachfolger (abgebrochene Rotation, #032) ersetzt nichts. */} + {s.nachfolger && s.status === "ROTIERT" && ( <> {" "} Ersetzt durch{" "} @@ -502,6 +539,14 @@ export default async function SchluesselSeite({ ))} )} + {abgebrocheneRotation && s.nachfolger && ( +

+ Die Rotation zu „{s.nachfolger.name}“ wurde abgebrochen + {s.nachfolger.status === "WIDERRUFEN" ? "" : " – der neue Schlüssel wird gerade entfernt"}; + dieser Schlüssel bleibt aktiv. Ein zweiter Nachfolger ist nicht vorgesehen: für eine neue + Rotation einen neuen Schlüssel anlegen, genauso zuweisen und diesen danach widerrufen. +

+ )} {rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && (

1 ? "mt-3" : ""}`}> {rotationsSperre.grund} diff --git a/src/app/(app)/schluessel/[id]/rotieren/page.tsx b/src/app/(app)/schluessel/[id]/rotieren/page.tsx index c8d7df7..961f1ac 100644 --- a/src/app/(app)/schluessel/[id]/rotieren/page.tsx +++ b/src/app/(app)/schluessel/[id]/rotieren/page.tsx @@ -59,9 +59,14 @@ export default async function RotierenSeite({ params }: { params: Promise<{ id: } const widerruf = - stand.neu && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT" + stand.neu && !stand.abgebrochen && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT" ? await offeneEntfernungen([alt.id]) : null; + // Abbruch (#032): solange der neue Schluessel nicht widerrufen ist, laeuft sein Widerruf hier weiter. + const abbruch = + stand.neu && stand.abgebrochen + ? { offen: stand.neu.status === "WIDERRUFEN" ? null : await offeneEntfernungen([stand.neu.id]) } + : null; return (

@@ -74,6 +79,7 @@ export default async function RotierenSeite({ params }: { params: Promise<{ id: neuOffen: stand.neu ? stand.neuOffen : 0, tests: stand.neu ? stand.tests : [], widerruf, + abbruch, darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"), }} /> diff --git a/src/components/rotation/Rotation.tsx b/src/components/rotation/Rotation.tsx index 41c82b4..5ec4600 100644 --- a/src/components/rotation/Rotation.tsx +++ b/src/components/rotation/Rotation.tsx @@ -9,6 +9,7 @@ import { Button, Formularfehler, Karte } from "@/components/ui"; import { Schrittleiste } from "@/components/ui/Schrittleiste"; import { warten } from "@/components/vorgang/warten"; import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf"; +import { RotationAbbrechen } from "./RotationAbbrechen"; import { rotationAltWiderrufenAktion, rotationStartenAktion, @@ -47,6 +48,11 @@ export type RotationsDaten = { /** Offene Entfernungen des alten Schluessels, sobald sein Widerruf laeuft. */ widerruf: OffeneEntfernung[] | null; darfPrivat: boolean; + /** + * Abgebrochene Rotation (#032): `offen` sind die offenen Entfernungen des neuen Schluessels, + * solange sein Widerruf laeuft; null, wenn er abgeschlossen ist. + */ + abbruch: { offen: OffeneEntfernung[] | null } | null; }; export function Rotation({ daten }: { daten: RotationsDaten }) { @@ -109,6 +115,40 @@ export function Rotation({ daten }: { daten: RotationsDaten }) { ); + if (daten.abbruch && neu) { + return daten.abbruch.offen ? ( + + + + ) : ( + +

+ Der neue Schlüssel{" "} + + {neu.name} + {" "} + ist widerrufen und von allen Hosts entfernt.{" "} + + {alt.name} + {" "} + bleibt aktiv. +

+
+ ); + } + return (
@@ -225,6 +265,11 @@ export function Rotation({ daten }: { daten: RotationsDaten }) { )} + {/* Abbrechen geht, solange der alte Schluessel noch nicht entfernt wird (#032). */} + {neu && schritt >= 1 && schritt <= 4 && !daten.widerruf && ( + + )} + {schritt === 5 && neu && (

diff --git a/src/components/rotation/RotationAbbrechen.tsx b/src/components/rotation/RotationAbbrechen.tsx new file mode 100644 index 0000000..ecf6185 --- /dev/null +++ b/src/components/rotation/RotationAbbrechen.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { Undo2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { Button, Formularfehler } from "@/components/ui"; +import { rotationAbbrechenAktion } from "@/server/actions/rotation"; + +/** + * „Rotation abbrechen“ (#032) – auf der Rotationsseite und auf der Seite des Nachfolgers. Der + * Klick merkt den neuen Schluessel nur zum Widerruf vor; entfernt wird er erst nach Dry-Run und + * „Anwenden“. Deshalb erst eine Rueckfrage, die genau das sagt. + */ +export function RotationAbbrechen({ + altId, + altName, + neuName, +}: { + altId: string; + altName: string; + neuName: string; +}) { + const router = useRouter(); + const [offen, setOffen] = useState(false); + const [fehler, setFehler] = useState(); + const [laeuft, starten] = useTransition(); + + if (!offen) { + return ( +

+ +
+ ); + } + + return ( +
+

Rotation abbrechen?

+
    +
  • + Der neue Schlüssel „{neuName}“ wird widerrufen – wo er schon ausgerollt ist, zeigt der Dry-Run, was + entfernt wird. +
  • +
  • „{altName}“ bleibt aktiv und unverändert; ein config-Alias zeigt danach wieder auf ihn.
  • +
  • Die Verknüpfung bleibt in der Rotationskette sichtbar, der Abbruch steht im Protokoll.
  • +
+ +
+ + +
+
+ ); +} diff --git a/src/lib/protokoll-aktionen.ts b/src/lib/protokoll-aktionen.ts index 3cc6a6e..274a2ba 100644 --- a/src/lib/protokoll-aktionen.ts +++ b/src/lib/protokoll-aktionen.ts @@ -83,6 +83,7 @@ export const AKTIONEN: Readonly> = { "schluessel.widerrufen": a("Schlüssel widerrufen", "widerruf"), "schluessel.rotiert": a("Schlüssel rotiert", "widerruf"), "schluessel.rotation.uebernommen": a("Rotation: Zuweisungen übernommen", "widerruf"), + "schluessel.rotation.abgebrochen": a("Rotation abgebrochen", "widerruf"), // Zielkonten (#018) "zielkonto.angelegt": a("Zielkonto angelegt", "zielkonto"), diff --git a/src/server/actions/rotation.ts b/src/server/actions/rotation.ts index 72ba2f8..ac81103 100644 --- a/src/server/actions/rotation.ts +++ b/src/server/actions/rotation.ts @@ -6,7 +6,12 @@ import { akteurVon } from "@/server/actions/formular"; import { darf } from "@/server/auth/rechte"; import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; import { FachFehler } from "@/server/services/fachfehler"; -import { rotationAltWiderrufen, rotationStarten, rotationUebernehmen } from "@/server/services/rotation"; +import { + rotationAbbrechen, + rotationAltWiderrufen, + rotationStarten, + rotationUebernehmen, +} from "@/server/services/rotation"; import { VerwendungsFehler } from "@/server/services/verwendungen"; /** @@ -69,3 +74,22 @@ export async function rotationAltWiderrufenAktion(schluesselId: string): Promise return alsAntwort(fehler); } } + +/** + * Laufende Rotation abbrechen (#032): merkt den neuen Schluessel zum Widerruf vor. Gleiche Rechte + * wie die Rotation selbst; Private Keys entfernt nur, wer sie ausrollen darf (Service). + */ +export async function rotationAbbrechenAktion(schluesselId: string): Promise { + try { + const { benutzer } = await berechtigt(); + const altId = id.parse(schluesselId); + await rotationAbbrechen(altId, akteurVon(benutzer), { + privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"), + }); + revalidatePath(`/schluessel/${altId}`); + revalidatePath(`/schluessel/${altId}/rotieren`); + return {}; + } catch (fehler) { + return alsAntwort(fehler); + } +} diff --git a/src/server/queries/schluessel.ts b/src/server/queries/schluessel.ts index d4cdb97..2817f39 100644 --- a/src/server/queries/schluessel.ts +++ b/src/server/queries/schluessel.ts @@ -58,8 +58,9 @@ export const SCHLUESSEL_DETAIL_SELECT = { person: { select: { id: true, name: true } }, vorlage: { select: { id: true, name: true } }, erstelltVon: { select: { name: true } }, - vorgaenger: { select: { id: true, name: true, status: true } }, - nachfolger: { select: { id: true, name: true, status: true } }, + vorgaenger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } }, + // Ein Nachfolger im Widerruf ist eine abgebrochene Rotation (#032). + nachfolger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } }, publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } }, orderBy: { angelegtAm: "asc" }, diff --git a/src/server/services/rotation.ts b/src/server/services/rotation.ts index a490ea7..a7e2532 100644 --- a/src/server/services/rotation.ts +++ b/src/server/services/rotation.ts @@ -45,7 +45,7 @@ const ALT_SELECT = { vorlageId: true, masterKeyVersion: true, widerrufEingeleitetAm: true, - nachfolger: { select: { id: true, name: true, status: true } }, + nachfolger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } }, _count: { select: { privateVerwendungen: { where: { zustand: { in: [...AKTIVE] } } } } }, } satisfies Prisma.SchluesselSelect; @@ -92,6 +92,13 @@ function gesperrtPruefen(alt: Alt) { export async function rotationStarten(altId: string, akteur: Akteur): Promise { const alt = await altLaden(altId); gesperrtPruefen(alt); + if (alt.nachfolger && rotationAbgebrochen(alt.nachfolger)) { + // vorgaengerId ist eindeutig und bleibt fuer die Historie stehen (#032): ein zweiter + // Nachfolger passt nicht in die Kette. + throw new RotationsFehler( + "Die Rotation dieses Schlüssels wurde abgebrochen; ein zweiter Nachfolger ist nicht vorgesehen. Bitte einen neuen Schlüssel anlegen, genauso zuweisen und diesen danach widerrufen.", + ); + } if (alt.nachfolger) throw new RotationsFehler("Für diesen Schlüssel gibt es schon einen Nachfolger."); const name = rotationsName(alt.name, rotationsDatum().anzeige, NAME_MAX); const slug = await eindeutigerSlug(null, name); @@ -111,8 +118,19 @@ export async function rotationStarten(altId: string, akteur: Akteur): Promise { + const alt = await altLaden(altId); + const neu = alt.nachfolger; + if (!neu) throw new RotationsFehler("Für diesen Schlüssel läuft keine Rotation."); + if (alt.status === "ROTIERT") throw new RotationsFehler("Die Rotation ist bereits abgeschlossen."); + // Idempotent: ein zweiter Klick (oder ein Neuladen) setzt den laufenden Abbruch nur fort. + if (rotationAbgebrochen(neu)) return; + if (alt.widerrufEingeleitetAm) { + throw new RotationsFehler( + "Der alte Schlüssel wird bereits entfernt – die Rotation lässt sich nicht mehr abbrechen.", + ); + } + await widerrufEinleiten([neu.id], akteur, { + privatErlaubt: optionen.privatErlaubt, + zusaetzlich: async (tx) => { + await protokollieren( + { + akteur, + aktion: "schluessel.rotation.abgebrochen", + ergebnis: "ERFOLG", + fingerprint: alt.fingerprint, + details: { name: alt.name, nachfolgerId: neu.id, nachfolger: neu.name }, + }, + tx, + ); + }, + }); +} diff --git a/src/server/services/widerruf.ts b/src/server/services/widerruf.ts index 53bb957..11160d5 100644 --- a/src/server/services/widerruf.ts +++ b/src/server/services/widerruf.ts @@ -37,7 +37,7 @@ const SCHLUESSEL_KOPF = { status: true, fingerprint: true, widerrufEingeleitetAm: true, - nachfolger: { select: { id: true, status: true } }, + nachfolger: { select: { id: true, status: true, widerrufEingeleitetAm: true } }, } satisfies Prisma.SchluesselSelect; type SchluesselKopf = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>; @@ -167,6 +167,11 @@ export async function zielkontoKandidaten(schluesselIds: readonly string[]): Pro export type EinleitenOptionen = { /** Darf der Akteur Private Keys entfernen (privateKeyAusrollen)? Prueft die Aktion per darf(). */ readonly privatErlaubt: boolean; + /** + * Laeuft in derselben Transaktion wie das Vormerken – etwa der Protokolleintrag eines + * Rotationsabbruchs (#032), damit es keinen Abbruch ohne Eintrag gibt. + */ + readonly zusaetzlich?: (tx: Prisma.TransactionClient) => Promise; }; /** @@ -264,6 +269,7 @@ export async function widerrufEinleiten( tx, ); } + await optionen.zusaetzlich?.(tx); }); } @@ -406,7 +412,9 @@ export async function widerrufAbschliessen( continue; } // Ein aktiver Nachfolger heisst: der Widerruf ist der letzte Schritt einer Rotation (#023). - const status = s.nachfolger?.status === "AKTIV" ? "ROTIERT" : "WIDERRUFEN"; + // Ein Nachfolger im Widerruf ist eine abgebrochene Rotation (#032) – ersetzt ist dann nichts. + const status = + s.nachfolger?.status === "AKTIV" && !s.nachfolger.widerrufEingeleitetAm ? "ROTIERT" : "WIDERRUFEN"; await prisma.$transaction(async (tx) => { const geaendert = await tx.schluessel.updateMany({ where: { id: s.id, status: { in: ["AKTIV", "ABGELAUFEN"] } }, diff --git a/tests/e2e/rotation.spec.ts b/tests/e2e/rotation.spec.ts index 43a35dd..108f443 100644 --- a/tests/e2e/rotation.spec.ts +++ b/tests/e2e/rotation.spec.ts @@ -69,3 +69,49 @@ test("Rotation mit Schrittanzeige", async ({ page }) => { await archivieren(page, hostA); await archivieren(page, hostB); }); + +test("Rotation abbrechen, nachdem der neue Schlüssel ausgerollt ist (#032)", async ({ page }) => { + const kennung = Date.now().toString(36); + const hostA = `zz-e2e-a-${kennung}`; + const hostB = `zz-e2e-b-${kennung}`; + const zweck = `zz-e2e Abbruch ${kennung}`; + + zieleZuruecksetzen(); + await anmelden(page); + await hostEinrichten(page, hostA, PORT.a); + await hostEinrichten(page, hostB, PORT.b); + const schluesselSeite = await kernAufbauen(page, zweck, hostA, hostB); + await page.reload(); + + await page.getByRole("link", { name: "Rotieren" }).click(); + await page.getByRole("button", { name: "Neuen Schlüssel erzeugen und Zuweisungen übernehmen" }).click(); + await page.getByRole("button", { name: "Dry-Run starten" }).click({ timeout: 60_000 }); + await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 }); + await expect(page.getByRole("button", { name: "Verbindungen testen" })).toBeVisible({ timeout: 60_000 }); + expect(aufZiel("ziel-a", "cat /root/.ssh/config")).toMatch(/IdentityFile ~\/\.ssh\/.*-r\d{8}$/m); + + // Abbrechen: Rueckfrage, dann Widerruf des neuen Schluessels mit Dry-Run. + await page.getByRole("button", { name: "Rotation abbrechen" }).click(); + const rueckfrage = page.getByRole("group", { name: "Rotation abbrechen?" }); + await expect(rueckfrage.getByText(`„${zweck}“ bleibt aktiv und unverändert`)).toBeVisible(); + await rueckfrage.getByRole("button", { name: "Neuen Schlüssel widerrufen" }).click(); + await expect(page.getByRole("heading", { name: "Rotation wird abgebrochen" })).toBeVisible(); + await page.getByRole("button", { name: "Dry-Run starten" }).click(); + await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 }); + await expect(page.getByRole("heading", { name: "Rotation abgebrochen" })).toBeVisible({ timeout: 60_000 }); + + // Auf den Hosts: wie vorher. Der Alias geht wieder mit dem alten Schluessel. + expect(aufZiel("ziel-a", "ls /root/.ssh")).not.toMatch(/-r\d{8}/); + 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. + 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 archivieren(page, hostA); + await archivieren(page, hostB); +}); diff --git a/tests/integration/rotation.test.ts b/tests/integration/rotation.test.ts index 2b368f9..27e141b 100644 --- a/tests/integration/rotation.test.ts +++ b/tests/integration/rotation.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { prisma } from "@/lib/db"; import { rotationsDatum } from "@/lib/rotation"; import { + rotationAbbrechen, rotationAltWiderrufen, rotationStarten, rotationUebernehmen, @@ -55,38 +56,44 @@ async function trockenlaufUndAusrollen(schluesselId: string) { expect(ausrollen.fehler).toBeNull(); } +/** Dienst-Key wie nach dem Assistenten: Private Key auf A (Alias zu-b), Public Key auf B, ausgerollt. */ +async function alterSchluesselAusgerollt(aId: string, bId: string) { + const paar = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:backup"); + const alt = await prisma.schluessel.create({ + data: { + id: paar.id, + name: "Backup", + slug: "backup", + typ: "ED25519", + kategorie: "DIENST", + publicKey: paar.publicKeyZeile, + fingerprint: paar.fingerprint, + kommentar: "sshkv:backup", + ...paar.versiegelt, + }, + select: { id: true, publicKey: true }, + }); + await privateVerwendungAnlegen( + { + schluesselId: alt.id, + hostId: aId, + zielbenutzer: "root", + pfad: "~/.ssh/id_backup", + sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 }, + knownHostsFuer: [bId], + }, + AKTEUR, + ); + await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: bId, zielbenutzer: "root" }, AKTEUR); + await trockenlaufUndAusrollen(alt.id); + return alt; +} + describe("Rotation", () => { it("tauscht den Schluessel aus, ohne dass der Alias aufhoert zu funktionieren", async () => { const a = await eingerichteterHost(zielA, "ziel-a"); const b = await eingerichteterHost(zielB, "ziel-b"); - const paar = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:backup"); - const alt = await prisma.schluessel.create({ - data: { - id: paar.id, - name: "Backup", - slug: "backup", - typ: "ED25519", - kategorie: "DIENST", - publicKey: paar.publicKeyZeile, - fingerprint: paar.fingerprint, - kommentar: "sshkv:backup", - ...paar.versiegelt, - }, - select: { id: true, publicKey: true }, - }); - await privateVerwendungAnlegen( - { - schluesselId: alt.id, - hostId: a.id, - zielbenutzer: "root", - pfad: "~/.ssh/id_backup", - sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 }, - knownHostsFuer: [b.id], - }, - AKTEUR, - ); - await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: b.id, zielbenutzer: "root" }, AKTEUR); - await trockenlaufUndAusrollen(alt.id); + const alt = await alterSchluesselAusgerollt(a.id, b.id); // 1–2: Nachfolger erzeugen, Zuweisungen uebernehmen. await vorgangImWorker(await rotationStarten(alt.id, AKTEUR)); @@ -123,4 +130,50 @@ describe("Rotation", () => { const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un"); expect(ssh.stdout.trim()).toBe("root"); }); + + it("bricht ab: der ausgerollte neue Schluessel verschwindet, der alte arbeitet weiter (#032)", async () => { + const a = await eingerichteterHost(zielA, "ziel-a"); + const b = await eingerichteterHost(zielB, "ziel-b"); + const alt = await alterSchluesselAusgerollt(a.id, b.id); + const altVorher = await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } }); + + await vorgangImWorker(await rotationStarten(alt.id, AKTEUR)); + await rotationUebernehmen(alt.id, AKTEUR, { privatErlaubt: true }); + const neu = await prisma.schluessel.findFirstOrThrow({ + where: { vorgaengerId: alt.id }, + select: { id: true, publicKey: true }, + }); + await trockenlaufUndAusrollen(neu.id); + const neuerPfad = `id_backup-r${rotationsDatum().kompakt}`; + expect((await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout).toContain( + `IdentityFile ~/.ssh/${neuerPfad}`, + ); + + // Abbrechen = Widerruf des neuen Schluessels mit Dry-Run und Anwenden. + await rotationAbbrechen(alt.id, AKTEUR, { privatErlaubt: true }); + await trockenlaufUndAusrollen(neu.id); + expect((await widerrufAbschliessen([neu.id], AKTEUR))[0]?.status).toBe("WIDERRUFEN"); + + const authorized = (await befehlAlsRoot(zielB, "cat /root/.ssh/authorized_keys")).stdout; + expect(authorized).not.toContain(neu.publicKey.split(" ")[1]!); + expect(authorized).toContain(alt.publicKey.split(" ")[1]!); + const dateien = (await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout.split("\n"); + expect(dateien).toContain("id_backup"); + expect(dateien).not.toContain(neuerPfad); + // Der Alias zeigt wieder auf die alte Datei – und funktioniert. + const config = (await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout; + expect(config.match(/^Host zu-b$/gm)).toHaveLength(1); + expect(config).toContain("IdentityFile ~/.ssh/id_backup\n"); + const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un"); + expect(ssh.stdout.trim()).toBe("root"); + + // Der alte Schluessel ist unveraendert, seine Zuweisungen sind ausgerollt. + expect(await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } })).toEqual(altVorher); + const zustaende = [ + ...(await prisma.publicVerwendung.findMany({ where: { schluesselId: alt.id } })), + ...(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); + }); }); diff --git a/tests/rotation.test.ts b/tests/rotation.test.ts index 9ee8d34..4658893 100644 --- a/tests/rotation.test.ts +++ b/tests/rotation.test.ts @@ -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"); + }); +}); From 021e415fdedaecb57068acae91ac5b1ef4fc8a8b Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 17:55:46 +0200 Subject: [PATCH 5/5] docs(issues): #032 abgeschlossen, Entscheidungen notiert (#032) Haelt fest, was die Nacharbeiten gefunden und entschieden haben: den Fehler im Massenwiderruf (UUIDs fielen aus der Auswahl), das Zustandsmodell des Rotationsabbruchs ohne neues Feld, warum es danach keinen zweiten Nachfolger gibt, und wie die Protokoll-IDs ohne Umnummerieren lueckenlos werden. Co-Authored-By: Claude Opus 5.5 --- docs/issues/032-nacharbeiten.md | 44 +++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/docs/issues/032-nacharbeiten.md b/docs/issues/032-nacharbeiten.md index 7995d03..ecdcc20 100644 --- a/docs/issues/032-nacharbeiten.md +++ b/docs/issues/032-nacharbeiten.md @@ -1,22 +1,52 @@ # #032 Nacharbeiten aus Phase 4/5 -**Status:** offen +**Status:** erledigt **Meilenstein:** v1.0.0 **Labels:** test, chore -**Geschätzt:** 2 h **Tatsächlich:** – +**Geschätzt:** 2 h **Tatsächlich:** 0,75 h ## Ziel Kleinere offene Punkte aus den Berichten der Phasen 4 und 5 erledigen. ## Aufgaben -- [ ] Massenwiderruf im Browser testen (E2E mit frischer Bestätigung über den Mock-OIDC) -- [ ] Laufende Rotation abbrechen können (Nachfolger widerrufen, Vorgänger wieder aktiv) -- [ ] Protokoll-IDs ohne Zweierschritte (Spalten-Default und Trigger ziehen beide `nextval`) -- [ ] „Zum Dry-Run“ aus der Zuweisungsmatrix direkt zur Dry-Run-Karte springen (Anker) +- [x] Massenwiderruf im Browser testen (E2E mit frischer Bestätigung über den Mock-OIDC) +- [x] Laufende Rotation abbrechen können (Nachfolger widerrufen, Vorgänger wieder aktiv) +- [x] Protokoll-IDs ohne Zweierschritte (Spalten-Default und Trigger ziehen beide `nextval`) +- [x] „Zum Dry-Run“ aus der Zuweisungsmatrix direkt zur Dry-Run-Karte springen (Anker) ## Akzeptanzkriterien -- [ ] Alle Suiten grün +- [x] Alle Suiten grün ## Abhängigkeiten #023, #024, #025, #027 ## Notizen / Entscheidungen +- **Massenwiderruf:** Der neue E2E-Test (`widerruf.spec.ts`) fand einen echten Fehler: Die Seite + `/schluessel/widerruf` filterte `?id=` mit `/^[a-z0-9]{1,64}$/`. Vom Worker erzeugte Schlüssel tragen + eine UUID mit Bindestrichen – jeder erzeugte Schlüssel fiel still heraus („Keine Schlüssel gewählt“). + Jetzt `idsAusSuchparameter` in `src/lib/schluessel.ts` (mit Test). Außerdem nannte der Hinweis nach + verweigerter Bestätigung den falschen Knopf („Widerruf vorbereiten“ statt „2 Schlüssel widerrufen“). + `datenbank()` und `oidcBestaetigen()` liegen jetzt in `tests/e2e/hilfen.ts`. +- **Rotation abbrechen:** kein neues Feld. Abbrechen = Widerruf des Nachfolgers über den Ablauf aus #024 + (Dry-Run, er kann schon ausgerollt sein); ein Nachfolger im Widerruf bzw. widerrufen *ist* die + abgebrochene Rotation (`rotationAbgebrochen`). `vorgaengerId` bleibt für die Historie, der Abbruch + steht als `schluessel.rotation.abgebrochen` im Protokoll – in derselben Transaktion wie das Vormerken + (`widerrufEinleiten` nimmt dafür optional `zusaetzlich` an). Der alte Schlüssel bleibt unverändert; bei + gleichem Alias zeigt die config nach dem Anwenden wieder auf die alte Datei (nur verbleibende + Zuweisungen landen im Block). +- Abbrechen geht nur, solange der alte Schlüssel noch nicht entfernt wird (Schritt 5) – sonst wären am + Ende beide halb entfernt. Auf dem Nachfolger ersetzt „Rotation abbrechen“ den normalen + „Widerrufen“-Knopf, damit der Abbruch immer im Protokoll steht. +- `widerrufAbschliessen` setzt `ROTIERT` nur noch bei einem aktiven Nachfolger, der nicht selbst im + Widerruf ist; die Seite schreibt „Ersetzt durch …“ nur bei `ROTIERT`. +- **Kein zweiter Nachfolger:** `vorgaengerId` ist eindeutig (1:1-Relation). Nach einem Abbruch lässt sich + derselbe Schlüssel deshalb nicht erneut über sshkv rotieren; Seite und Fehlermeldung sagen das + (neuen Schlüssel anlegen, zuweisen, alten widerrufen). Eine erneute Rotation bräuchte eine + 1:n-Relation mit eindeutigem Teilindex „ein nicht widerrufener Nachfolger je Vorgänger“. +- **Protokoll-IDs:** Migration `20261012173000_protokoll_id_lueckenlos` setzt den Spalten-Default auf den + Platzhalter `0` (Prisma: `@default(0)`, `create` ohne ID geht weiter); die ID zieht nur noch der + Trigger unter der Advisory-Sperre aus der weiterhin zugeordneten Sequenz. Bestehende Zeilen bleiben + unverändert (die ID steckt in der Hash-Eingabe), alte Lücken bleiben also stehen. Eine mitgegebene ID + überschreibt der Trigger weiterhin. +- **Anker:** Die Dry-Run-Karte trägt `id="dry-run"` (`Karte` nimmt eine optionale `id`) und + `scroll-mt-20` wegen der festen Kopfleiste der schmalen Ansicht; „Zum Dry-Run“ zeigt auf + `/schluessel/#dry-run`.