Files
florian.wildeandClaude Opus 5.5 c6361ff3c6 feat(widerruf): Widerruf und Massenwiderruf mit Dry-Run (#024)
Ein Schlüssel soll nach Projektende mit einem Klick von allen Hosts
verschwinden – aber nie als „widerrufen“ gelten, solange er irgendwo noch
liegt. Deshalb ein Zwischenzustand (widerrufEingeleitetAm): Verwendungen
werden zum Entfernen vorgemerkt, der bestehende Dry-Run mit Hash-Sperre
zeigt und schreibt die Änderungen, und erst wenn keine Verwendung mehr
offen ist, setzt der Abschluss WIDERRUFEN (bzw. ROTIERT für #023).
Scheitert ein Host, bleibt der Schlüssel im Zwischenzustand und die Seite
nennt die offenen Ziele.

Zielkonten lassen sich optional mit entfernen, aber erst nach den
Schlüsseln und nie, wenn ein anderer aktiver Schlüssel sie noch nutzt.
Massenwiderruf (Admin, frische Bestätigung) nutzt einen gemeinsamen
Dry-Run und protokolliert je Schlüssel. Abgelaufene Schlüssel erscheinen
im Filter „abgelaufen“ und bieten den Widerruf an – entfernt wird nie
automatisch.

Migration 20261012130000_widerruf_eingeleitet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:14:01 +02:00

347 lines
15 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, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { BESTAETIGUNG_NOETIG, RECHTE } from "@/server/auth/rechte";
import { schluesselListe } from "@/server/queries/schluessel";
import { widerrufUebersicht } from "@/server/queries/widerruf";
import {
VerwendungsFehler,
publicVerwendungAnlegen,
trockenlaufStarten,
} from "@/server/services/verwendungen";
import {
WiderrufFehler,
widerrufAbschliessen,
widerrufEinleiten,
widerrufZielkontenEntfernen,
zielkontoKandidaten,
} from "@/server/services/widerruf";
import { datenbankLeeren } from "./hilfen/db";
import { datenbankAlsText, warteschlangeStoppen } from "./hilfen/vorgang";
import { ed25519Blob, zeile } from "./ssh/hilfen";
/** Widerruf und Massenwiderruf (#024): Zustaende, Rechte, Protokoll. */
let admin: { id: string; name: string };
let a: { id: string; name: string };
let b: { id: string; name: string };
let zaehler = 0;
afterAll(async () => {
await warteschlangeStoppen();
});
beforeEach(async () => {
await datenbankLeeren();
admin = await prisma.benutzer.create({
data: { name: "Ada Admin", rolle: "ADMIN" },
select: { id: true, name: true },
});
a = await prisma.host.create({
data: { name: "srv-a", adresse: "10.0.0.1" },
select: { id: true, name: true },
});
b = await prisma.host.create({
data: { name: "srv-b", adresse: "10.0.0.2" },
select: { id: true, name: true },
});
});
async function schluessel(name: string, daten: { kategorie?: "PERSON" | "DIENST" | "DEPLOYMENT" } = {}) {
zaehler += 1;
return prisma.schluessel.create({
data: {
name,
slug: `k-${zaehler}`,
typ: "ED25519",
kategorie: daten.kategorie ?? "DIENST",
publicKey: zeile("ssh-ed25519", ed25519Blob(), `k-${zaehler}`),
fingerprint: `SHA256:test${zaehler}`,
kommentar: `k-${zaehler}`,
},
select: { id: true, name: true, fingerprint: true },
});
}
/** Public auf B (ausgerollt) und Private auf A (ausgerollt) – wie nach dem Assistenten. */
async function ausgerollt(schluesselId: string, benutzerB = "deploy") {
const jetzt = new Date();
const oeffentlich = await prisma.publicVerwendung.create({
data: {
schluesselId,
hostId: b.id,
zielbenutzer: benutzerB,
zustand: "AUSGEROLLT",
zuletztAngewendetAm: jetzt,
},
});
const privat = await prisma.privateVerwendung.create({
data: {
schluesselId,
hostId: a.id,
zielbenutzer: "root",
pfad: `~/.ssh/id_${schluesselId}`,
sshConfig: { alias: "zu-b", hostName: "srv-b", benutzer: benutzerB, port: 22 },
zustand: "AUSGEROLLT",
zuletztAngewendetAm: jetzt,
},
});
return { oeffentlich, privat };
}
async function fehlerVon(versuch: Promise<unknown>): Promise<Error> {
const fehler = await versuch.then(
() => undefined,
(f: unknown) => f,
);
expect(fehler).toBeInstanceOf(Error);
return fehler as Error;
}
describe("Widerruf einleiten", () => {
it("merkt alle Verwendungen zum Entfernen vor und laesst den Status noch AKTIV", async () => {
const s = await schluessel("Backup");
const { oeffentlich, privat } = await ausgerollt(s.id);
// Nie ausgerollt: auf dem Host steht nichts, die Verwendung ist sofort erledigt.
const geplant = await prisma.publicVerwendung.create({
data: { schluesselId: s.id, hostId: a.id, zielbenutzer: "root" },
});
await widerrufEinleiten([s.id], admin, { privatErlaubt: true });
const nachher = await prisma.schluessel.findUniqueOrThrow({
where: { id: s.id },
select: { status: true, widerrufEingeleitetAm: true },
});
expect(nachher.status).toBe("AKTIV");
expect(nachher.widerrufEingeleitetAm).not.toBeNull();
expect((await prisma.publicVerwendung.findUniqueOrThrow({ where: { id: oeffentlich.id } })).zustand).toBe(
"ENTFERNEN",
);
expect((await prisma.publicVerwendung.findUniqueOrThrow({ where: { id: geplant.id } })).zustand).toBe(
"ENTFERNT",
);
expect((await prisma.privateVerwendung.findUniqueOrThrow({ where: { id: privat.id } })).zustand).toBe(
"ENTFERNEN",
);
const eintrag = await prisma.protokoll.findFirstOrThrow({
where: { aktion: "schluessel.widerruf.eingeleitet" },
});
expect(eintrag.fingerprint).toBe(s.fingerprint);
expect(eintrag.akteurName).toBe("Ada Admin");
expect(eintrag.details).toMatchObject({ public: 2, privat: 1, ohneHostZugriff: 1 });
expect(await datenbankAlsText()).not.toContain("PRIVATE KEY");
});
it("ist idempotent und sperrt neue Zuweisungen", async () => {
const s = await schluessel("Backup");
await ausgerollt(s.id);
await widerrufEinleiten([s.id], admin, { privatErlaubt: true });
const erstes = (await prisma.schluessel.findUniqueOrThrow({ where: { id: s.id } })).widerrufEingeleitetAm;
await widerrufEinleiten([s.id], admin, { privatErlaubt: true });
expect(
(await prisma.schluessel.findUniqueOrThrow({ where: { id: s.id } })).widerrufEingeleitetAm,
).toEqual(erstes);
const fehler = await fehlerVon(
publicVerwendungAnlegen({ schluesselId: s.id, hostId: a.id, zielbenutzer: "backup" }, admin),
);
expect(fehler).toBeInstanceOf(VerwendungsFehler);
expect(fehler.message).toMatch(/widerrufen/);
});
it("verlangt fuer Private Keys das Admin-Recht", async () => {
const s = await schluessel("Backup");
await ausgerollt(s.id);
const fehler = await fehlerVon(widerrufEinleiten([s.id], admin, { privatErlaubt: false }));
expect(fehler).toBeInstanceOf(WiderrufFehler);
expect(fehler.message).toMatch(/nur ein Admin/);
// Nichts vorgemerkt.
expect(await prisma.publicVerwendung.count({ where: { zustand: "ENTFERNEN" } })).toBe(0);
// Ein reiner Public-Key-Widerruf geht auch ohne.
const person = await schluessel("Laptop Alice", { kategorie: "PERSON" });
await prisma.publicVerwendung.create({
data: { schluesselId: person.id, hostId: b.id, zielbenutzer: "alice", zustand: "AUSGEROLLT" },
});
await widerrufEinleiten([person.id], admin, { privatErlaubt: false });
});
it("weist den Deployment-Key und bereits widerrufene Schluessel ab", async () => {
const deployment = await schluessel("Deployment", { kategorie: "DEPLOYMENT" });
expect(
(await fehlerVon(widerrufEinleiten([deployment.id], admin, { privatErlaubt: true }))).message,
).toMatch(/Deployment-Key/);
const alt = await schluessel("Alt");
await prisma.schluessel.update({ where: { id: alt.id }, data: { status: "WIDERRUFEN" } });
expect((await fehlerVon(widerrufEinleiten([alt.id], admin, { privatErlaubt: true }))).message).toMatch(
/bereits widerrufen/,
);
});
it("erledigt Verwendungen auf archivierten Hosts sofort", async () => {
const s = await schluessel("Backup");
const v = await prisma.publicVerwendung.create({
data: { schluesselId: s.id, hostId: b.id, zielbenutzer: "deploy", zustand: "AUSGEROLLT" },
});
await prisma.host.update({ where: { id: b.id }, data: { archiviertAm: new Date() } });
await widerrufEinleiten([s.id], admin, { privatErlaubt: true });
expect((await prisma.publicVerwendung.findUniqueOrThrow({ where: { id: v.id } })).zustand).toBe(
"ENTFERNT",
);
const eintrag = await prisma.protokoll.findFirstOrThrow({
where: { aktion: "schluessel.widerruf.eingeleitet" },
});
expect(eintrag.details).toMatchObject({ archivierteHosts: ["srv-b"] });
});
});
describe("Dry-Run fuer den Widerruf", () => {
it("nimmt die zum Entfernen vorgemerkten Ziele mehrerer Schluessel in einen Trockenlauf", async () => {
const eins = await schluessel("Eins");
const zwei = await schluessel("Zwei");
await ausgerollt(eins.id);
await prisma.publicVerwendung.create({
data: { schluesselId: zwei.id, hostId: b.id, zielbenutzer: "deploy", zustand: "AUSGEROLLT" },
});
await widerrufEinleiten([eins.id, zwei.id], admin, { privatErlaubt: true });
const id = await trockenlaufStarten({ schluesselIds: [eins.id, zwei.id] }, admin, true);
const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id } });
expect(vorgang.art).toBe("TROCKENLAUF");
expect(vorgang.schluesselId).toBeNull();
const ziele = (vorgang.eingabe as { ziele: { hostId: string; zielbenutzer: string; art?: string }[] })
.ziele;
// Beide Schluessel auf B/deploy ergeben ein Ziel, dazu der Private Key auf A.
expect(ziele).toHaveLength(2);
expect(ziele).toContainEqual({ hostId: b.id, zielbenutzer: "deploy" });
expect(ziele).toContainEqual({ hostId: a.id, zielbenutzer: "root", art: "privat" });
});
});
describe("Widerruf abschliessen", () => {
it("bleibt im Zwischenzustand, solange eine Entfernung offen ist", async () => {
const s = await schluessel("Backup");
const { oeffentlich, privat } = await ausgerollt(s.id);
await widerrufEinleiten([s.id], admin, { privatErlaubt: true });
// B war nicht erreichbar: der Private Key auf A ist weg, der Public Key auf B noch nicht.
await prisma.privateVerwendung.update({ where: { id: privat.id }, data: { zustand: "ENTFERNT" } });
const [ergebnis] = await widerrufAbschliessen([s.id], admin);
expect(ergebnis?.status).toBe("OFFEN");
expect(ergebnis?.offen.map((o) => o.ziel)).toEqual(["deploy@srv-b:~/.ssh/authorized_keys"]);
expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: s.id } })).status).toBe("AKTIV");
expect((await widerrufUebersicht([s.id])).offen).toHaveLength(1);
await prisma.publicVerwendung.update({ where: { id: oeffentlich.id }, data: { zustand: "ENTFERNT" } });
const [fertig] = await widerrufAbschliessen([s.id], admin);
expect(fertig?.status).toBe("WIDERRUFEN");
expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: s.id } })).status).toBe("WIDERRUFEN");
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "schluessel.widerrufen" } });
expect(eintrag.fingerprint).toBe(s.fingerprint);
// Ein zweiter Abschluss aendert nichts und protokolliert nicht doppelt.
await widerrufAbschliessen([s.id], admin);
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.widerrufen" } })).toBe(1);
});
it("schliesst ohne eingeleiteten Widerruf nicht ab", async () => {
const s = await schluessel("Backup");
expect((await fehlerVon(widerrufAbschliessen([s.id], admin))).message).toMatch(
/kein Widerruf eingeleitet/,
);
});
it("setzt ROTIERT, wenn ein aktiver Nachfolger existiert (#023)", async () => {
const alt = await schluessel("Alt");
const neu = await schluessel("Neu");
await prisma.schluessel.update({ where: { id: neu.id }, data: { vorgaengerId: alt.id } });
await widerrufEinleiten([alt.id], admin, { privatErlaubt: true });
const [ergebnis] = await widerrufAbschliessen([alt.id], admin);
expect(ergebnis?.status).toBe("ROTIERT");
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.rotiert" } })).toBe(1);
});
});
describe("Zielkonten beim Widerruf", () => {
it("bietet nur von sshkv angelegte Konten an und nennt weitere Nutzer", async () => {
const s = await schluessel("Deploy A");
const andere = await schluessel("Deploy C");
await ausgerollt(s.id);
const konto = await prisma.zielkonto.create({ data: { hostId: b.id, benutzername: "deploy" } });
// Ein vorhandenes, nicht von sshkv angelegtes Konto taucht nicht auf.
await prisma.zielkonto.create({ data: { hostId: a.id, benutzername: "root2", vonSshkvAngelegt: false } });
expect(await zielkontoKandidaten([s.id])).toEqual([
expect.objectContaining({
id: konto.id,
benutzername: "deploy",
hostName: "srv-b",
nochGenutztVon: [],
}),
]);
await prisma.publicVerwendung.create({
data: { schluesselId: andere.id, hostId: b.id, zielbenutzer: "deploy", zustand: "AUSGEROLLT" },
});
expect((await zielkontoKandidaten([s.id]))[0]?.nochGenutztVon).toEqual(["Deploy C"]);
// Beide gemeinsam widerrufen: niemand nutzt das Konto weiter.
expect((await zielkontoKandidaten([s.id, andere.id]))[0]?.nochGenutztVon).toEqual([]);
});
it("entfernt Zielkonten erst nach den Schluesseln und nie ein noch genutztes", async () => {
const s = await schluessel("Deploy A");
const { oeffentlich, privat } = await ausgerollt(s.id);
const konto = await prisma.zielkonto.create({ data: { hostId: b.id, benutzername: "deploy" } });
await widerrufEinleiten([s.id], admin, { privatErlaubt: true });
expect((await fehlerVon(widerrufZielkontenEntfernen([s.id], [konto.id], admin))).message).toMatch(
/erst entfernt, wenn die Schlüssel/,
);
await prisma.publicVerwendung.update({ where: { id: oeffentlich.id }, data: { zustand: "ENTFERNT" } });
await prisma.privateVerwendung.update({ where: { id: privat.id }, data: { zustand: "ENTFERNT" } });
const andere = await schluessel("Deploy C");
const fremd = await prisma.publicVerwendung.create({
data: { schluesselId: andere.id, hostId: b.id, zielbenutzer: "deploy", zustand: "AUSGEROLLT" },
});
expect((await fehlerVon(widerrufZielkontenEntfernen([s.id], [konto.id], admin))).message).toMatch(
/noch von „Deploy C“ genutzt/,
);
await prisma.publicVerwendung.delete({ where: { id: fremd.id } });
const vorgaenge = await widerrufZielkontenEntfernen([s.id], [konto.id], admin);
expect(vorgaenge).toEqual([{ id: expect.any(String), beschriftung: "deploy@srv-b" }]);
const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgaenge[0]!.id } });
expect(vorgang).toMatchObject({
art: "ZIELKONTO_ENTFERNEN",
hostId: b.id,
eingabe: { benutzer: "deploy" },
});
});
});
describe("Rechte und Ablauf", () => {
it("Massenwiderruf und Zielkonten nur fuer Admins mit frischer Bestaetigung", () => {
expect(RECHTE.massenwiderruf).toEqual(["ADMIN"]);
expect(BESTAETIGUNG_NOETIG.has("massenwiderruf")).toBe(true);
expect(BESTAETIGUNG_NOETIG.has("zielkontenVerwalten")).toBe(true);
});
it("zeigt aktive Schluessel mit vergangenem Ablaufdatum unter „abgelaufen“", async () => {
const s = await schluessel("Projekt X");
const frisch = await schluessel("Projekt Y");
await prisma.schluessel.update({
where: { id: s.id },
data: { ablaufAm: new Date("2020-01-01T00:00:00Z") },
});
await prisma.schluessel.update({
where: { id: frisch.id },
data: { ablaufAm: new Date("2999-01-01T00:00:00Z") },
});
expect((await schluesselListe({ status: "ABGELAUFEN" })).map((k) => k.name)).toEqual(["Projekt X"]);
expect((await schluesselListe({ status: "AKTIV" })).map((k) => k.name)).toEqual(["Projekt Y"]);
// Automatisch entfernt wird nichts: der Status in der Datenbank bleibt AKTIV.
expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: s.id } })).status).toBe("AKTIV");
});
});