Merge branch 'phase/023-024' (Rotation, Widerruf)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
anmelden,
|
||||
archivieren,
|
||||
aufZiel,
|
||||
hostEinrichten,
|
||||
kernAufbauen,
|
||||
PORT,
|
||||
zieleZuruecksetzen,
|
||||
} from "./hilfen";
|
||||
|
||||
/**
|
||||
* Gefuehrte Rotation im Browser (#023), gebauter Stand + Worker: Ausgangslage ist der
|
||||
* Kern-Anwendungsfall. Neuer Schluessel → Zuweisungen uebernehmen → ausrollen → Verbindung
|
||||
* testen → alten widerrufen. `ssh zu-b` von A funktioniert vorher und nachher.
|
||||
*/
|
||||
test.setTimeout(300_000);
|
||||
|
||||
test.beforeAll(() => zieleZuruecksetzen());
|
||||
|
||||
test("Rotation mit Schrittanzeige", async ({ page }) => {
|
||||
const kennung = Date.now().toString(36);
|
||||
const hostA = `zz-e2e-a-${kennung}`;
|
||||
const hostB = `zz-e2e-b-${kennung}`;
|
||||
const zweck = `zz-e2e Rotation ${kennung}`;
|
||||
|
||||
await anmelden(page);
|
||||
await hostEinrichten(page, hostA, PORT.a);
|
||||
await hostEinrichten(page, hostB, PORT.b);
|
||||
await kernAufbauen(page, zweck, hostA, hostB);
|
||||
const altDateien = aufZiel("ziel-a", "ls /root/.ssh");
|
||||
await page.reload();
|
||||
|
||||
await page.getByRole("link", { name: "Rotieren" }).click();
|
||||
await expect(page.getByRole("heading", { name: `„${zweck}“ rotieren` })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Neuen Schlüssel erzeugen und Zuweisungen übernehmen" }).click();
|
||||
|
||||
// Schritt 3: neuen Schluessel ausrollen.
|
||||
await expect(page.getByRole("button", { name: "Dry-Run starten" })).toBeVisible({ timeout: 60_000 });
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||
// Derselbe Alias, neue Datei: nur die IdentityFile-Zeile aendert sich.
|
||||
await expect(page.getByText(/IdentityFile ~\/\.ssh\/.*-r\d{8}$/)).toBeVisible({ timeout: 30_000 });
|
||||
await page.screenshot({ path: "docs/anleitung/bilder/rotation-desktop.png", fullPage: true });
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
|
||||
|
||||
// Schritt 4: Verbindungstest mit dem neuen Schluessel.
|
||||
await page.getByRole("button", { name: "Verbindungen testen" }).click({ timeout: 60_000 });
|
||||
|
||||
// Schritt 5: alten Schluessel widerrufen.
|
||||
await page.getByRole("button", { name: "Alten Schlüssel widerrufen" }).click({ timeout: 60_000 });
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click({ timeout: 30_000 });
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
|
||||
await expect(page.getByRole("heading", { name: "Rotation abgeschlossen" })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root");
|
||||
const neueDateien = aufZiel("ziel-a", "ls /root/.ssh");
|
||||
const alterKey = altDateien.split("\n").find((d) => d.startsWith("id_ed25519") && !d.endsWith(".pub"))!;
|
||||
expect(neueDateien.split("\n")).not.toContain(alterKey);
|
||||
expect(neueDateien).toMatch(/-r\d{8}\n/);
|
||||
|
||||
// Kette auf der Seite des neuen Schluessels.
|
||||
await page.getByRole("link", { name: /\(rotiert / }).click();
|
||||
const kette = page.getByRole("list", { name: "Rotationskette" });
|
||||
await expect(kette.getByRole("link", { name: zweck })).toBeVisible();
|
||||
await expect(kette.getByText("rotiert", { exact: true })).toBeVisible();
|
||||
|
||||
await archivieren(page, hostA);
|
||||
await archivieren(page, hostB);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { rotationsDatum } from "@/lib/rotation";
|
||||
import {
|
||||
rotationAltWiderrufen,
|
||||
rotationStarten,
|
||||
rotationUebernehmen,
|
||||
rotationsStand,
|
||||
} from "@/server/services/rotation";
|
||||
import { zieleVonPrivatVerwendungen, zieleVonVerwendungen } from "@/server/services/soll";
|
||||
import { privateVerwendungAnlegen, publicVerwendungAnlegen } from "@/server/services/verwendungen";
|
||||
import { widerrufAbschliessen } from "@/server/services/widerruf";
|
||||
import { deploymentKeySicherstellen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse";
|
||||
import { datenbankLeeren } from "../hilfen/db";
|
||||
import { vorgangImWorker, warteschlangeStoppen } from "../hilfen/vorgang";
|
||||
import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host";
|
||||
import { befehlAlsRoot, zielA, zielB, zielBereit } from "./hilfen/ziele";
|
||||
|
||||
/**
|
||||
* Rotation gegen echte Hosts (#023): der neue Schluessel funktioniert (Verbindungstest gruen),
|
||||
* der alte ist danach von beiden Hosts verschwunden, und `ssh <alias>` von A geht weiter –
|
||||
* derselbe Alias zeigt jetzt auf die neue Datei.
|
||||
*/
|
||||
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;
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
expect(ausrollen.fehler).toBeNull();
|
||||
}
|
||||
|
||||
describe("Rotation", () => {
|
||||
it("tauscht den Schluessel aus, ohne dass der Alias aufhoert zu funktionieren", async () => {
|
||||
const a = await eingerichteterHost(zielA, "ziel-a");
|
||||
const b = await eingerichteterHost(zielB, "ziel-b");
|
||||
const paar = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:backup");
|
||||
const alt = await prisma.schluessel.create({
|
||||
data: {
|
||||
id: paar.id,
|
||||
name: "Backup",
|
||||
slug: "backup",
|
||||
typ: "ED25519",
|
||||
kategorie: "DIENST",
|
||||
publicKey: paar.publicKeyZeile,
|
||||
fingerprint: paar.fingerprint,
|
||||
kommentar: "sshkv:backup",
|
||||
...paar.versiegelt,
|
||||
},
|
||||
select: { id: true, publicKey: true },
|
||||
});
|
||||
await privateVerwendungAnlegen(
|
||||
{
|
||||
schluesselId: alt.id,
|
||||
hostId: a.id,
|
||||
zielbenutzer: "root",
|
||||
pfad: "~/.ssh/id_backup",
|
||||
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 },
|
||||
knownHostsFuer: [b.id],
|
||||
},
|
||||
AKTEUR,
|
||||
);
|
||||
await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: b.id, zielbenutzer: "root" }, AKTEUR);
|
||||
await trockenlaufUndAusrollen(alt.id);
|
||||
|
||||
// 1–2: Nachfolger erzeugen, Zuweisungen uebernehmen.
|
||||
await vorgangImWorker(await rotationStarten(alt.id, AKTEUR));
|
||||
await rotationUebernehmen(alt.id, AKTEUR, { privatErlaubt: true });
|
||||
const neu = await prisma.schluessel.findFirstOrThrow({
|
||||
where: { vorgaengerId: alt.id },
|
||||
select: { id: true, publicKey: true },
|
||||
});
|
||||
|
||||
// 3: ausrollen – beide Schluessel liegen jetzt nebeneinander, der Alias zeigt auf den neuen.
|
||||
await trockenlaufUndAusrollen(neu.id);
|
||||
const neuerPfad = `id_backup-r${rotationsDatum().kompakt}`;
|
||||
const config = (await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout;
|
||||
expect(config.match(/^Host zu-b$/gm)).toHaveLength(1);
|
||||
expect(config).toContain(`IdentityFile ~/.ssh/${neuerPfad}`);
|
||||
|
||||
// 4: Verbindungstest mit dem neuen Schluessel.
|
||||
const verwendung = await prisma.privateVerwendung.findFirstOrThrow({ where: { schluesselId: neu.id } });
|
||||
const test = await vorgangLaufen("VERBINDUNGSTEST", { eingabe: { privatVerwendungId: verwendung.id } });
|
||||
expect(test.fehler).toBeNull();
|
||||
expect((await rotationsStand(alt.id)).schritt).toBe(4);
|
||||
|
||||
// 5: alten widerrufen.
|
||||
await rotationAltWiderrufen(alt.id, AKTEUR, { privatErlaubt: true });
|
||||
await trockenlaufUndAusrollen(alt.id);
|
||||
expect((await widerrufAbschliessen([alt.id], AKTEUR))[0]?.status).toBe("ROTIERT");
|
||||
|
||||
const authorized = (await befehlAlsRoot(zielB, "cat /root/.ssh/authorized_keys")).stdout;
|
||||
expect(authorized).not.toContain(alt.publicKey.split(" ")[1]!);
|
||||
expect(authorized).toContain(neu.publicKey.split(" ")[1]!);
|
||||
const dateien = (await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout.split("\n");
|
||||
expect(dateien).not.toContain("id_backup");
|
||||
expect(dateien).toContain(neuerPfad);
|
||||
const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
|
||||
expect(ssh.stdout.trim()).toBe("root");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation";
|
||||
import { rotationsKette } from "@/server/queries/schluessel";
|
||||
import {
|
||||
RotationsFehler,
|
||||
rotationAltWiderrufen,
|
||||
rotationGesperrt,
|
||||
rotationStarten,
|
||||
rotationUebernehmen,
|
||||
rotationsStand,
|
||||
} from "@/server/services/rotation";
|
||||
import { widerrufAbschliessen } from "@/server/services/widerruf";
|
||||
import { masterKeyVergessen } from "@/worker/geheimnisse";
|
||||
import { datenbankLeeren } from "./hilfen/db";
|
||||
import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang";
|
||||
import { ed25519Blob, zeile } from "./ssh/hilfen";
|
||||
|
||||
/** Gefuehrte Rotation (#023): Regeln, Schritte, Rechte, Protokoll. */
|
||||
|
||||
let verzeichnis: string;
|
||||
let admin: { id: string; name: string };
|
||||
let a: { id: string };
|
||||
let b: { id: string };
|
||||
|
||||
beforeAll(() => {
|
||||
verzeichnis = mkdtempSync(path.join(tmpdir(), "sshkv-master-"));
|
||||
const datei = path.join(verzeichnis, "master.key");
|
||||
writeFileSync(datei, randomBytes(32), { mode: 0o600 });
|
||||
process.env["SSHKV_MASTER_KEY_FILE"] = datei;
|
||||
masterKeyVergessen();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
rmSync(verzeichnis, { recursive: true, force: true });
|
||||
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 } });
|
||||
b = await prisma.host.create({ data: { name: "srv-b", adresse: "10.0.0.2" }, select: { id: true } });
|
||||
});
|
||||
|
||||
/** Ein ausgerollter Dienst-Key wie nach dem Assistenten: Private auf A (Alias), Public auf B. */
|
||||
async function alterSchluessel() {
|
||||
const alt = await prisma.schluessel.create({
|
||||
data: {
|
||||
name: "Backup A → B",
|
||||
slug: "backup-a-b",
|
||||
typ: "ED25519",
|
||||
kategorie: "DIENST",
|
||||
publicKey: zeile("ssh-ed25519", ed25519Blob(), "alt"),
|
||||
fingerprint: "SHA256:alt",
|
||||
kommentar: "alt",
|
||||
tags: ["backup"],
|
||||
ablaufAm: new Date("2020-01-01T00:00:00Z"),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const jetzt = new Date();
|
||||
await prisma.publicVerwendung.create({
|
||||
data: {
|
||||
schluesselId: alt.id,
|
||||
hostId: b.id,
|
||||
zielbenutzer: "backup",
|
||||
optionen: 'from="10.0.0.1"',
|
||||
zustand: "AUSGEROLLT",
|
||||
zuletztAngewendetAm: jetzt,
|
||||
},
|
||||
});
|
||||
await prisma.privateVerwendung.create({
|
||||
data: {
|
||||
schluesselId: alt.id,
|
||||
hostId: a.id,
|
||||
zielbenutzer: "root",
|
||||
pfad: "~/.ssh/id_backup",
|
||||
sshConfig: { alias: "zu-b", hostName: "10.0.0.2", benutzer: "backup", port: 22 },
|
||||
knownHostsFuer: [b.id],
|
||||
beimWiderruf: "UMBENENNEN",
|
||||
zustand: "AUSGEROLLT",
|
||||
zuletztAngewendetAm: jetzt,
|
||||
},
|
||||
});
|
||||
return alt.id;
|
||||
}
|
||||
|
||||
async function nachfolgerErzeugen(altId: string): Promise<string> {
|
||||
const vorgangId = await rotationStarten(altId, admin);
|
||||
await vorgangImWorker(vorgangId);
|
||||
const neu = await prisma.schluessel.findFirstOrThrow({
|
||||
where: { vorgaengerId: altId },
|
||||
select: { id: true },
|
||||
});
|
||||
return neu.id;
|
||||
}
|
||||
|
||||
describe("Namen und Pfade", () => {
|
||||
it("ersetzt ein frueheres Rotations-Suffix", () => {
|
||||
expect(rotationsName("Backup", "12.10.2026")).toBe("Backup (rotiert 12.10.2026)");
|
||||
expect(rotationsName("Backup (rotiert 01.01.2026)", "12.10.2026")).toBe("Backup (rotiert 12.10.2026)");
|
||||
expect(rotationsName("x".repeat(200), "12.10.2026", 120)).toHaveLength(120);
|
||||
});
|
||||
|
||||
it("haengt -rJJJJMMTT an und weicht belegten Pfaden aus", () => {
|
||||
expect(rotationsPfad("~/.ssh/id_backup", "20261012", new Set())).toBe("~/.ssh/id_backup-r20261012");
|
||||
expect(rotationsPfad("~/.ssh/id_backup-r20260101", "20261012", new Set())).toBe(
|
||||
"~/.ssh/id_backup-r20261012",
|
||||
);
|
||||
expect(
|
||||
rotationsPfad("~/.ssh/id_backup-r20261012", "20261012", new Set(["~/.ssh/id_backup-r20261012"])),
|
||||
).toBe("~/.ssh/id_backup-r20261012-2");
|
||||
});
|
||||
|
||||
it("rechnet das Datum in Berlin", () => {
|
||||
expect(rotationsDatum(new Date("2026-10-11T23:30:00Z"))).toEqual({
|
||||
anzeige: "12.10.2026",
|
||||
kompakt: "20261012",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Was sich rotieren laesst", () => {
|
||||
const basis = {
|
||||
status: "AKTIV" as const,
|
||||
masterKeyVersion: 1,
|
||||
widerrufEingeleitetAm: null,
|
||||
privatZuweisungen: 0,
|
||||
};
|
||||
it("verlangt bei Personen-Keys einen neuen Public Key von aussen", () => {
|
||||
expect(rotationGesperrt({ ...basis, kategorie: "PERSON", masterKeyVersion: null })).toMatchObject({
|
||||
neuerPublicKeyNoetig: true,
|
||||
});
|
||||
expect(rotationGesperrt({ ...basis, kategorie: "DIENST", masterKeyVersion: null })).toMatchObject({
|
||||
neuerPublicKeyNoetig: true,
|
||||
});
|
||||
// Private Key aus sshkv geloescht, aber ausgerollt: sshkv erzeugt und verteilt den neuen selbst.
|
||||
expect(
|
||||
rotationGesperrt({ ...basis, kategorie: "DIENST", masterKeyVersion: null, privatZuweisungen: 1 }),
|
||||
).toBeNull();
|
||||
expect(rotationGesperrt({ ...basis, kategorie: "DEPLOYMENT" })?.grund).toMatch(/eigene Rotation/);
|
||||
expect(rotationGesperrt({ ...basis, kategorie: "DIENST", status: "WIDERRUFEN" })).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rotation Schritt fuer Schritt", () => {
|
||||
it("fuehrt vom neuen Schluessel bis ROTIERT und haelt die Reihenfolge ein", async () => {
|
||||
const altId = await alterSchluessel();
|
||||
expect((await rotationsStand(altId)).schritt).toBe(0);
|
||||
|
||||
const neuId = await nachfolgerErzeugen(altId);
|
||||
const neu = await prisma.schluessel.findUniqueOrThrow({
|
||||
where: { id: neuId },
|
||||
select: { name: true, typ: true, tags: true, ablaufAm: true, kategorie: true },
|
||||
});
|
||||
expect(neu.name).toBe(`Backup A → B (rotiert ${rotationsDatum().anzeige})`);
|
||||
expect(neu).toMatchObject({ typ: "ED25519", kategorie: "DIENST", tags: ["backup"], ablaufAm: null });
|
||||
expect((await rotationsStand(altId)).schritt).toBe(1);
|
||||
await expect(rotationStarten(altId, admin)).rejects.toThrow(/schon einen Nachfolger/);
|
||||
|
||||
await expect(rotationUebernehmen(altId, admin, { privatErlaubt: false })).rejects.toBeInstanceOf(
|
||||
RotationsFehler,
|
||||
);
|
||||
expect(await rotationUebernehmen(altId, admin, { privatErlaubt: true })).toEqual({
|
||||
public: 1,
|
||||
privat: 1,
|
||||
});
|
||||
expect(await rotationUebernehmen(altId, admin, { privatErlaubt: true })).toEqual({
|
||||
public: 0,
|
||||
privat: 0,
|
||||
});
|
||||
|
||||
const oeffentlich = await prisma.publicVerwendung.findFirstOrThrow({ where: { schluesselId: neuId } });
|
||||
expect(oeffentlich).toMatchObject({ hostId: b.id, zielbenutzer: "backup", optionen: 'from="10.0.0.1"' });
|
||||
const privat = await prisma.privateVerwendung.findFirstOrThrow({ where: { schluesselId: neuId } });
|
||||
expect(privat).toMatchObject({
|
||||
hostId: a.id,
|
||||
zielbenutzer: "root",
|
||||
pfad: `~/.ssh/id_backup-r${rotationsDatum().kompakt}`,
|
||||
sshConfig: { alias: "zu-b", hostName: "10.0.0.2", benutzer: "backup", port: 22 },
|
||||
knownHostsFuer: [b.id],
|
||||
beimWiderruf: "UMBENENNEN",
|
||||
});
|
||||
expect((await rotationsStand(altId)).schritt).toBe(2);
|
||||
await expect(rotationAltWiderrufen(altId, admin, { privatErlaubt: true })).rejects.toThrow(
|
||||
/noch nicht überall/,
|
||||
);
|
||||
|
||||
// Ausgerollt, aber noch nicht getestet.
|
||||
const jetzt = new Date();
|
||||
await prisma.publicVerwendung.updateMany({
|
||||
where: { schluesselId: neuId },
|
||||
data: { zustand: "AUSGEROLLT" },
|
||||
});
|
||||
await prisma.privateVerwendung.updateMany({
|
||||
where: { schluesselId: neuId },
|
||||
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
|
||||
});
|
||||
expect((await rotationsStand(altId)).schritt).toBe(3);
|
||||
await prisma.vorgang.create({
|
||||
data: { art: "VERBINDUNGSTEST", status: "FEHLGESCHLAGEN", eingabe: { privatVerwendungId: privat.id } },
|
||||
});
|
||||
await expect(rotationAltWiderrufen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/grün/);
|
||||
|
||||
await prisma.vorgang.create({
|
||||
data: { art: "VERBINDUNGSTEST", status: "ERLEDIGT", eingabe: { privatVerwendungId: privat.id } },
|
||||
});
|
||||
expect((await rotationsStand(altId)).schritt).toBe(4);
|
||||
await rotationAltWiderrufen(altId, admin, { privatErlaubt: true });
|
||||
expect(
|
||||
await prisma.publicVerwendung.count({ where: { schluesselId: altId, zustand: "ENTFERNEN" } }),
|
||||
).toBe(1);
|
||||
// Der Nachfolger bleibt unberuehrt.
|
||||
expect(
|
||||
await prisma.publicVerwendung.count({ where: { schluesselId: neuId, zustand: "AUSGEROLLT" } }),
|
||||
).toBe(1);
|
||||
|
||||
await prisma.publicVerwendung.updateMany({
|
||||
where: { schluesselId: altId },
|
||||
data: { zustand: "ENTFERNT" },
|
||||
});
|
||||
await prisma.privateVerwendung.updateMany({
|
||||
where: { schluesselId: altId },
|
||||
data: { zustand: "ENTFERNT" },
|
||||
});
|
||||
expect((await widerrufAbschliessen([altId], admin))[0]?.status).toBe("ROTIERT");
|
||||
expect((await rotationsStand(altId)).schritt).toBe(5);
|
||||
|
||||
expect((await rotationsKette(neuId)).map((g) => [g.id, g.status])).toEqual([
|
||||
[altId, "ROTIERT"],
|
||||
[neuId, "AKTIV"],
|
||||
]);
|
||||
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.rotation.uebernommen" } })).toBe(1);
|
||||
expect(await datenbankAlsText()).not.toContain("PRIVATE KEY");
|
||||
});
|
||||
|
||||
it("verweigert Personen-Keys und den Deployment-Key", async () => {
|
||||
const person = await prisma.schluessel.create({
|
||||
data: {
|
||||
name: "Laptop",
|
||||
slug: "laptop",
|
||||
typ: "ED25519",
|
||||
kategorie: "PERSON",
|
||||
publicKey: zeile("ssh-ed25519", ed25519Blob(), "p"),
|
||||
fingerprint: "SHA256:p",
|
||||
kommentar: "p",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
await expect(rotationStarten(person.id, admin)).rejects.toThrow(/neuen Public Key/);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user