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:
2026-10-12 14:28:45 +02:00
co-authored by Claude Opus 5.5
parent 00712dc6ce
commit 426d3ec95a
18 changed files with 690 additions and 39 deletions
+157
View File
@@ -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();
});
});