feat(widerruf): Widerruf und Massenwiderruf mit Dry-Run (#024)

Ein Schlüssel soll nach Projektende mit einem Klick von allen Hosts
verschwinden – aber nie als „widerrufen“ gelten, solange er irgendwo noch
liegt. Deshalb ein Zwischenzustand (widerrufEingeleitetAm): Verwendungen
werden zum Entfernen vorgemerkt, der bestehende Dry-Run mit Hash-Sperre
zeigt und schreibt die Änderungen, und erst wenn keine Verwendung mehr
offen ist, setzt der Abschluss WIDERRUFEN (bzw. ROTIERT für #023).
Scheitert ein Host, bleibt der Schlüssel im Zwischenzustand und die Seite
nennt die offenen Ziele.

Zielkonten lassen sich optional mit entfernen, aber erst nach den
Schlüsseln und nie, wenn ein anderer aktiver Schlüssel sie noch nutzt.
Massenwiderruf (Admin, frische Bestätigung) nutzt einen gemeinsamen
Dry-Run und protokolliert je Schlüssel. Abgelaufene Schlüssel erscheinen
im Filter „abgelaufen“ und bieten den Widerruf an – entfernt wird nie
automatisch.

Migration 20261012130000_widerruf_eingeleitet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:14:01 +02:00
co-authored by Claude Opus 5.5
parent 2dad8e5631
commit c6361ff3c6
26 changed files with 2334 additions and 91 deletions
+8 -2
View File
@@ -74,7 +74,11 @@ export async function publicEntfernenAktion(
}
}
export type AusrollAuswahl = { readonly schluesselId: string } | { readonly hostId: string };
export type AusrollAuswahl =
| { readonly schluesselId: string }
| { readonly hostId: string }
/** Ein gemeinsamer Dry-Run fuer mehrere Schluessel (Massenwiderruf, #024). */
| { readonly schluesselIds: readonly string[] };
export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<VorgangsAntwort> {
try {
@@ -84,7 +88,9 @@ export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<Vorgan
vorgangId: await trockenlaufStarten(
"schluesselId" in auswahl
? { schluesselId: id.parse(auswahl.schluesselId) }
: { hostId: id.parse(auswahl.hostId) },
: "schluesselIds" in auswahl
? { schluesselIds: z.array(id).min(1).max(200).parse(auswahl.schluesselIds) }
: { hostId: id.parse(auswahl.hostId) },
akteurVon(benutzer),
// Private-Key-Ziele nur fuer Admins – ein Operator rollt so nie einen Private Key aus.
darf(benutzer.rolle, "privateKeyAusrollen"),
+85
View File
@@ -0,0 +1,85 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { akteurVon } from "@/server/actions/formular";
import { darf } from "@/server/auth/rechte";
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { VerwendungsFehler } from "@/server/services/verwendungen";
import {
widerrufAbschliessen,
widerrufEinleiten,
widerrufZielkontenEntfernen,
type AbschlussErgebnis,
} from "@/server/services/widerruf";
/**
* Widerruf und Massenwiderruf (#024). Duenn: Recht, Eingabe, Service.
*
* Rechte: einen Public Key entfernen darf, wer ausrollen darf; sobald Private Keys betroffen
* sind, nur ein Admin (prueft der Service mit `privatErlaubt`). Mehrere Schluessel auf einmal
* (Massenwiderruf) und das Entfernen von Zielkonten verlangen Admin mit frischer Bestaetigung.
*/
export type WiderrufAntwort = {
readonly fehler?: string;
/** Frische Bestaetigung noetig: die Oberflaeche verlinkt /bestaetigen. */
readonly bestaetigen?: boolean;
};
function alsAntwort(fehler: unknown): WiderrufAntwort {
if (fehler instanceof BestaetigungFehler) return { bestaetigen: true, fehler: fehler.message };
if (fehler instanceof RechteFehler || fehler instanceof VerwendungsFehler)
return { fehler: fehler.message };
throw fehler;
}
const idListe = z.array(z.string().min(1).max(64)).min(1).max(200);
function pfadeNeuLaden(ids: readonly string[]) {
revalidatePath("/schluessel");
for (const id of ids) revalidatePath(`/schluessel/${id}`);
}
export async function widerrufEinleitenAktion(schluesselIds: string[]): Promise<WiderrufAntwort> {
try {
const ids = idListe.parse(schluesselIds);
const { benutzer } = await rechtErforderlich(ids.length > 1 ? "massenwiderruf" : "publicKeyAusrollen");
await widerrufEinleiten(ids, akteurVon(benutzer), {
privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"),
});
pfadeNeuLaden(ids);
return {};
} catch (fehler) {
return alsAntwort(fehler);
}
}
export async function widerrufZielkontenAktion(
schluesselIds: string[],
zielkontoIds: string[],
): Promise<WiderrufAntwort & { vorgaenge?: { id: string; beschriftung: string }[] }> {
try {
const ids = idListe.parse(schluesselIds);
const konten = idListe.parse(zielkontoIds);
// Ein Benutzer samt Home verschwindet: Admin mit frischer Bestaetigung (#018).
const { benutzer } = await rechtErforderlich("zielkontenVerwalten");
return { vorgaenge: await widerrufZielkontenEntfernen(ids, konten, akteurVon(benutzer)) };
} catch (fehler) {
return alsAntwort(fehler);
}
}
export async function widerrufAbschliessenAktion(
schluesselIds: string[],
): Promise<WiderrufAntwort & { ergebnisse?: AbschlussErgebnis[] }> {
try {
const ids = idListe.parse(schluesselIds);
// Abschliessen setzt nur den Status nach dem, was die Datenbank belegt – kein Host-Zugriff.
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
const ergebnisse = await widerrufAbschliessen(ids, akteurVon(benutzer));
pfadeNeuLaden(ids);
return { ergebnisse };
} catch (fehler) {
return alsAntwort(fehler);
}
}