Bisher war vorgaengerId eindeutig: nach einer abgebrochenen Rotation passte kein zweiter Nachfolger in die Kette, und der Schlüssel ließ sich nur über Neuanlage, Zuweisen und Widerruf ersetzen. Jetzt ist die Relation 1:n. Ein partieller Index erlaubt je Vorgänger nur einen Nachfolger, der weder widerrufen ist noch gerade widerrufen wird. Maßgeblich ist der jüngste. Erst wenn der Abbruch abgeschlossen ist, beginnt die Rotation wieder bei Schritt 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
444 lines
16 KiB
TypeScript
444 lines
16 KiB
TypeScript
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";
|
||
import { JUENGSTER, type MitNachfolger, mitNachfolger } from "@/lib/rotation";
|
||
|
||
/**
|
||
* 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,
|
||
nachfolgerListe: { select: { id: true, status: true, widerrufEingeleitetAm: true }, ...JUENGSTER },
|
||
} satisfies Prisma.SchluesselSelect;
|
||
|
||
type KopfRoh = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>;
|
||
type SchluesselKopf = MitNachfolger<KopfRoh>;
|
||
|
||
/** 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.map(mitNachfolger);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 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;
|
||
}
|