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
+26 -1
View File
@@ -23,6 +23,7 @@ export const SCHLUESSEL_LISTE_SELECT = {
ablaufAm: true,
masterKeyVersion: true,
passphraseGeschuetzt: true,
widerrufEingeleitetAm: true,
person: { select: { id: true, name: true } },
_count: {
select: {
@@ -50,12 +51,15 @@ export const SCHLUESSEL_DETAIL_SELECT = {
tags: true,
ablaufAm: true,
zuletztVerwendetAm: true,
widerrufEingeleitetAm: true,
angelegtAm: true,
personId: true,
vorlageId: true,
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 } },
publicVerwendungen: {
where: { zustand: { not: "ENTFERNT" } },
orderBy: { angelegtAm: "asc" },
@@ -101,13 +105,34 @@ function alsStatus(wert: string | undefined): SchluesselStatus | undefined {
return STATI.find((s) => s === wert);
}
/**
* Heute 00:00 UTC nach dem Kalender in Berlin – Ablaufdaten liegen als UTC-Mitternacht ihres
* Tages in der Datenbank, und `ablaufZustand()` rechnet ebenfalls mit dem Berliner Datum.
*/
function heute(jetzt = new Date()): Date {
const tag = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin" }).format(jetzt);
return new Date(`${tag}T00:00:00Z`);
}
/**
* Status als Filter (#024): ein aktiver Schluessel mit Ablaufdatum in der Vergangenheit gilt als
* abgelaufen, auch wenn niemand den Status von Hand gesetzt hat. Automatisch entfernt wird nichts.
*/
function statusFilter(status: SchluesselStatus | undefined): Prisma.SchluesselWhereInput {
if (status === "AKTIV") return { status, OR: [{ ablaufAm: null }, { ablaufAm: { gte: heute() } }] };
if (status === "ABGELAUFEN") {
return { OR: [{ status }, { status: "AKTIV", ablaufAm: { lt: heute() } }] };
}
return status ? { status } : {};
}
export async function schluesselListe(filter: SchluesselFilter = {}) {
const q = filter.q?.trim().slice(0, 100);
const kategorie = alsKategorie(filter.kategorie);
const status = alsStatus(filter.status);
const where: Prisma.SchluesselWhereInput = {
...(kategorie ? { kategorie } : {}),
...(status ? { status } : {}),
AND: [statusFilter(status)],
...(q
? {
OR: [
+121
View File
@@ -0,0 +1,121 @@
import { prisma } from "@/lib/db";
import {
offeneEntfernungen,
widerrufGesperrt,
zielkontoKandidaten,
type OffeneEntfernung,
type ZielkontoKandidat,
} from "@/server/services/widerruf";
/**
* Was ein Widerruf bewirkt (#024) – fuer den Bestaetigungsdialog, die Massenwiderruf-Seite und
* die Fortsetzen-Ansicht. Nur Namen, Ziele und Pfade; nie Schluesselinhalte.
*/
export type WiderrufSchluessel = {
readonly id: string;
readonly name: string;
readonly kategorie: "PERSON" | "DIENST" | "DEPLOYMENT";
readonly status: "AKTIV" | "ROTIERT" | "WIDERRUFEN" | "ABGELAUFEN";
readonly eingeleitet: boolean;
readonly person: string | null;
readonly gesperrt: string | null;
};
export type WiderrufZiel = {
readonly id: string;
readonly schluesselId: string;
readonly ziel: string;
readonly archiviert: boolean;
};
export type WiderrufPrivatZiel = WiderrufZiel & {
readonly pfad: string;
readonly alias: string | null;
readonly beimWiderruf: "LOESCHEN" | "UMBENENNEN";
};
export type WiderrufUebersicht = {
readonly schluessel: WiderrufSchluessel[];
readonly public: WiderrufZiel[];
readonly privat: WiderrufPrivatZiel[];
readonly zielkonten: ZielkontoKandidat[];
readonly offen: OffeneEntfernung[];
};
export async function widerrufUebersicht(schluesselIds: readonly string[]): Promise<WiderrufUebersicht> {
const ids = [...new Set(schluesselIds)];
const schluessel = await prisma.schluessel.findMany({
where: { id: { in: ids } },
select: {
id: true,
name: true,
kategorie: true,
status: true,
widerrufEingeleitetAm: true,
person: { select: { name: true } },
},
orderBy: { name: "asc" },
});
const [oeffentlich, privat, zielkonten, offen] = await Promise.all([
prisma.publicVerwendung.findMany({
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
select: {
id: true,
schluesselId: true,
zielbenutzer: true,
host: { select: { name: true, archiviertAm: true } },
hostgruppe: { select: { name: true } },
},
orderBy: { angelegtAm: "asc" },
}),
prisma.privateVerwendung.findMany({
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
select: {
id: true,
schluesselId: true,
zielbenutzer: true,
pfad: true,
sshConfig: true,
beimWiderruf: true,
host: { select: { name: true, archiviertAm: true } },
},
orderBy: { angelegtAm: "asc" },
}),
zielkontoKandidaten(ids),
offeneEntfernungen(ids),
]);
return {
schluessel: schluessel.map((s) => ({
id: s.id,
name: s.name,
kategorie: s.kategorie,
status: s.status,
eingeleitet: Boolean(s.widerrufEingeleitetAm),
person: s.person?.name ?? null,
gesperrt: widerrufGesperrt(s),
})),
public: oeffentlich.map((v) => ({
id: v.id,
schluesselId: v.schluesselId,
ziel: v.host
? `${v.zielbenutzer}@${v.host.name}`
: `${v.zielbenutzer}@${v.hostgruppe?.name ?? "?"} (Gruppe)`,
archiviert: Boolean(v.host?.archiviertAm),
})),
privat: privat.map((v) => {
const config =
v.sshConfig && typeof v.sshConfig === "object" ? (v.sshConfig as { alias?: unknown }) : null;
return {
id: v.id,
schluesselId: v.schluesselId,
ziel: `${v.zielbenutzer}@${v.host.name}`,
pfad: v.pfad,
alias: typeof config?.alias === "string" ? config.alias : null,
beimWiderruf: v.beimWiderruf,
archiviert: Boolean(v.host.archiviertAm),
};
}),
zielkonten,
offen,
};
}