Files
sshkeyvault/src/server/services/widerruf.ts
T
florian.wildeandClaude Opus 5.5 f18fd4ec06 feat(rotation): laufende Rotation abbrechen (#032)
Bisher liess sich eine angefangene Rotation nur zu Ende fuehren. Wer
merkt, dass der neue Schluessel nicht passt, braucht einen Weg zurueck,
ohne den alten anzufassen.

"Rotation abbrechen" (Rotationsseite und Seite des Nachfolgers) merkt
den neuen Schluessel ueber den bestehenden Widerruf aus #024 vor; er
kann schon ausgerollt sein, deshalb folgen Dry-Run und Anwenden wie
sonst. Der alte bleibt aktiv und unveraendert. Wo beide denselben
Alias tragen, zeigt die config danach wieder auf die alte Datei, weil
privat.ts nur die verbleibenden Zuweisungen in den Block schreibt.

Zustandsmodell: kein neues Feld. Ein Nachfolger im Widerruf (oder
widerrufen) ist eine abgebrochene Rotation; vorgaengerId bleibt fuer
die Historie, der Abbruch steht als schluessel.rotation.abgebrochen im
Protokoll (in derselben Transaktion wie das Vormerken, dafuer nimmt
widerrufEinleiten optional einen Zusatzschritt an). Abbrechen geht nur,
solange der alte Schluessel noch nicht entfernt wird. widerrufAbschliessen
macht aus dem Widerruf des alten Schluessels kein ROTIERT mehr, wenn
der Nachfolger selbst im Widerruf ist. Weil vorgaengerId eindeutig
ist, gibt es nach einem Abbruch keinen zweiten Nachfolger; Seite und
Fehlermeldung sagen das.

Tests: DB-Tests fuer Abbruch, Idempotenz, Sperren und Status;
Integrationstest gegen echte Ziele (neuer Key ausgerollt, abbrechen,
neuer weg, alter in authorized_keys, ssh zu-b von A geht); E2E fuer
den Ablauf im Browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:45:11 +02:00

442 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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;
/**
* 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<void>;
};
/**
* 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,
);
}
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<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).
// 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;
}