feat(deploymentkey): geführte Rotation des Deployment-Keys (#031)
Bisher ließ sich der Instanz-Key nur von Hand auf jedem Host tauschen. Die Rotation läuft jetzt in fünf Schritten: Nachfolger erzeugen, per Dry-Run neben den alten verteilen, Anmeldung je Host prüfen, umstellen, alten Key aus allen Blöcken entfernen und seinen Private Key verwerfen. Der Nachfolger steht während der Rotation in Block und Pflichtliste, damit kein Host ohne funktionierenden Key dasteht. Umgestellt wird nur nach erfolgreicher Prüfung oder bewusstem Übergehen eines Hosts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { akteurVon } from "@/server/actions/formular";
|
||||
import type { VorgangsAntwort } from "@/server/actions/verwendungen";
|
||||
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { DeploymentKeyFehler, rotationsSchrittAnfordern, umstellen } from "@/server/services/deploymentkey";
|
||||
|
||||
/** Rotation des Deployment-Keys (#031). Nur Admins mit frischer Bestaetigung. */
|
||||
|
||||
function alsAntwort(fehler: unknown): { fehler: string } {
|
||||
if (
|
||||
fehler instanceof DeploymentKeyFehler ||
|
||||
fehler instanceof RechteFehler ||
|
||||
fehler instanceof BestaetigungFehler
|
||||
) {
|
||||
return { fehler: fehler.message };
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
export async function rotationsSchrittAktion(
|
||||
schritt: "erzeugen" | "pruefen" | "abschliessen",
|
||||
): Promise<VorgangsAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("deploymentKeyVerwalten");
|
||||
const geprueft = z.enum(["erzeugen", "pruefen", "abschliessen"]).parse(schritt);
|
||||
return { vorgangId: await rotationsSchrittAnfordern(geprueft, akteurVon(benutzer)) };
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function umstellenAktion(uebergehen: readonly string[]): Promise<{ fehler?: string }> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("deploymentKeyVerwalten");
|
||||
await umstellen(akteurVon(benutzer), z.array(z.string().min(1).max(64)).max(500).parse(uebergehen));
|
||||
revalidatePath("/deployment-key");
|
||||
return {};
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
publicVerwendungEntfernen,
|
||||
trockenlaufStarten,
|
||||
} from "@/server/services/verwendungen";
|
||||
import { instanzZiele } from "@/server/services/deploymentkey";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/** Public-Key-Zuweisungen und Ausrollen (#016). Duenn: Recht, Eingabe, Service. */
|
||||
@@ -78,10 +79,16 @@ export type AusrollAuswahl =
|
||||
| { readonly schluesselId: string }
|
||||
| { readonly hostId: string }
|
||||
/** Ein gemeinsamer Dry-Run fuer mehrere Schluessel (Massenwiderruf, #024). */
|
||||
| { readonly schluesselIds: readonly string[] };
|
||||
| { readonly schluesselIds: readonly string[] }
|
||||
/** Alle Hosts mit dem Instanz-Deployment-Key – fuer dessen Rotation (#031). */
|
||||
| { readonly deploymentKey: true };
|
||||
|
||||
export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<VorgangsAntwort> {
|
||||
try {
|
||||
if ("deploymentKey" in auswahl) {
|
||||
const { benutzer } = await rechtErforderlich("deploymentKeyVerwalten");
|
||||
return { vorgangId: await trockenlaufStarten({ ziele: await instanzZiele() }, akteurVon(benutzer)) };
|
||||
}
|
||||
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
|
||||
const id = z.string().min(1).max(64);
|
||||
return {
|
||||
|
||||
@@ -49,7 +49,7 @@ export function aktionEinordnen(aktion: string, ergebnis: string): AktionsArt {
|
||||
if (aktion.startsWith("authorized_keys.") || aktion.startsWith("privatekey.ausgerollt"))
|
||||
return "ausgerollt";
|
||||
if (aktion.includes("widerruf")) return "widerrufen";
|
||||
if (aktion.includes("rotation")) return "rotiert";
|
||||
if (aktion.includes("rotation") || aktion === "deploymentkey.umgestellt") return "rotiert";
|
||||
if (aktion === "abgleich") return "abgleich";
|
||||
if (aktion.startsWith("anmeldung") || aktion.startsWith("bestaetigung")) return "anmeldung";
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { anmeldebenutzerVon, deploymentNachfolger, type Ziel } from "@/server/services/soll";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/**
|
||||
* Rotation des Instanz-Deployment-Keys (#031) – geführt in fünf Schritten:
|
||||
*
|
||||
* 1. erzeugen: Nachfolger anlegen (DEPLOYMENT, nicht Standard, vorgaengerId = alter).
|
||||
* 2. verteilen: Dry-Run + Ausrollen auf alle Hosts mit Instanz-Key – der Nachfolger kommt
|
||||
* **neben** den alten in den Block (soll.ts).
|
||||
* 3. pruefen: je Host eine Anmeldung mit dem neuen Key.
|
||||
* 4. umstellen: Nachfolger wird Standard, alter ROTIERT; danach Dry-Run + Ausrollen entfernt den
|
||||
* alten aus allen Blöcken.
|
||||
* 5. abschliessen: auf jedem Host nachsehen, dass der alte weg ist, dann seinen Private Key
|
||||
* verwerfen.
|
||||
*
|
||||
* Zu keinem Zeitpunkt steht ein Host ohne funktionierenden Deployment-Key da: umgestellt wird
|
||||
* erst, wenn der neue Key überall geprüft ist (oder ein Admin einen Host bewusst übergeht –
|
||||
* der braucht danach einen neuen Bootstrap).
|
||||
*/
|
||||
export class DeploymentKeyFehler extends Error {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "DeploymentKeyFehler";
|
||||
}
|
||||
}
|
||||
|
||||
type Akteur = { readonly id: string; readonly name: string };
|
||||
|
||||
export type PruefErgebnis = { hostId: string; hostName: string; ok: boolean; fehler?: string };
|
||||
|
||||
/** Hosts, die den Instanz-Key benutzen (kein eigener, keiner über eine Gruppe). */
|
||||
export async function instanzHosts() {
|
||||
return prisma.host.findMany({
|
||||
where: {
|
||||
archiviertAm: null,
|
||||
bootstrapAm: { not: null },
|
||||
deploymentSchluesselId: null,
|
||||
gruppen: { none: { hostgruppe: { deploymentSchluesselId: { not: null } } } },
|
||||
},
|
||||
select: { id: true, name: true, anmeldeart: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function instanzZiele(): Promise<Ziel[]> {
|
||||
return (await instanzHosts()).map((h) => ({
|
||||
hostId: h.id,
|
||||
zielbenutzer: anmeldebenutzerVon(h),
|
||||
art: "public",
|
||||
}));
|
||||
}
|
||||
|
||||
export async function rotationsStand() {
|
||||
const aktuell = await prisma.schluessel.findFirst({
|
||||
where: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" },
|
||||
select: { id: true, fingerprint: true, angelegtAm: true, vorgaengerId: true },
|
||||
});
|
||||
const nachfolger = await deploymentNachfolger();
|
||||
// Nach dem Umstellen: der alte ist ROTIERT, sein Private Key noch nicht verworfen.
|
||||
const vorgaenger = aktuell?.vorgaengerId
|
||||
? await prisma.schluessel.findFirst({
|
||||
where: {
|
||||
id: aktuell.vorgaengerId,
|
||||
kategorie: "DEPLOYMENT",
|
||||
status: "ROTIERT",
|
||||
privatGeloeschtAm: null,
|
||||
},
|
||||
select: { id: true, fingerprint: true },
|
||||
})
|
||||
: null;
|
||||
const letztePruefung = await prisma.vorgang.findFirst({
|
||||
where: { art: "ROTATION", eingabe: { path: ["schritt"], equals: "pruefen" } },
|
||||
orderBy: { angelegtAm: "desc" },
|
||||
select: { id: true, status: true, ergebnis: true, beendetAm: true, angelegtAm: true },
|
||||
});
|
||||
const hosts = await instanzHosts();
|
||||
const pruefung =
|
||||
letztePruefung && nachfolger && letztePruefung.angelegtAm > nachfolger.angelegtAm
|
||||
? ((letztePruefung.ergebnis as { hosts?: PruefErgebnis[] } | null)?.hosts ?? null)
|
||||
: null;
|
||||
const phase: "bereit" | "verteilen" | "aufraeumen" = nachfolger
|
||||
? "verteilen"
|
||||
: vorgaenger
|
||||
? "aufraeumen"
|
||||
: "bereit";
|
||||
return { aktuell, nachfolger, vorgaenger, phase, hosts, pruefung };
|
||||
}
|
||||
|
||||
export async function rotationsSchrittAnfordern(
|
||||
schritt: "erzeugen" | "pruefen" | "abschliessen",
|
||||
akteur: Akteur,
|
||||
): Promise<string> {
|
||||
const stand = await rotationsStand();
|
||||
if (schritt === "erzeugen" && stand.phase !== "bereit") {
|
||||
throw new DeploymentKeyFehler("Es läuft bereits eine Rotation des Deployment-Keys.");
|
||||
}
|
||||
if (schritt === "pruefen" && !stand.nachfolger)
|
||||
throw new DeploymentKeyFehler("Es gibt keinen neuen Deployment-Key zu prüfen.");
|
||||
if (schritt === "abschliessen" && !stand.vorgaenger)
|
||||
throw new DeploymentKeyFehler("Es gibt nichts abzuschließen.");
|
||||
return vorgangEinreihen({ art: "ROTATION", angefordertVonId: akteur.id, eingabe: { schritt } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Schritt 4: Nachfolger wird Standard. Verlangt eine Prüfung nach dem Erzeugen, in der jeder
|
||||
* Host den neuen Key angenommen hat – oder ausdrücklich übergangen wird.
|
||||
*/
|
||||
export async function umstellen(akteur: Akteur, uebergehen: readonly string[] = []): Promise<void> {
|
||||
const stand = await rotationsStand();
|
||||
if (!stand.nachfolger || !stand.aktuell)
|
||||
throw new DeploymentKeyFehler("Es gibt keinen neuen Deployment-Key.");
|
||||
if (!stand.pruefung) throw new DeploymentKeyFehler("Erst den neuen Key auf allen Hosts prüfen.");
|
||||
const geprueft = new Map(stand.pruefung.map((p) => [p.hostId, p]));
|
||||
const offen = stand.hosts.filter((h) => !geprueft.get(h.id)?.ok && !uebergehen.includes(h.id));
|
||||
if (offen.length) {
|
||||
throw new DeploymentKeyFehler(
|
||||
`Der neue Key ist noch nicht auf allen Hosts geprüft: ${offen.map((h) => h.name).join(", ")}.`,
|
||||
);
|
||||
}
|
||||
const alt = stand.aktuell;
|
||||
const neu = stand.nachfolger;
|
||||
await prisma.$transaction([
|
||||
// Erst den alten abloesen, dann den neuen setzen – der partielle Index erlaubt nur einen Standard.
|
||||
prisma.schluessel.update({
|
||||
where: { id: alt.id },
|
||||
data: { instanzStandard: false, status: "ROTIERT" },
|
||||
select: { id: true },
|
||||
}),
|
||||
prisma.schluessel.update({
|
||||
where: { id: neu.id },
|
||||
data: { instanzStandard: true },
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: "deploymentkey.umgestellt",
|
||||
ergebnis: "ERFOLG",
|
||||
fingerprint: neu.fingerprint,
|
||||
details: { alt: alt.fingerprint, uebergangen: [...uebergehen] },
|
||||
});
|
||||
}
|
||||
@@ -46,6 +46,23 @@ async function deploymentKeyFuer(host: Pick<Host, "id" | "deploymentSchluesselId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Nachfolger des Instanz-Deployment-Keys waehrend einer Rotation (#031): neu erzeugt,
|
||||
* aber noch nicht Standard. Er gehoert schon in jeden Block – neben den alten –, damit kein
|
||||
* Host je ohne funktionierenden Deployment-Key dasteht.
|
||||
*/
|
||||
export async function deploymentNachfolger() {
|
||||
return prisma.schluessel.findFirst({
|
||||
where: {
|
||||
kategorie: "DEPLOYMENT",
|
||||
instanzStandard: false,
|
||||
status: "AKTIV",
|
||||
vorgaenger: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" },
|
||||
},
|
||||
select: { id: true, publicKey: true, fingerprint: true, vorgaengerId: true, angelegtAm: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function sollFuer(hostId: string, zielbenutzer: string): Promise<Soll> {
|
||||
const host = await prisma.host.findUniqueOrThrow({
|
||||
where: { id: hostId },
|
||||
@@ -77,6 +94,18 @@ export async function sollFuer(hostId: string, zielbenutzer: string): Promise<So
|
||||
eintraege.push({ publicKey: deployment.publicKey, fingerprint: deployment.fingerprint });
|
||||
pflicht.push(deployment.fingerprint);
|
||||
}
|
||||
// Nur Hosts mit dem Instanz-Key nehmen an dessen Rotation teil (#031).
|
||||
const eigener = host.deploymentSchluesselId !== null;
|
||||
const nachfolger = eigener ? null : await deploymentNachfolger();
|
||||
if (nachfolger && (await deploymentKeyFuer(host))?.fingerprint !== nachfolger.fingerprint) {
|
||||
const gruppe = await prisma.hostgruppeMitglied.count({
|
||||
where: { hostId: host.id, hostgruppe: { deploymentSchluesselId: { not: null } } },
|
||||
});
|
||||
if (gruppe === 0) {
|
||||
eintraege.push({ publicKey: nachfolger.publicKey, fingerprint: nachfolger.fingerprint });
|
||||
pflicht.push(nachfolger.fingerprint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const drin: string[] = [];
|
||||
|
||||
Reference in New Issue
Block a user