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,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