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, widerrufEingeleitetAm: 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): 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 { 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 { 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(); 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; /** * 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; }; /** * 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 { 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, ); } await optionen.zusaetzlich?.(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 { 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 { 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). // 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"] } }, 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; }