Merge branch 'phase/023-024' (Rotation, Widerruf)

This commit is contained in:
2026-10-12 13:31:47 +02:00
39 changed files with 3701 additions and 101 deletions
+71
View File
@@ -0,0 +1,71 @@
"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 { FachFehler } from "@/server/services/fachfehler";
import { rotationAltWiderrufen, rotationStarten, rotationUebernehmen } from "@/server/services/rotation";
import { VerwendungsFehler } from "@/server/services/verwendungen";
/**
* Gefuehrte Rotation (#023): je Schritt eine duenne Aktion, die Oberflaeche verkettet sie.
* Rotieren heisst einen Schluessel erzeugen und ausrollen: `schluesselVerwalten` und
* `publicKeyAusrollen`; fuer Private-Key-Zuweisungen zusaetzlich `privateKeyAusrollen` (Service).
*/
export type RotationsAntwort = { readonly vorgangId?: string; readonly fehler?: string };
function alsAntwort(fehler: unknown): RotationsAntwort {
if (
fehler instanceof RechteFehler ||
fehler instanceof BestaetigungFehler ||
fehler instanceof VerwendungsFehler ||
fehler instanceof FachFehler
) {
return { fehler: fehler.message };
}
throw fehler;
}
const id = z.string().min(1).max(64);
async function berechtigt() {
await rechtErforderlich("schluesselVerwalten");
return rechtErforderlich("publicKeyAusrollen");
}
export async function rotationStartenAktion(schluesselId: string): Promise<RotationsAntwort> {
try {
const { benutzer } = await berechtigt();
return { vorgangId: await rotationStarten(id.parse(schluesselId), akteurVon(benutzer)) };
} catch (fehler) {
return alsAntwort(fehler);
}
}
export async function rotationUebernehmenAktion(schluesselId: string): Promise<RotationsAntwort> {
try {
const { benutzer } = await berechtigt();
await rotationUebernehmen(id.parse(schluesselId), akteurVon(benutzer), {
privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"),
});
revalidatePath(`/schluessel/${schluesselId}`);
return {};
} catch (fehler) {
return alsAntwort(fehler);
}
}
export async function rotationAltWiderrufenAktion(schluesselId: string): Promise<RotationsAntwort> {
try {
const { benutzer } = await berechtigt();
await rotationAltWiderrufen(id.parse(schluesselId), akteurVon(benutzer), {
privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"),
});
revalidatePath(`/schluessel/${schluesselId}`);
return {};
} catch (fehler) {
return alsAntwort(fehler);
}
}
+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);
}
}
+67 -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: [
@@ -166,3 +191,44 @@ export async function vorlagenAuswahl() {
select: { id: true, name: true, standardLaufzeitTage: true },
});
}
// ---------------------------------------------------------------- Rotationskette (#023)
export type KettenGlied = { readonly id: string; readonly name: string; readonly status: SchluesselStatus };
/**
* Die ganze Rotationskette eines Schluessels, aeltester zuerst. Begrenzt, damit eine (durch
* Fehler entstandene) Schleife die Seite nicht aufhaengt.
*/
export async function rotationsKette(id: string, maximal = 25): Promise<KettenGlied[]> {
const glied = (schluesselId: string) =>
prisma.schluessel.findUnique({
where: { id: schluesselId },
select: {
id: true,
name: true,
status: true,
vorgaengerId: true,
nachfolger: { select: { id: true } },
},
});
const start = await glied(id);
if (!start) return [];
const kette: KettenGlied[] = [{ id: start.id, name: start.name, status: start.status }];
const gesehen = new Set([start.id]);
for (let davor = start.vorgaengerId; davor && kette.length < maximal && !gesehen.has(davor);) {
const g = await glied(davor);
if (!g) break;
gesehen.add(g.id);
kette.unshift({ id: g.id, name: g.name, status: g.status });
davor = g.vorgaengerId;
}
for (let danach = start.nachfolger?.id; danach && kette.length < maximal && !gesehen.has(danach);) {
const g = await glied(danach);
if (!g) break;
gesehen.add(g.id);
kette.push({ id: g.id, name: g.name, status: g.status });
danach = g.nachfolger?.id;
}
return kette;
}
+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,
};
}
+313
View File
@@ -0,0 +1,313 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation";
import { NAME_MAX, type SchluesselVorgangEingabe } from "@/lib/schluessel";
import { eindeutigerSlug } from "@/server/services/schluessel";
import { protokollieren } from "@/server/services/protokoll";
import {
VerwendungsFehler,
privateVerwendungAnlegen,
publicVerwendungAnlegen,
} from "@/server/services/verwendungen";
import { widerrufEinleiten } from "@/server/services/widerruf";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
/**
* Gefuehrte Rotation (#023): neuer Schluessel gleichen Typs, alle aktiven Zuweisungen
* uebernehmen, ausrollen, Verbindungen testen – und erst dann den alten Schluessel ueber den
* Widerruf (#024) entfernen. Der alte endet als ROTIERT, der neue zeigt per `vorgaengerId` auf ihn.
*
* Jeder Schritt ist idempotent und liest seinen Stand aus der Datenbank: die Oberflaeche kann
* jederzeit neu laden und an derselben Stelle weitermachen.
*/
export class RotationsFehler extends VerwendungsFehler {
constructor(nachricht: string) {
super(nachricht);
this.name = "RotationsFehler";
}
}
type Akteur = { readonly id: string; readonly name: string };
/** Zustaende, in denen eine Verwendung mitgenommen wird. */
const AKTIVE = ["GEPLANT", "AUSGEROLLT", "ABWEICHUNG", "FEHLGESCHLAGEN"] as const;
const ALT_SELECT = {
id: true,
name: true,
typ: true,
kategorie: true,
status: true,
fingerprint: true,
tags: true,
ablaufAm: true,
personId: true,
vorlageId: true,
masterKeyVersion: true,
widerrufEingeleitetAm: true,
nachfolger: { select: { id: true, name: true, status: true } },
_count: { select: { privateVerwendungen: { where: { zustand: { in: [...AKTIVE] } } } } },
} satisfies Prisma.SchluesselSelect;
type Alt = Prisma.SchluesselGetPayload<{ select: typeof ALT_SELECT }>;
/**
* Warum sshkv diesen Schluessel nicht selbst rotieren kann – oder null. Ein Personen-Key (oder
* ein Dienst-Key, dessen Private Key woanders liegt) braucht einen neuen Public Key von aussen.
*/
export function rotationGesperrt(
s: Pick<Alt, "kategorie" | "status" | "masterKeyVersion" | "widerrufEingeleitetAm"> & {
readonly privatZuweisungen: number;
},
): { grund: string; neuerPublicKeyNoetig?: boolean } | null {
if (s.kategorie === "DEPLOYMENT")
return { grund: "Der Deployment-Key wird über eine eigene Rotation ersetzt." };
if (s.status !== "AKTIV" && s.status !== "ABGELAUFEN")
return { grund: "Nur aktive oder abgelaufene Schlüssel lassen sich rotieren." };
if (s.kategorie === "PERSON" || (s.masterKeyVersion === null && s.privatZuweisungen === 0)) {
return {
grund:
"Den Private Key hat sshkv nicht – rotieren heißt hier: Die Person erzeugt ein neues Schlüsselpaar und liefert den neuen Public Key. Diesen importieren, genauso zuweisen und danach diesen Schlüssel widerrufen.",
neuerPublicKeyNoetig: true,
};
}
return null;
}
async function altLaden(id: string): Promise<Alt> {
const alt = await prisma.schluessel.findUnique({ where: { id }, select: ALT_SELECT });
if (!alt) throw new RotationsFehler("Diesen Schlüssel gibt es nicht.");
return alt;
}
function gesperrtPruefen(alt: Alt) {
// Ein laufender Widerruf gehoert zur Rotation, wenn es schon einen Nachfolger gibt.
if (alt.widerrufEingeleitetAm && !alt.nachfolger)
throw new RotationsFehler("Dieser Schlüssel wird gerade widerrufen.");
const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen });
if (gesperrt) throw new RotationsFehler(gesperrt.grund);
}
/** Schritt 1: Nachfolger im Worker erzeugen lassen. Liefert die Vorgangs-ID. */
export async function rotationStarten(altId: string, akteur: Akteur): Promise<string> {
const alt = await altLaden(altId);
gesperrtPruefen(alt);
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);
// Ein vergangenes Ablaufdatum wuerde der Nachfolger sonst erben – er waere sofort abgelaufen.
const ablaufAm = alt.ablaufAm && alt.ablaufAm > new Date() ? alt.ablaufAm.toISOString() : null;
const eingabe: SchluesselVorgangEingabe = {
name,
slug,
tags: alt.tags,
ablaufAm,
personId: alt.personId,
vorlageId: alt.vorlageId,
kommentar: null,
typ: alt.typ === "RSA4096" ? "RSA4096" : "ED25519",
vorgaengerId: alt.id,
};
return vorgangEinreihen({ art: "SCHLUESSEL_ERZEUGEN", angefordertVonId: akteur.id, eingabe });
}
async function nachfolgerVon(alt: Alt) {
if (!alt.nachfolger) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen.");
if (alt.nachfolger.status !== "AKTIV") throw new RotationsFehler("Der neue Schlüssel ist nicht aktiv.");
return alt.nachfolger;
}
/**
* Schritt 2: aktive Zuweisungen fuer den Nachfolger uebernehmen. Public: gleiche Ziele und
* Optionen. Private: gleicher Host/Benutzer und Alias, neuer Pfad – der config-Block zeigt nach
* dem Ausrollen auf die neue Datei (juengste Zuweisung gewinnt je Alias, src/worker/ablaeufe/privat.ts).
*/
export async function rotationUebernehmen(
altId: string,
akteur: Akteur,
optionen: { readonly privatErlaubt: boolean },
): Promise<{ public: number; privat: number }> {
const alt = await altLaden(altId);
gesperrtPruefen(alt);
const neu = await nachfolgerVon(alt);
const [altPublic, altPrivat, neuPublic, neuPrivat] = await Promise.all([
prisma.publicVerwendung.findMany({
where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } },
orderBy: { angelegtAm: "asc" },
}),
prisma.privateVerwendung.findMany({
where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } },
orderBy: { angelegtAm: "asc" },
}),
prisma.publicVerwendung.findMany({ where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } } }),
prisma.privateVerwendung.findMany({ where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } } }),
]);
if (altPrivat.length > 0 && !optionen.privatErlaubt)
throw new RotationsFehler("Private Keys rollt nur ein Admin aus.");
let anzahlPublic = 0;
for (const v of altPublic) {
const schon = neuPublic.some(
(n) => n.hostId === v.hostId && n.hostgruppeId === v.hostgruppeId && n.zielbenutzer === v.zielbenutzer,
);
if (schon) continue;
await publicVerwendungAnlegen(
{
schluesselId: neu.id,
hostId: v.hostId,
hostgruppeId: v.hostgruppeId,
zielbenutzer: v.zielbenutzer,
optionen: v.optionen,
},
akteur,
);
anzahlPublic += 1;
}
const datum = rotationsDatum().kompakt;
let anzahlPrivat = 0;
for (const v of altPrivat) {
const config = v.sshConfig as { alias: string; hostName: string; benutzer: string; port: number } | null;
const schon = neuPrivat.some(
(n) =>
n.hostId === v.hostId &&
n.zielbenutzer === v.zielbenutzer &&
((n.sshConfig as { alias?: string } | null)?.alias ?? null) === (config?.alias ?? null),
);
if (schon) continue;
// Auch entfernte Zuweisungen belegen einen Pfad (eindeutiger Index ueber alle Zustaende).
const belegt = new Set(
(
await prisma.privateVerwendung.findMany({
where: { hostId: v.hostId, zielbenutzer: v.zielbenutzer },
select: { pfad: true },
})
).map((p) => p.pfad),
);
await privateVerwendungAnlegen(
{
schluesselId: neu.id,
hostId: v.hostId,
zielbenutzer: v.zielbenutzer,
pfad: rotationsPfad(v.pfad, datum, belegt),
sshConfig: config,
knownHostsFuer: v.knownHostsFuer,
beimWiderruf: v.beimWiderruf,
},
akteur,
);
anzahlPrivat += 1;
}
if (anzahlPublic + anzahlPrivat > 0) {
await protokollieren({
akteur,
aktion: "schluessel.rotation.uebernommen",
ergebnis: "ERFOLG",
fingerprint: alt.fingerprint,
details: { nachfolgerId: neu.id, public: anzahlPublic, privat: anzahlPrivat },
});
}
return { public: anzahlPublic, privat: anzahlPrivat };
}
/** Verbindungstests, die der Nachfolger bestehen muss: jede Private-Zuweisung mit Alias. */
async function testZiele(neuId: string) {
const verwendungen = await prisma.privateVerwendung.findMany({
where: { schluesselId: neuId, zustand: { not: "ENTFERNT" } },
select: {
id: true,
zielbenutzer: true,
sshConfig: true,
zustand: true,
zuletztAngewendetAm: true,
host: { select: { name: true } },
},
orderBy: { angelegtAm: "asc" },
});
const mitAlias = verwendungen.filter((v) => (v.sshConfig as { alias?: string } | null)?.alias);
return Promise.all(
mitAlias.map(async (v) => {
// Gruen ist nur ein Test nach dem letzten Ausrollen – ein aelterer bewiese nichts.
const test = await prisma.vorgang.findFirst({
where: {
art: "VERBINDUNGSTEST",
eingabe: { equals: { privatVerwendungId: v.id } },
...(v.zuletztAngewendetAm ? { angelegtAm: { gte: v.zuletztAngewendetAm } } : {}),
},
orderBy: { angelegtAm: "desc" },
select: { status: true, fehler: true },
});
return {
id: v.id,
ziel: `${v.zielbenutzer}@${v.host.name} → ${(v.sshConfig as { alias: string }).alias}`,
ausgerollt: v.zustand === "AUSGEROLLT",
status: test?.status ?? null,
fehler: test?.fehler ?? null,
};
}),
);
}
export type RotationsStand = Awaited<ReturnType<typeof rotationsStand>>;
/** Wo steht die Rotation? Fuer die Schrittanzeige – alles aus der Datenbank. */
export async function rotationsStand(altId: string) {
const alt = await altLaden(altId);
const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen });
const neu = alt.nachfolger;
if (!neu) return { alt, gesperrt, neu: null, schritt: 0 as const };
const [altPublic, altPrivat, neuPublic, neuPrivat, tests] = await Promise.all([
prisma.publicVerwendung.count({ where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } } }),
prisma.privateVerwendung.count({ where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } } }),
prisma.publicVerwendung.findMany({
where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } },
select: { zustand: true },
}),
prisma.privateVerwendung.findMany({
where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } },
select: { zustand: true },
}),
testZiele(neu.id),
]);
const neuOffen = [...neuPublic, ...neuPrivat].filter((v) => v.zustand !== "AUSGEROLLT").length;
// Uebernommen, sobald der Neue mindestens so viele Zuweisungen hat wie der Alte (noch) aktive.
const uebernommen =
alt.widerrufEingeleitetAm !== null || (neuPublic.length >= altPublic && neuPrivat.length >= altPrivat);
const testsGruen = tests.every((t) => t.ausgerollt && t.status === "ERLEDIGT");
const schritt =
alt.status === "ROTIERT"
? (5 as const)
: !uebernommen
? (1 as const)
: alt.widerrufEingeleitetAm
? (4 as const)
: neuOffen > 0
? (2 as const)
: !testsGruen
? (3 as const)
: (4 as const);
return { alt, gesperrt, neu, schritt, neuOffen, tests, testsGruen };
}
/**
* Schritt 5: den alten Schluessel widerrufen – nur wenn der Nachfolger ueberall ausgerollt ist
* und jeder Verbindungstest seit dem letzten Ausrollen gruen war.
*/
export async function rotationAltWiderrufen(
altId: string,
akteur: Akteur,
optionen: { readonly privatErlaubt: boolean },
): Promise<void> {
const stand = await rotationsStand(altId);
if (!stand.neu) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen.");
if (stand.schritt === 5) return;
if (stand.schritt < 4) {
throw new RotationsFehler(
stand.schritt === 3
? "Erst wenn alle Verbindungstests mit dem neuen Schlüssel grün sind, wird der alte entfernt."
: "Der neue Schlüssel ist noch nicht überall ausgerollt.",
);
}
await widerrufEinleiten([altId], akteur, optionen);
}
+42 -2
View File
@@ -20,6 +20,9 @@ export class VerwendungsFehler extends Error {
type Akteur = { readonly id: string; readonly name: string };
/** Ein Schluessel im Widerruf (#024) bekommt keine neuen Ziele – sonst wuerde er nie fertig. */
const WIDERRUF_LAEUFT = "Dieser Schlüssel wird gerade widerrufen und lässt sich nicht mehr zuweisen.";
export type PublicZuweisung = {
readonly schluesselId: string;
readonly hostId?: string | null;
@@ -46,11 +49,12 @@ export async function publicVerwendungAnlegen(eingabe: PublicZuweisung, akteur:
}
const schluessel = await prisma.schluessel.findUnique({
where: { id: eingabe.schluesselId },
select: { status: true, fingerprint: true, kategorie: true },
select: { status: true, fingerprint: true, kategorie: true, widerrufEingeleitetAm: true },
});
if (!schluessel) throw new VerwendungsFehler("Diesen Schlüssel gibt es nicht.");
if (schluessel.status !== "AKTIV")
throw new VerwendungsFehler("Nur aktive Schlüssel lassen sich zuweisen.");
if (schluessel.widerrufEingeleitetAm) throw new VerwendungsFehler(WIDERRUF_LAEUFT);
if (schluessel.kategorie === "DEPLOYMENT") {
throw new VerwendungsFehler("Der Deployment-Key wird von sshkv selbst verwaltet.");
}
@@ -116,13 +120,47 @@ export async function publicVerwendungEntfernen(id: string, akteur: Akteur): Pro
});
}
/** Ziele mehrerer Schluessel ohne Doppelte – ein gemeinsamer Dry-Run beim Massenwiderruf (#024). */
async function zieleMehrererSchluessel(
schluesselIds: readonly string[],
mitPrivat: boolean,
): Promise<Ziel[]> {
const ziele = new Map<string, Ziel>();
for (const schluesselId of schluesselIds) {
const liste = [
...(await zieleVonVerwendungen({ schluesselId })),
...(mitPrivat ? await zieleVonPrivatVerwendungen({ schluesselId }) : []),
];
for (const ziel of liste)
ziele.set(`${ziel.hostId}\u0000${ziel.zielbenutzer}\u0000${ziel.art ?? "public"}`, ziel);
}
return [...ziele.values()];
}
/** Trockenlauf fuer die Ziele eines Schluessels oder fuer ausgewaehlte Verwendungen. */
export async function trockenlaufStarten(
auswahl: { schluesselId?: string; hostId?: string; verwendungIds?: string[]; ziele?: Ziel[] },
auswahl: {
schluesselId?: string;
/** Mehrere Schluessel in einem Dry-Run (Massenwiderruf, #024). */
schluesselIds?: readonly string[];
hostId?: string;
verwendungIds?: string[];
ziele?: Ziel[];
},
akteur: Akteur,
/** Private-Key-Ziele nur fuer Admins (Freigabe Frage 3) – der Aufrufer entscheidet per darf(). */
mitPrivat = false,
): Promise<string> {
if (auswahl.schluesselIds && !auswahl.ziele) {
return trockenlaufStarten(
{
ziele: await zieleMehrererSchluessel(auswahl.schluesselIds, mitPrivat),
...(auswahl.schluesselIds.length === 1 ? { schluesselId: auswahl.schluesselIds[0] } : {}),
},
akteur,
mitPrivat,
);
}
const ziele = auswahl.ziele ?? [
...(await zieleVonVerwendungen({
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
@@ -197,11 +235,13 @@ export async function privateVerwendungAnlegen(eingabe: PrivateZuweisung, akteur
kategorie: true,
privatGeloeschtAm: true,
masterKeyVersion: true,
widerrufEingeleitetAm: true,
},
});
if (!schluessel) throw new VerwendungsFehler("Diesen Schlüssel gibt es nicht.");
if (schluessel.status !== "AKTIV")
throw new VerwendungsFehler("Nur aktive Schlüssel lassen sich ausrollen.");
if (schluessel.widerrufEingeleitetAm) throw new VerwendungsFehler(WIDERRUF_LAEUFT);
if (schluessel.kategorie !== "DIENST")
throw new VerwendungsFehler("Private Keys werden nur für Service-Schlüssel ausgerollt.");
// masterKeyVersion ist genau dann gesetzt, wenn ein Private Key gespeichert ist (DB-CHECK).
+433
View File
@@ -0,0 +1,433 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { protokollieren } from "@/server/services/protokoll";
import { VerwendungsFehler } from "@/server/services/verwendungen";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
/**
* Widerruf und Massenwiderruf (#024).
*
* Ablauf in drei Schritten, damit der Status nie mehr verspricht, als auf den Hosts passiert ist:
*
* 1. `widerrufEinleiten`: alle Verwendungen auf ENTFERNEN, `widerrufEingeleitetAm` setzen.
* Der Schluessel bleibt AKTIV, laesst sich aber nicht mehr zuweisen.
* 2. Dry-Run und Ausrollen wie sonst auch (#016/#017). Was gelingt, wird ENTFERNT; was auf einem
* Host scheitert (nicht erreichbar, Datei geaendert), bleibt ENTFERNEN.
* 3. `widerrufAbschliessen`: erst wenn keine Verwendung mehr offen ist, wird der Status
* WIDERRUFEN (bzw. ROTIERT, wenn ein Nachfolger aus einer Rotation existiert, #023).
*
* Optional dazwischen: Zielkonten entfernen, die fuer diese Schluessel angelegt wurden.
*/
export class WiderrufFehler extends VerwendungsFehler {
constructor(nachricht: string) {
super(nachricht);
this.name = "WiderrufFehler";
}
}
type Akteur = { readonly id: string; readonly name: string };
/** Verwendungen in diesen Zustaenden zaehlen als „noch in Gebrauch“ (fuer Zielkonten). */
const IN_GEBRAUCH = ["GEPLANT", "AUSGEROLLT", "ABWEICHUNG", "FEHLGESCHLAGEN"] as const;
const SCHLUESSEL_KOPF = {
id: true,
name: true,
kategorie: true,
status: true,
fingerprint: true,
widerrufEingeleitetAm: true,
nachfolger: { select: { id: true, status: true } },
} satisfies Prisma.SchluesselSelect;
type SchluesselKopf = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>;
/** Warum ein Schluessel nicht widerrufen werden kann – oder null. */
export function widerrufGesperrt(s: Pick<SchluesselKopf, "kategorie" | "status" | "name">): string | null {
// Der Deployment-Key ist sshkvs eigener Zugang; ohne ihn ist kein Host mehr erreichbar.
if (s.kategorie === "DEPLOYMENT")
return "Der Deployment-Key lässt sich nicht widerrufen – er wird über eine eigene Rotation ersetzt.";
if (s.status === "WIDERRUFEN") return `„${s.name}“ ist bereits widerrufen.`;
if (s.status === "ROTIERT") return `„${s.name}“ ist bereits rotiert und von allen Hosts entfernt.`;
return null;
}
async function schluesselLaden(ids: readonly string[]): Promise<SchluesselKopf[]> {
const eindeutig = [...new Set(ids)];
if (eindeutig.length === 0) throw new WiderrufFehler("Bitte mindestens einen Schlüssel wählen.");
const liste = await prisma.schluessel.findMany({
where: { id: { in: eindeutig } },
select: SCHLUESSEL_KOPF,
orderBy: { name: "asc" },
});
if (liste.length !== eindeutig.length) throw new WiderrufFehler("Einen der Schlüssel gibt es nicht.");
return liste;
}
// ---------------------------------------------------------------- Zielkonten
export type ZielkontoKandidat = {
readonly id: string;
readonly hostId: string;
readonly hostName: string;
readonly benutzername: string;
readonly sudoModus: "KEINER" | "BEFEHLE" | "VOLL";
/** Namen anderer Schluessel, die das Konto noch nutzen – dann bleibt es. */
readonly nochGenutztVon: string[];
};
/**
* Zielkonten, die sshkv fuer diese Schluessel angelegt hat: ein von sshkv angelegtes, noch
* vorhandenes Konto auf einem Host, auf dem einer der Schluessel (in irgendeinem Zustand) fuer
* genau diesen Benutzer zugewiesen war. Ein Zielkonto kennt seinen Schluessel nicht; die
* Zuweisung ist die einzige belastbare Verbindung.
*/
export async function zielkontoKandidaten(schluesselIds: readonly string[]): Promise<ZielkontoKandidat[]> {
const ids = [...new Set(schluesselIds)];
const [oeffentlich, privat] = await Promise.all([
prisma.publicVerwendung.findMany({
where: { schluesselId: { in: ids } },
select: {
zielbenutzer: true,
hostId: true,
hostgruppe: { select: { mitglieder: { select: { hostId: true } } } },
},
}),
prisma.privateVerwendung.findMany({
where: { schluesselId: { in: ids } },
select: { zielbenutzer: true, hostId: true },
}),
]);
const paare = new Set<string>();
for (const v of oeffentlich) {
const hosts = v.hostId ? [v.hostId] : (v.hostgruppe?.mitglieder.map((m) => m.hostId) ?? []);
for (const hostId of hosts) paare.add(`${hostId}\u0000${v.zielbenutzer}`);
}
for (const v of privat) paare.add(`${v.hostId}\u0000${v.zielbenutzer}`);
if (paare.size === 0) return [];
const konten = await prisma.zielkonto.findMany({
where: {
entferntAm: null,
vonSshkvAngelegt: true,
host: { archiviertAm: null },
OR: [...paare].map((p) => {
const [hostId, benutzername] = p.split("\u0000") as [string, string];
return { hostId, benutzername };
}),
},
select: {
id: true,
hostId: true,
benutzername: true,
sudoModus: true,
host: { select: { name: true } },
},
orderBy: [{ host: { name: "asc" } }, { benutzername: "asc" }],
});
const ergebnis: ZielkontoKandidat[] = [];
for (const konto of konten) {
const [andere, anderePrivat] = await Promise.all([
prisma.publicVerwendung.findMany({
where: {
schluesselId: { notIn: ids },
zielbenutzer: konto.benutzername,
zustand: { in: [...IN_GEBRAUCH] },
schluessel: { widerrufEingeleitetAm: null },
OR: [{ hostId: konto.hostId }, { hostgruppe: { mitglieder: { some: { hostId: konto.hostId } } } }],
},
select: { schluessel: { select: { name: true } } },
}),
prisma.privateVerwendung.findMany({
where: {
schluesselId: { notIn: ids },
hostId: konto.hostId,
zielbenutzer: konto.benutzername,
zustand: { in: [...IN_GEBRAUCH] },
schluessel: { widerrufEingeleitetAm: null },
},
select: { schluessel: { select: { name: true } } },
}),
]);
ergebnis.push({
id: konto.id,
hostId: konto.hostId,
hostName: konto.host.name,
benutzername: konto.benutzername,
sudoModus: konto.sudoModus,
nochGenutztVon: [...new Set([...andere, ...anderePrivat].map((v) => v.schluessel.name))].sort(),
});
}
return ergebnis;
}
// ---------------------------------------------------------------- Einleiten
export type EinleitenOptionen = {
/** Darf der Akteur Private Keys entfernen (privateKeyAusrollen)? Prueft die Aktion per darf(). */
readonly privatErlaubt: boolean;
};
/**
* Schritt 1: Verwendungen zum Entfernen vormerken. Idempotent – ein zweiter Aufruf (etwa beim
* Fortsetzen) aendert an bereits vorgemerkten Verwendungen nichts.
*/
export async function widerrufEinleiten(
schluesselIds: readonly string[],
akteur: Akteur,
optionen: EinleitenOptionen,
): Promise<void> {
const liste = await schluesselLaden(schluesselIds);
for (const s of liste) {
const grund = widerrufGesperrt(s);
if (grund) throw new WiderrufFehler(grund);
}
const ids = liste.map((s) => s.id);
const privatOffen = await prisma.privateVerwendung.count({
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
});
if (privatOffen > 0 && !optionen.privatErlaubt) {
throw new WiderrufFehler(
"Private Keys auf Hosts entfernt nur ein Admin. Bitte einen Admin widerrufen lassen.",
);
}
await prisma.$transaction(async (tx) => {
const jetzt = new Date();
for (const s of liste) {
const [oeffentlich, privat] = await Promise.all([
tx.publicVerwendung.findMany({
where: { schluesselId: s.id, zustand: { not: "ENTFERNT" } },
select: {
id: true,
zustand: true,
zuletztAngewendetAm: true,
host: { select: { name: true, archiviertAm: true } },
},
}),
tx.privateVerwendung.findMany({
where: { schluesselId: s.id, zustand: { not: "ENTFERNT" } },
select: {
id: true,
zustand: true,
zuletztAngewendetAm: true,
host: { select: { name: true, archiviertAm: true } },
},
}),
]);
// Nie ausgerollt: auf dem Host steht nichts. Archivierter Host: sshkv verbindet sich dort
// nicht mehr – das Protokoll haelt fest, dass dort nichts entfernt wurde.
const sofort = (v: (typeof oeffentlich)[number]) =>
(v.zustand === "GEPLANT" && !v.zuletztAngewendetAm) || Boolean(v.host?.archiviertAm);
const archiviert = [...oeffentlich, ...privat]
.filter((v) => v.host?.archiviertAm)
.map((v) => v.host!.name);
await tx.publicVerwendung.updateMany({
where: { id: { in: oeffentlich.filter(sofort).map((v) => v.id) } },
data: { zustand: "ENTFERNT" },
});
await tx.publicVerwendung.updateMany({
where: { id: { in: oeffentlich.filter((v) => !sofort(v)).map((v) => v.id) } },
data: { zustand: "ENTFERNEN" },
});
await tx.privateVerwendung.updateMany({
where: { id: { in: privat.filter(sofort).map((v) => v.id) } },
data: { zustand: "ENTFERNT" },
});
await tx.privateVerwendung.updateMany({
where: { id: { in: privat.filter((v) => !sofort(v)).map((v) => v.id) } },
data: { zustand: "ENTFERNEN" },
});
if (!s.widerrufEingeleitetAm) {
await tx.schluessel.update({
where: { id: s.id },
data: { widerrufEingeleitetAm: jetzt },
select: { id: true },
});
}
await protokollieren(
{
akteur,
aktion: "schluessel.widerruf.eingeleitet",
ergebnis: "ERFOLG",
fingerprint: s.fingerprint,
details: {
name: s.name,
public: oeffentlich.length,
privat: privat.length,
ohneHostZugriff: oeffentlich.filter(sofort).length + privat.filter(sofort).length,
archivierteHosts: [...new Set(archiviert)].sort(),
massenwiderruf: liste.length > 1,
},
},
tx,
);
}
});
}
// ---------------------------------------------------------------- Offene Entfernungen
export type OffeneEntfernung = {
readonly id: string;
readonly schluesselId: string;
readonly art: "public" | "privat";
readonly ziel: string;
readonly zustand: string;
};
/** Was fuer diese Schluessel noch nicht entfernt ist – fuer Seite und Abschluss. */
export async function offeneEntfernungen(schluesselIds: readonly string[]): Promise<OffeneEntfernung[]> {
const ids = [...new Set(schluesselIds)];
const [oeffentlich, privat] = await Promise.all([
prisma.publicVerwendung.findMany({
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
select: {
id: true,
schluesselId: true,
zielbenutzer: true,
zustand: true,
host: { select: { name: 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,
zustand: true,
host: { select: { name: true } },
},
orderBy: { angelegtAm: "asc" },
}),
]);
return [
...oeffentlich.map((v) => ({
id: v.id,
schluesselId: v.schluesselId,
art: "public" as const,
ziel: v.host
? `${v.zielbenutzer}@${v.host.name}:~/.ssh/authorized_keys`
: `${v.zielbenutzer}@${v.hostgruppe?.name ?? "?"} (Gruppe):~/.ssh/authorized_keys`,
zustand: v.zustand,
})),
...privat.map((v) => ({
id: v.id,
schluesselId: v.schluesselId,
art: "privat" as const,
ziel: `${v.zielbenutzer}@${v.host.name}:${v.pfad}`,
zustand: v.zustand,
})),
];
}
// ---------------------------------------------------------------- Zielkonten entfernen
/**
* Optionaler Schritt nach dem Ausrollen: ausgewaehlte Zielkonten entfernen. Erst wenn die
* Schluessel von den Hosts verschwunden sind – sonst haette ein Fehlschlag beim Ausrollen einen
* halb geloeschten Benutzer mit noch gueltigem Schluessel zur Folge.
*/
export async function widerrufZielkontenEntfernen(
schluesselIds: readonly string[],
zielkontoIds: readonly string[],
akteur: Akteur,
): Promise<{ id: string; beschriftung: string }[]> {
const liste = await schluesselLaden(schluesselIds);
if (liste.some((s) => !s.widerrufEingeleitetAm))
throw new WiderrufFehler("Bitte zuerst den Widerruf einleiten.");
const offen = await offeneEntfernungen(liste.map((s) => s.id));
if (offen.length > 0) {
throw new WiderrufFehler(
"Zielkonten werden erst entfernt, wenn die Schlüssel von allen Hosts entfernt sind.",
);
}
const kandidaten = new Map((await zielkontoKandidaten(liste.map((s) => s.id))).map((k) => [k.id, k]));
const vorgaenge: { id: string; beschriftung: string }[] = [];
const gewaehlt = [...new Set(zielkontoIds)];
for (const id of gewaehlt) {
const konto = kandidaten.get(id);
if (!konto) throw new WiderrufFehler("Dieses Zielkonto gehört nicht zu den gewählten Schlüsseln.");
if (konto.nochGenutztVon.length > 0) {
throw new WiderrufFehler(
`${konto.benutzername}@${konto.hostName} wird noch von „${konto.nochGenutztVon.join("“, „")}“ genutzt und bleibt.`,
);
}
}
for (const id of gewaehlt) {
const konto = kandidaten.get(id)!;
vorgaenge.push({
id: await vorgangEinreihen({
art: "ZIELKONTO_ENTFERNEN",
angefordertVonId: akteur.id,
hostId: konto.hostId,
eingabe: { benutzer: konto.benutzername },
}),
beschriftung: `${konto.benutzername}@${konto.hostName}`,
});
}
return vorgaenge;
}
// ---------------------------------------------------------------- Abschliessen
export type AbschlussErgebnis = {
readonly schluesselId: string;
readonly name: string;
readonly status: "WIDERRUFEN" | "ROTIERT" | "OFFEN";
readonly offen: OffeneEntfernung[];
};
/**
* Schritt 3: Status setzen – aber nur fuer Schluessel ohne offene Entfernung. Die anderen
* bleiben im Zwischenzustand; die Oberflaeche zeigt, was fehlt.
*/
export async function widerrufAbschliessen(
schluesselIds: readonly string[],
akteur: Akteur,
): Promise<AbschlussErgebnis[]> {
const liste = await schluesselLaden(schluesselIds);
const offen = await offeneEntfernungen(liste.map((s) => s.id));
const ergebnisse: AbschlussErgebnis[] = [];
for (const s of liste) {
if (s.status === "WIDERRUFEN" || s.status === "ROTIERT") {
ergebnisse.push({ schluesselId: s.id, name: s.name, status: s.status, offen: [] });
continue;
}
if (!s.widerrufEingeleitetAm) throw new WiderrufFehler(`Für „${s.name}“ ist kein Widerruf eingeleitet.`);
const eigene = offen.filter((o) => o.schluesselId === s.id);
if (eigene.length > 0) {
ergebnisse.push({ schluesselId: s.id, name: s.name, status: "OFFEN", offen: eigene });
continue;
}
// Ein aktiver Nachfolger heisst: der Widerruf ist der letzte Schritt einer Rotation (#023).
const status = s.nachfolger?.status === "AKTIV" ? "ROTIERT" : "WIDERRUFEN";
await prisma.$transaction(async (tx) => {
const geaendert = await tx.schluessel.updateMany({
where: { id: s.id, status: { in: ["AKTIV", "ABGELAUFEN"] } },
data: { status },
});
if (geaendert.count === 0) return;
await protokollieren(
{
akteur,
aktion: status === "ROTIERT" ? "schluessel.rotiert" : "schluessel.widerrufen",
ergebnis: "ERFOLG",
fingerprint: s.fingerprint,
details: {
name: s.name,
...(s.nachfolger && status === "ROTIERT" ? { nachfolgerId: s.nachfolger.id } : {}),
},
},
tx,
);
});
ergebnisse.push({ schluesselId: s.id, name: s.name, status, offen: [] });
}
return ergebnisse;
}