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`. 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/src/app/(app)/schluessel/[id]/page.tsx b/src/app/(app)/schluessel/[id]/page.tsx index 836e6f7..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{" "} @@ -457,6 +494,10 @@ export default async function SchluesselSeite({ !imWiderruf && (s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && ( @@ -498,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/app/(app)/schluessel/widerruf/page.tsx b/src/app/(app)/schluessel/widerruf/page.tsx index f034ab5..28565ac 100644 --- a/src/app/(app)/schluessel/widerruf/page.tsx +++ b/src/app/(app)/schluessel/widerruf/page.tsx @@ -9,6 +9,7 @@ import { Leerzustand } from "@/components/ui/Leerzustand"; import { Seitenkopf } from "@/components/ui/Seitenkopf"; import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf"; import { WiderrufFormular } from "@/components/widerruf/WiderrufFormular"; +import { idsAusSuchparameter } from "@/lib/schluessel"; import { darf } from "@/server/auth/rechte"; import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { widerrufUebersicht } from "@/server/queries/widerruf"; @@ -17,14 +18,6 @@ export const metadata: Metadata = { title: "Massenwiderruf" }; type Suchparameter = Promise<{ id?: string | string[]; zielkonten?: string | string[] }>; -function liste(wert: string | string[] | undefined): string[] { - const roh = Array.isArray(wert) ? wert : wert ? [wert] : []; - return [...new Set(roh.flatMap((w) => w.split(",")).filter((w) => /^[a-z0-9]{1,64}$/i.test(w)))].slice( - 0, - 200, - ); -} - /** * Massenwiderruf (#024): mehrere Schluessel – etwa alle einer Person nach Projektende – in einem * Durchgang mit gemeinsamem Dry-Run. Nur Admins; das Vormerken verlangt eine frische @@ -32,7 +25,7 @@ function liste(wert: string | string[] | undefined): string[] { */ export default async function MassenwiderrufSeite({ searchParams }: { searchParams: Suchparameter }) { const parameter = await searchParams; - const ids = liste(parameter.id); + const ids = idsAusSuchparameter(parameter.id); const selbst = `/schluessel/widerruf?${ids.map((id) => `id=${id}`).join("&")}`; const { benutzer } = await sitzungErforderlich("massenwiderruf", selbst); @@ -61,7 +54,7 @@ export default async function MassenwiderrufSeite({ searchParams }: { searchPara const offeneStati = (s: { status: string }) => s.status === "AKTIV" || s.status === "ABGELAUFEN"; const nochNichtEingeleitet = uebersicht.schluessel.filter((s) => offeneStati(s) && !s.eingeleitet); const laufend = uebersicht.schluessel.filter((s) => offeneStati(s) && s.eingeleitet); - const vorauswahl = liste(parameter.zielkonten); + const vorauswahl = idsAusSuchparameter(parameter.zielkonten); return (
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/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 ( s.gesperrt); const mehrere = uebersicht.schluessel.length > 1; + const knopfText = mehrere ? `${uebersicht.schluessel.length} Schlüssel widerrufen` : "Widerruf vorbereiten"; const ziel = (() => { const url = new URL(ruecksprung, "http://x"); @@ -100,7 +101,8 @@ export function WiderrufFormular({ href={`/bestaetigen?ziel=${encodeURIComponent(ruecksprung)}`} className="text-accent inline-flex min-h-11 items-center text-[14px] font-medium" > - Anmeldung bestätigen und danach erneut „Widerruf vorbereiten“ wählen + {/* Nennt den Knopf, den es hier wirklich gibt – beim Massenwiderruf heisst er anders (#032). */} + Anmeldung bestätigen und danach erneut „{knopfText}“ wählen )}
@@ -124,11 +126,7 @@ export function WiderrufFormular({ }) } > - {laeuft - ? "Wird vorgemerkt …" - : mehrere - ? `${uebersicht.schluessel.length} Schlüssel widerrufen` - : "Widerruf vorbereiten"} + {laeuft ? "Wird vorgemerkt …" : knopfText}
diff --git a/src/components/zuweisungen/ZellenDialog.tsx b/src/components/zuweisungen/ZellenDialog.tsx index 1f781f6..aac9512 100644 --- a/src/components/zuweisungen/ZellenDialog.tsx +++ b/src/components/zuweisungen/ZellenDialog.tsx @@ -175,7 +175,7 @@ export function ZellenDialog({ zeigt und schreibt die Änderung.