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>
158 lines
6.3 KiB
TypeScript
158 lines
6.3 KiB
TypeScript
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();
|
|
});
|
|
});
|