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>
This commit is contained in:
2026-10-12 13:14:01 +02:00
co-authored by Claude Opus 5.5
parent 2dad8e5631
commit c6361ff3c6
26 changed files with 2334 additions and 91 deletions
+90
View File
@@ -0,0 +1,90 @@
import { execFileSync } from "node:child_process";
import { expect, type Page } from "@playwright/test";
import "dotenv/config";
/**
* Gemeinsame Schritte fuer Widerruf (#024) und Rotation (#023): anmelden, Hosts einrichten und
* der Kern-Anwendungsfall aus kern-anwendungsfall.spec.ts als Ausgangslage.
*/
const ROOT_PASSWORT = process.env["SSHKV_TEST_ROOT_PASSWORT"] || "sshkv-test";
export const PORT = {
a: (process.env["SSHKV_TEST_ZIEL_A"] ?? "127.0.0.1:2221").split(":")[1] ?? "2221",
b: (process.env["SSHKV_TEST_ZIEL_B"] ?? "127.0.0.1:2222").split(":")[1] ?? "2222",
};
export function aufZiel(ziel: "ziel-a" | "ziel-b", befehl: string): string {
return execFileSync(
"docker",
["compose", "-f", "docker-compose.test.yml", "exec", "-T", ziel, "sh", "-c", befehl],
{ encoding: "utf8" },
);
}
export function zieleZuruecksetzen(): void {
const reset =
"userdel -r sshkv 2>/dev/null; userdel -r deploy 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true";
aufZiel("ziel-a", reset);
aufZiel("ziel-b", reset);
}
export async function anmelden(page: Page): Promise<void> {
await page.goto("/anmelden");
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
await page.locator('button[data-rolle="admin"]').click();
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
}
export async function hostEinrichten(page: Page, name: string, port: string): Promise<void> {
await page.goto("/hosts/neu");
await page.getByLabel("Name").fill(name);
await page.getByLabel("Adresse").fill("127.0.0.1");
await page.getByLabel("Port").fill(port);
await page.getByRole("button", { name: "Host anlegen" }).click();
await page.getByRole("button", { name: "Host-Key abrufen" }).click();
await page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" }).click({ timeout: 30_000 });
await page.getByLabel("Passwort", { exact: true }).fill(ROOT_PASSWORT);
await page.getByRole("button", { name: "Zugang einrichten" }).click();
await expect(page.getByRole("button", { name: "Verbindung prüfen" })).toBeVisible({ timeout: 60_000 });
}
export async function archivieren(page: Page, name: string): Promise<void> {
await page.goto("/hosts");
await page.getByRole("link", { name }).first().click();
await page.getByRole("button", { name: "Host archivieren" }).click();
await page.getByRole("dialog").getByRole("button", { name: "Archivieren", exact: true }).click();
await expect(page).toHaveURL(/\/hosts$/);
}
/**
* Kern-Anwendungsfall: Schluessel erzeugen, Private Key auf A (Alias zu-b), Public Key auf B,
* Dry-Run, anwenden. Endet auf der Schluesselseite; liefert deren Adresse.
*/
export async function kernAufbauen(page: Page, zweck: string, hostA: string, hostB: string): Promise<string> {
await page.goto("/schluessel/neu");
await page.getByLabel("Zweck").fill(zweck);
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
await expect(page.getByRole("heading", { name: zweck })).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: "Ausrollen" }).first().click();
const privat = page.getByRole("dialog", { name: "Private Key ausrollen" });
await privat.getByLabel("Quell-Host (bekommt den Private Key)").selectOption({ label: hostA });
await privat.getByLabel("Ziel-Host (für known_hosts)").selectOption({ label: hostB });
await privat.getByRole("textbox", { name: "Alias" }).fill("zu-b");
await privat.getByRole("textbox", { name: "HostName" }).fill("ziel-b");
await privat.getByRole("spinbutton", { name: "Port" }).fill("22");
await privat.getByRole("button", { name: "Zuweisen" }).click();
await expect(privat).toBeHidden();
await page.getByRole("button", { name: "Ausrollen" }).nth(1).click();
const oeffentlich = page.getByRole("dialog", { name: "Public Key zuweisen" });
await oeffentlich.getByLabel("Ziel", { exact: true }).selectOption({ label: hostB });
await oeffentlich.getByRole("button", { name: "Zuweisen" }).click();
await expect(oeffentlich).toBeHidden();
await page.getByRole("button", { name: "Dry-Run starten" }).click();
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
await expect(page.getByText("Ausgerollt.")).toBeVisible({ timeout: 60_000 });
expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root");
return page.url();
}
+61
View File
@@ -0,0 +1,61 @@
import { expect, test } from "@playwright/test";
import {
anmelden,
archivieren,
aufZiel,
hostEinrichten,
kernAufbauen,
PORT,
zieleZuruecksetzen,
} from "./hilfen";
/**
* Widerruf im Browser (#024), gebauter Stand + Worker: Ausgangslage ist der Kern-Anwendungsfall
* (Private Key auf A, Public Key auf B). „Widerrufen“ → Dialog → Dry-Run → anwenden → Status
* „widerrufen“; danach kommt A nicht mehr nach B.
*/
test.setTimeout(240_000);
test.beforeAll(() => zieleZuruecksetzen());
test("Widerruf mit einem Klick", async ({ page }) => {
const kennung = Date.now().toString(36);
const hostA = `zz-e2e-a-${kennung}`;
const hostB = `zz-e2e-b-${kennung}`;
const zweck = `zz-e2e Widerruf ${kennung}`;
await anmelden(page);
await hostEinrichten(page, hostA, PORT.a);
await hostEinrichten(page, hostB, PORT.b);
await kernAufbauen(page, zweck, hostA, hostB);
await page.reload();
// Dialog: zeigt genau, was passiert.
await page.getByRole("button", { name: "Widerrufen" }).click();
const dialog = page.getByRole("dialog", { name: `„${zweck}“ widerrufen?` });
await expect(dialog.getByText(`root@${hostB}`)).toBeVisible();
await expect(dialog.getByText(`root@${hostA}:~/.ssh/`)).toBeVisible();
await expect(dialog.getByText(/config-Eintrag „Host zu-b“/)).toBeVisible();
await page.screenshot({ path: "docs/anleitung/bilder/widerruf-desktop.png" });
await dialog.getByRole("button", { name: "Widerruf vorbereiten" }).click();
// Zwischenzustand: noch nicht widerrufen, nichts mehr zuweisbar.
await expect(page.getByRole("heading", { name: "Widerruf läuft" })).toBeVisible();
await expect(page.getByText("Widerruf unvollständig")).toBeVisible();
await expect(page.getByRole("button", { name: "Ausrollen" })).toHaveCount(0);
await page.getByRole("button", { name: "Dry-Run starten" }).click();
await expect(page.getByText(`root@${hostB}:/root/.ssh/authorized_keys`)).toBeVisible({ timeout: 30_000 });
await page.screenshot({ path: "docs/anleitung/bilder/widerruf-dry-run-desktop.png", fullPage: true });
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
await expect(page.getByRole("heading", { name: "Widerrufen", exact: true })).toBeVisible({
timeout: 60_000,
});
await expect(page.getByText("Widerruf unvollständig")).toHaveCount(0);
expect(aufZiel("ziel-a", "ls /root/.ssh")).not.toContain("id_ed25519");
expect(() => aufZiel("ziel-a", "ssh -o BatchMode=yes -o ConnectTimeout=5 zu-b true")).toThrow();
await archivieren(page, hostA);
await archivieren(page, hostB);
});
+171
View File
@@ -0,0 +1,171 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { zieleVonPrivatVerwendungen, zieleVonVerwendungen } from "@/server/services/soll";
import { privateVerwendungAnlegen, publicVerwendungAnlegen } from "@/server/services/verwendungen";
import {
widerrufAbschliessen,
widerrufEinleiten,
widerrufZielkontenEntfernen,
} from "@/server/services/widerruf";
import type { Plan } from "@/worker/ablaeufe/ausrollen";
import { vorgangAusfuehren } from "@/worker/ausfuehren";
import { deploymentKeySicherstellen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse";
import { HANDLER } from "@/worker/handler";
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";
/**
* Widerruf gegen echte Hosts (#024): Public Key aus authorized_keys von B, Private Key samt
* config- und known_hosts-Eintrag von A, optional das Zielkonto deploy auf B. Danach scheitert
* `ssh <alias>` von A. Scheitert ein Host, bleibt der Schluessel im Zwischenzustand.
*/
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; userdel -r deploy 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true";
await Promise.all([befehlAlsRoot(zielA, reset), befehlAlsRoot(zielB, reset)]);
const admin = await prisma.benutzer.create({ data: { name: "Test-Admin", rolle: "ADMIN" } });
AKTEUR.id = admin.id;
});
async function dienstSchluessel(name: string) {
const neu = await schluesselpaarVersiegeltErzeugen("ED25519", `sshkv:${name}`);
return prisma.schluessel.create({
data: {
id: neu.id,
name,
slug: name,
typ: "ED25519",
kategorie: "DIENST",
publicKey: neu.publicKeyZeile,
fingerprint: neu.fingerprint,
kommentar: `sshkv:${name}`,
...neu.versiegelt,
},
select: { id: true, fingerprint: true, publicKey: true },
});
}
async function trockenlaufUndAusrollen(schluesselId: string) {
const ziele = [
...(await zieleVonVerwendungen({ schluesselId })),
...(await zieleVonPrivatVerwendungen({ schluesselId })),
];
const trockenlauf = await vorgangLaufen("TROCKENLAUF", { eingabe: { ziele }, angefordertVonId: AKTEUR.id });
expect(trockenlauf.status).toBe("ERLEDIGT");
const ausrollen = await vorgangLaufen("AUSROLLEN", {
eingabe: { trockenlaufId: trockenlauf.id },
angefordertVonId: AKTEUR.id,
});
return { plan: trockenlauf.plan as unknown as Plan, ausrollen };
}
/** Deployment-Zugang wie im Assistenten: deploy auf B, Private Key auf A, Alias zu-b. */
async function deploymentZugang() {
const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b");
const konto = await vorgangLaufen("ZIELKONTO_ANLEGEN", {
hostId: b.id,
eingabe: { benutzer: "deploy", modus: "KEINER", befehle: [] },
});
expect(konto.fehler).toBeNull();
const schluessel = await dienstSchluessel("deploy-a-b");
await privateVerwendungAnlegen(
{
schluesselId: schluessel.id,
hostId: a.id,
zielbenutzer: "root",
pfad: "~/.ssh/id_ed25519_deploy",
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "deploy", port: 22 },
knownHostsFuer: [b.id],
},
AKTEUR,
);
await publicVerwendungAnlegen(
{ schluesselId: schluessel.id, hostId: b.id, zielbenutzer: "deploy" },
AKTEUR,
);
const { ausrollen } = await trockenlaufUndAusrollen(schluessel.id);
expect(ausrollen.fehler).toBeNull();
const vorher = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
expect(vorher.stdout.trim()).toBe("deploy");
return { a, b, schluessel };
}
describe("Widerruf", () => {
it("entfernt Public Key, Private Key, config, known_hosts und auf Wunsch das Zielkonto", async () => {
const { schluessel } = await deploymentZugang();
const blob = schluessel.publicKey.split(" ")[1]!;
await widerrufEinleiten([schluessel.id], AKTEUR, { privatErlaubt: true });
const { plan, ausrollen } = await trockenlaufUndAusrollen(schluessel.id);
expect(plan.ziele.every((z) => !z.fehler)).toBe(true);
expect(ausrollen.fehler).toBeNull();
expect((await befehlAlsRoot(zielB, "cat /home/deploy/.ssh/authorized_keys")).stdout).not.toContain(blob);
const ssh = await befehlAlsRoot(zielA, "ls -a /root/.ssh; cat /root/.ssh/config /root/.ssh/known_hosts");
expect(ssh.stdout).not.toContain("id_ed25519_deploy");
expect(ssh.stdout).not.toContain("zu-b");
expect(ssh.stdout).not.toContain("ssh-ed25519");
const danach = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o ConnectTimeout=5 zu-b id -un");
expect(danach.code).not.toBe(0);
// Zielkonto: erst nach den Schluesseln, dann Abschluss.
const konto = await prisma.zielkonto.findFirstOrThrow({ where: { benutzername: "deploy" } });
const vorgaenge = await widerrufZielkontenEntfernen([schluessel.id], [konto.id], AKTEUR);
for (const v of vorgaenge) await vorgangAusfuehren(v.id, HANDLER);
expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgaenge[0]!.id } })).fehler).toBeNull();
expect((await befehlAlsRoot(zielB, "id deploy")).code).not.toBe(0);
const [ergebnis] = await widerrufAbschliessen([schluessel.id], AKTEUR);
expect(ergebnis?.status).toBe("WIDERRUFEN");
const protokoll = await prisma.protokoll.findMany({ where: { fingerprint: schluessel.fingerprint } });
expect(protokoll.map((p) => p.aktion)).toEqual(
expect.arrayContaining(["schluessel.widerruf.eingeleitet", "schluessel.widerrufen"]),
);
expect(JSON.stringify(protokoll.map((p) => [p.details, p.diff]))).not.toContain("PRIVATE KEY");
});
it("bleibt im Zwischenzustand, wenn ein Host nicht erreichbar ist", async () => {
const { b, schluessel } = await deploymentZugang();
await widerrufEinleiten([schluessel.id], AKTEUR, { privatErlaubt: true });
// B „faellt aus“: ein Port, auf dem niemand lauscht.
await prisma.host.update({ where: { id: b.id }, data: { port: 1 } });
const { plan } = await trockenlaufUndAusrollen(schluessel.id);
expect(plan.ziele.find((z) => z.hostName === "ziel-b")?.fehler).toBeTruthy();
expect(plan.ziele.find((z) => z.hostName === "ziel-a")?.fehler).toBeUndefined();
const [offen] = await widerrufAbschliessen([schluessel.id], AKTEUR);
expect(offen?.status).toBe("OFFEN");
expect(offen?.offen.map((o) => o.ziel)).toEqual(["deploy@ziel-b:~/.ssh/authorized_keys"]);
expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: schluessel.id } })).status).toBe(
"AKTIV",
);
// Was ging, ist trotzdem weg: der Private Key auf A.
expect((await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout).not.toContain("id_ed25519_deploy");
// B ist zurueck: ein neuer Dry-Run erledigt den Rest.
await prisma.host.update({ where: { id: b.id }, data: { port: zielB.port } });
const { ausrollen } = await trockenlaufUndAusrollen(schluessel.id);
expect(ausrollen.fehler).toBeNull();
const [fertig] = await widerrufAbschliessen([schluessel.id], AKTEUR);
expect(fertig?.status).toBe("WIDERRUFEN");
});
});
+346
View File
@@ -0,0 +1,346 @@
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");
});
});