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): Promise { 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"); }); });