Files
florian.wildeandClaude Opus 5.5 5d03681f3c feat(deploymentkey): Rotation vor dem Umstellen abbrechen (#036)
Eine begonnene Rotation ließ sich nicht zurücknehmen; nahm ein Host den
neuen Key dauerhaft nicht an, blieb nur das Übergehen. Abbrechen
verwirft jetzt den neuen Key samt Private Key, ein Dry-Run nimmt ihn
wieder von den Hosts, und eine neue Rotation kann sofort starten.

Nebenbei: Aktionen der Benutzerverwaltung (#035) bekommen im Audit-Log
eine eigene Gruppe, statt den Filter „Anmeldung“ zu verwässern; der
E2E-Test für lokale Konten hängt nicht mehr von der Reihenfolge ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:52:32 +02:00

177 lines
7.6 KiB
TypeScript
Raw Permalink 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 { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { authorizedKeysLesen } from "@/lib/ssh/authorized-keys";
import {
DeploymentKeyFehler,
instanzZiele,
rotationsStand,
umstellen,
} from "@/server/services/deploymentkey";
import { anmeldebenutzerVon } from "@/server/services/soll";
import { deploymentKeySicherstellen, privateKeyLaden } from "@/worker/geheimnisse";
import { hostVerbinden } from "@/worker/ssh/host";
import { authorizedKeysHolen } from "@/worker/ssh/ssh-dateien";
import { datenbankLeeren } from "../hilfen/db";
import { warteschlangeStoppen } from "../hilfen/vorgang";
import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host";
import { befehlAlsRoot, zielA, zielB, zielBereit } from "./hilfen/ziele";
/**
* Rotation des Deployment-Keys gegen echte Hosts (#031): danach meldet sich sshkv nur noch mit
* dem neuen Key an, der alte steht auf keinem Host mehr, und zu keinem Zeitpunkt war ein Host
* ohne funktionierenden Key.
*/
const AKTEUR = { id: "", name: "Test-Admin" };
let aufraeumen: () => void;
beforeAll(async () => {
await Promise.all([zielBereit(zielA), zielBereit(zielB)]);
aufraeumen = masterKeyAnlegen();
});
afterAll(async () => {
aufraeumen();
await warteschlangeStoppen();
});
beforeEach(async () => {
await datenbankLeeren();
await deploymentKeySicherstellen();
const reset = "userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true";
await Promise.all([befehlAlsRoot(zielA, reset), befehlAlsRoot(zielB, reset)]);
AKTEUR.id = (await prisma.benutzer.create({ data: { name: "Test-Admin", rolle: "ADMIN" } })).id;
});
const schritt = (s: "erzeugen" | "pruefen" | "abbrechen" | "abschliessen") =>
vorgangLaufen("ROTATION", { eingabe: { schritt: s }, angefordertVonId: AKTEUR.id });
async function trockenlaufUndAusrollen() {
const trockenlauf = await vorgangLaufen("TROCKENLAUF", {
eingabe: { ziele: await instanzZiele() },
angefordertVonId: AKTEUR.id,
});
expect(trockenlauf.status).toBe("ERLEDIGT");
const ausrollen = await vorgangLaufen("AUSROLLEN", {
eingabe: { trockenlaufId: trockenlauf.id },
angefordertVonId: AKTEUR.id,
});
expect(ausrollen.fehler).toBeNull();
}
type Host = Awaited<ReturnType<typeof eingerichteterHost>>;
async function fingerprintsAuf(host: Host): Promise<string[]> {
const verbindung = await hostVerbinden(host);
try {
const datei = await authorizedKeysHolen(verbindung, host, anmeldebenutzerVon(host));
return authorizedKeysLesen(datei.inhalt).zeilen.flatMap((z) =>
z.art === "schluessel" ? [z.fingerprint] : [],
);
} finally {
verbindung.end();
}
}
async function anmeldungKlappt(host: Host, schluesselId: string): Promise<boolean> {
const privateKey = await privateKeyLaden(schluesselId);
try {
const verbindung = await hostVerbinden(host, { benutzer: anmeldebenutzerVon(host), privateKey });
verbindung.end();
return true;
} catch {
return false;
} finally {
privateKey.fill(0);
}
}
describe("Rotation des Deployment-Keys", () => {
it("tauscht den Key auf allen Hosts ohne Unterbrechung aus", async () => {
const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b", "ROOT");
const alt = await prisma.schluessel.findFirstOrThrow({ where: { instanzStandard: true } });
expect((await schritt("erzeugen")).status).toBe("ERLEDIGT");
const neu = (await rotationsStand()).nachfolger!;
expect(neu.vorgaengerId).toBe(alt.id);
// Ein zweites Erzeugen waehrend der Rotation wird abgelehnt.
expect((await schritt("erzeugen")).status).toBe("FEHLGESCHLAGEN");
// Vor dem Verteilen scheitert die Pruefung – und ohne erfolgreiche Pruefung kein Umstellen.
await schritt("pruefen");
expect((await rotationsStand()).pruefung?.every((p) => !p.ok)).toBe(true);
await expect(umstellen(AKTEUR)).rejects.toThrow(DeploymentKeyFehler);
await trockenlaufUndAusrollen();
for (const host of [a, b]) {
expect(await fingerprintsAuf(host)).toEqual(expect.arrayContaining([alt.fingerprint, neu.fingerprint]));
}
await schritt("pruefen");
expect((await rotationsStand()).pruefung?.map((p) => p.ok)).toEqual([true, true]);
await umstellen(AKTEUR);
const umgestellt = await rotationsStand();
expect(umgestellt.aktuell?.id).toBe(neu.id);
expect(umgestellt.phase).toBe("aufraeumen");
// Abschliessen geht erst, wenn der alte Key ueberall entfernt ist.
expect((await schritt("abschliessen")).fehler).toMatch(/steht noch auf/);
await trockenlaufUndAusrollen();
for (const host of [a, b]) {
const fingerprints = await fingerprintsAuf(host);
expect(fingerprints).toContain(neu.fingerprint);
expect(fingerprints).not.toContain(alt.fingerprint);
expect(await anmeldungKlappt(host, alt.id)).toBe(false);
expect(await anmeldungKlappt(host, neu.id)).toBe(true);
}
expect((await schritt("abschliessen")).status).toBe("ERLEDIGT");
const verworfen = await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } });
expect(verworfen.status).toBe("ROTIERT");
expect(verworfen.privatGeloeschtAm).not.toBeNull();
expect((await rotationsStand()).phase).toBe("bereit");
// Der Alltag geht mit dem neuen Key weiter.
const abgleich = await vorgangLaufen("ABGLEICH", { hostId: a.id });
expect(abgleich.status).toBe("ERLEDIGT");
expect(await prisma.abweichung.count({ where: { hostId: a.id, behobenAm: null } })).toBe(0);
});
it("bricht vor dem Umstellen ab und laesst danach eine neue Rotation zu (#036)", async () => {
const a = await eingerichteterHost(zielA, "ziel-a");
const alt = await prisma.schluessel.findFirstOrThrow({ where: { instanzStandard: true } });
expect((await schritt("erzeugen")).status).toBe("ERLEDIGT");
const neu = (await rotationsStand()).nachfolger!;
await trockenlaufUndAusrollen();
expect(await fingerprintsAuf(a)).toContain(neu.fingerprint);
expect((await schritt("abbrechen")).status).toBe("ERLEDIGT");
const verworfen = await prisma.schluessel.findUniqueOrThrow({ where: { id: neu.id } });
expect(verworfen).toMatchObject({ status: "WIDERRUFEN", privatVerschluesselt: null });
expect(await rotationsStand()).toMatchObject({ phase: "bereit", abgebrochen: { id: neu.id } });
// Ohne laufende Rotation gibt es nichts mehr abzubrechen.
expect((await schritt("abbrechen")).status).toBe("FEHLGESCHLAGEN");
// Der Dry-Run nimmt den verworfenen Key wieder heraus; der alte arbeitet unveraendert.
await trockenlaufUndAusrollen();
const fingerprints = await fingerprintsAuf(a);
expect(fingerprints).toContain(alt.fingerprint);
expect(fingerprints).not.toContain(neu.fingerprint);
expect(await anmeldungKlappt(a, alt.id)).toBe(true);
// Eine neue Rotation beginnt von vorn.
expect((await schritt("erzeugen")).status).toBe("ERLEDIGT");
const zweiter = (await rotationsStand()).nachfolger!;
expect(zweiter.id).not.toBe(neu.id);
expect(zweiter.vorgaengerId).toBe(alt.id);
});
it("laesst Hosts mit eigenem Deployment-Key aus", async () => {
const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b");
const eigener = await prisma.schluessel.findFirstOrThrow({ where: { instanzStandard: true } });
// b bekommt (formal) einen eigenen Key – derselbe genuegt, es zaehlt die Zuordnung.
await prisma.host.update({ where: { id: b.id }, data: { deploymentSchluesselId: eigener.id } });
expect((await instanzZiele()).map((z) => z.hostId)).toEqual([a.id]);
});
});