feat(schluessel): SSH-Schlüssel hinterlegen statt in jedes Formular
Benutzer hinterlegen ihre öffentlichen Schlüssel einmal, Admins zusätzlich Team-Schlüssel. Sie kommen in jeden Gast: im Assistenten vorausgewählt und abwählbar, per API und CLI automatisch, wenn die Anfrage keine nennt. Die Schlüssel kommen über die Schnittstelle SchluesselQuelle – dort lässt sich später eine externe Verwaltung anbinden. Vorgezogen aus #015 auf die Frage, ob Schlüssel ins Template gehören: nein, in die Verwaltung. Refs #015 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { anmelden, E2E_PRAEFIX, keinQuerscrollen } from "./helfer";
|
||||
|
||||
/** Je Lauf ein eigener Schlüssel – derselbe zweimal wäre für denselben Benutzer ein Duplikat. */
|
||||
function neuerSchluessel(): string {
|
||||
const ordner = mkdtempSync(join(tmpdir(), "pd-e2e-"));
|
||||
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "e2e@test", "-f", join(ordner, "k")]);
|
||||
return readFileSync(join(ordner, "k.pub"), "utf8").trim();
|
||||
}
|
||||
|
||||
test("SSH-Schlüssel hinterlegen, im Assistenten vorausgewählt, wieder entfernen", async ({ page }) => {
|
||||
const name = `${E2E_PRAEFIX}-${test.info().project.name}`;
|
||||
await anmelden(page, "/einstellungen");
|
||||
// Reste eines abgebrochenen Laufs wegräumen.
|
||||
for (const alt of (await (await page.request.get("/api/v1/schluessel")).json()) as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
}>) {
|
||||
if (alt.name.startsWith(E2E_PRAEFIX)) await page.request.delete(`/api/v1/schluessel/${alt.id}`);
|
||||
}
|
||||
await page.reload();
|
||||
await page.getByLabel("Name").fill(name);
|
||||
await page.getByLabel("Öffentlicher Schlüssel").fill("-----BEGIN OPENSSH PRIVATE KEY-----");
|
||||
await page.getByRole("button", { name: "Hinzufügen" }).click();
|
||||
await expect(page.getByText(/nie den privaten Schlüssel/)).toBeVisible();
|
||||
await page.getByLabel("Öffentlicher Schlüssel").fill(neuerSchluessel());
|
||||
await page.getByRole("button", { name: "Hinzufügen" }).click();
|
||||
await expect(page.getByText(name)).toBeVisible();
|
||||
await keinQuerscrollen(page);
|
||||
|
||||
await page.goto("/ausrollen");
|
||||
if (await page.getByText("Erst einen Server anlegen").isVisible())
|
||||
test.skip(true, "Kein getesteter Server vorhanden");
|
||||
for (let i = 0; i < 3; i++) await page.getByRole("button", { name: "Weiter" }).click();
|
||||
await page.getByLabel("Name (Hostname)").fill("pd-e2e");
|
||||
for (let i = 0; i < 3; i++) await page.getByRole("button", { name: "Weiter" }).click();
|
||||
await expect(page.getByLabel(name)).toBeChecked();
|
||||
// Mit hinterlegtem Schlüssel geht es ohne Eingabe weiter.
|
||||
await expect(page.getByRole("button", { name: "Weiter" })).toBeEnabled();
|
||||
|
||||
await page.goto("/einstellungen");
|
||||
page.once("dialog", (d) => void d.accept());
|
||||
await page.getByRole("button", { name: `Schlüssel ${name} entfernen` }).click();
|
||||
await expect(page.getByText(name)).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import * as liste from "@/app/api/v1/schluessel/route";
|
||||
import * as einzeln from "@/app/api/v1/schluessel/[id]/route";
|
||||
import { verschluesseln } from "@/lib/geheimnisse";
|
||||
import { schluesselErgaenzen, ausrollenVorbereiten } from "@/server/services/ausrollen";
|
||||
import { katalogSynchronisieren } from "@/server/services/katalog";
|
||||
import { fingerabdruck } from "@/server/schluessel/quelle";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
|
||||
|
||||
function neuerSchluessel(kommentar: string): string {
|
||||
const ordner = mkdtempSync(join(tmpdir(), "pd-key-"));
|
||||
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", kommentar, "-f", join(ordner, "k")]);
|
||||
return readFileSync(join(ordner, "k.pub"), "utf8").trim();
|
||||
}
|
||||
|
||||
describe("Fingerprint", () => {
|
||||
it("entspricht ssh-keygen -lf", () => {
|
||||
const ordner = mkdtempSync(join(tmpdir(), "pd-key-"));
|
||||
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", join(ordner, "k")]);
|
||||
const erwartet = execFileSync("ssh-keygen", ["-lf", join(ordner, "k.pub")])
|
||||
.toString()
|
||||
.split(" ")[1];
|
||||
expect(fingerabdruck(readFileSync(join(ordner, "k.pub"), "utf8"))).toBe(erwartet);
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("SSH-Schlüssel", () => {
|
||||
let admin: { cookie: string; benutzer: { id: string; benutzername: string } };
|
||||
let operator: { cookie: string; benutzer: { id: string; benutzername: string } };
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
admin = await angemeldet("ADMIN");
|
||||
operator = await angemeldet("OPERATOR");
|
||||
});
|
||||
|
||||
const anlegen = async (cookie: string, rumpf: unknown) =>
|
||||
await liste.POST(anfrage("POST", "/api/v1/schluessel", cookie, rumpf));
|
||||
|
||||
it("eigene Schlüssel für alle, Team-Schlüssel nur für Admins; jeder sieht eigene + Team", async () => {
|
||||
expect(
|
||||
(await anlegen(operator.cookie, { name: "Laptop", oeffentlich: neuerSchluessel("op") })).status,
|
||||
).toBe(201);
|
||||
expect(
|
||||
(await anlegen(operator.cookie, { name: "Team?", oeffentlich: neuerSchluessel("x"), team: true }))
|
||||
.status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(await anlegen(admin.cookie, { name: "Notfall", oeffentlich: neuerSchluessel("team"), team: true }))
|
||||
.status,
|
||||
).toBe(201);
|
||||
expect(
|
||||
(await anlegen(admin.cookie, { name: "Admin", oeffentlich: neuerSchluessel("admin") })).status,
|
||||
).toBe(201);
|
||||
|
||||
const sicht = await (await liste.GET(anfrage("GET", "/api/v1/schluessel", operator.cookie))).json();
|
||||
expect(sicht.map((s: { name: string; bereich: string }) => `${s.name}:${s.bereich}`).sort()).toEqual([
|
||||
"Laptop:persoenlich",
|
||||
"Notfall:team",
|
||||
]);
|
||||
});
|
||||
|
||||
it("weist private Schlüssel und Doppelte ab", async () => {
|
||||
const privat = await anlegen(operator.cookie, {
|
||||
name: "x",
|
||||
oeffentlich: "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----",
|
||||
});
|
||||
expect(privat.status).toBe(400);
|
||||
expect((await privat.json()).fehler.felder.oeffentlich).toMatch(/nie den privaten/);
|
||||
const schluessel = neuerSchluessel("doppelt");
|
||||
await anlegen(operator.cookie, { name: "a", oeffentlich: schluessel });
|
||||
expect((await anlegen(operator.cookie, { name: "b", oeffentlich: schluessel })).status).toBe(409);
|
||||
});
|
||||
|
||||
it("fremde Schlüssel darf niemand löschen, Team-Schlüssel nur Admins", async () => {
|
||||
const eigener = await (
|
||||
await anlegen(admin.cookie, { name: "Admin", oeffentlich: neuerSchluessel("a") })
|
||||
).json();
|
||||
const team = await (
|
||||
await anlegen(admin.cookie, { name: "Team", oeffentlich: neuerSchluessel("t"), team: true })
|
||||
).json();
|
||||
const loeschen = (id: string, cookie: string) =>
|
||||
einzeln.DELETE(anfrage("DELETE", `/api/v1/schluessel/${id}`, cookie), kontext({ id }));
|
||||
expect((await loeschen(eigener.id, operator.cookie)).status).toBe(403);
|
||||
expect((await loeschen(team.id, operator.cookie)).status).toBe(403);
|
||||
expect((await loeschen(team.id, admin.cookie)).status).toBe(204);
|
||||
});
|
||||
|
||||
it("Ausrollen ohne Schlüssel nimmt die hinterlegten (eigene + Team); angegebene gewinnen", async () => {
|
||||
await katalogSynchronisieren();
|
||||
await testPrisma.server.create({
|
||||
data: {
|
||||
name: "rz1",
|
||||
titel: "RZ 1",
|
||||
url: "https://127.0.0.1:8006",
|
||||
tokenId: "a@pve!b",
|
||||
tokenGeheimnis: verschluesseln("x"),
|
||||
fingerabdruck: "AA",
|
||||
},
|
||||
});
|
||||
const eigen = neuerSchluessel("eigen");
|
||||
const team = neuerSchluessel("team");
|
||||
await anlegen(operator.cookie, { name: "eigen", oeffentlich: eigen });
|
||||
await anlegen(admin.cookie, { name: "team", oeffentlich: team, team: true });
|
||||
const roh = {
|
||||
typ: "vm",
|
||||
server: "rz1",
|
||||
distribution: "debian-13",
|
||||
name: "web01",
|
||||
storage: "s",
|
||||
netz: { bridge: "vmbr0" },
|
||||
zugang: { benutzer: "florian" },
|
||||
};
|
||||
const aufrufer = { benutzer: { ...operator.benutzer } as never, quelle: "CLI" as const };
|
||||
const v = await ausrollenVorbereiten(await schluesselErgaenzen(roh, aufrufer));
|
||||
expect(v.anfrage.zugang.sshSchluessel.sort()).toEqual([eigen, team].sort());
|
||||
|
||||
const anders = neuerSchluessel("anders");
|
||||
const nurAngegeben = await schluesselErgaenzen(
|
||||
{ ...roh, zugang: { benutzer: "florian", sshSchluessel: [anders] } },
|
||||
aufrufer,
|
||||
);
|
||||
expect((nurAngegeben as { zugang: { sshSchluessel: string[] } }).zugang.sshSchluessel).toEqual([anders]);
|
||||
});
|
||||
|
||||
it("ohne hinterlegte und ohne angegebene Schlüssel: verständlicher Fehler", async () => {
|
||||
await katalogSynchronisieren();
|
||||
await testPrisma.server.create({
|
||||
data: {
|
||||
name: "rz1",
|
||||
titel: "RZ 1",
|
||||
url: "https://127.0.0.1:8006",
|
||||
tokenId: "a@pve!b",
|
||||
tokenGeheimnis: verschluesseln("x"),
|
||||
fingerabdruck: "AA",
|
||||
},
|
||||
});
|
||||
const roh = {
|
||||
typ: "vm",
|
||||
server: "rz1",
|
||||
distribution: "debian-13",
|
||||
name: "web01",
|
||||
storage: "s",
|
||||
netz: { bridge: "vmbr0" },
|
||||
zugang: { benutzer: "florian" },
|
||||
};
|
||||
await expect(
|
||||
ausrollenVorbereiten(
|
||||
await schluesselErgaenzen(roh, { benutzer: operator.benutzer as never, quelle: "CLI" }),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user