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
+55 -31
View File
@@ -49,6 +49,12 @@ export type AssistentDaten = {
benutzer: string;
version: string;
vorlagen: Array<{ name: string; titel: string; beschreibung: string | null; parameter: VorlagenParameter }>;
schluessel: Array<{
name: string;
oeffentlich: string;
fingerabdruck: string;
bereich: "persoenlich" | "team";
}>;
};
type Plan = {
@@ -94,6 +100,8 @@ type Werte = {
suchdomain: string;
benutzer: string;
schluessel: string;
/** Fingerprints hinterlegter Schlüssel, die für diesen Gast abgewählt sind. */
ohneSchluessel: string[];
profile: string[];
variablen: Record<string, Record<string, string | boolean>>;
templateBauen: boolean;
@@ -128,9 +136,7 @@ function dekodieren(text: string | null): Partial<Werte> {
}
}
const SCHLUESSEL_SPEICHER = "proxdeploy.ssh-schluessel";
function anfrageAus(w: Werte): AusrollEingabe {
function anfrageAus(w: Werte, hinterlegt: AssistentDaten["schluessel"] = []): AusrollEingabe {
const zahl = (text: string) => (text.trim() === "" ? undefined : Number(text));
return {
typ: w.typ,
@@ -158,10 +164,13 @@ function anfrageAus(w: Werte): AusrollEingabe {
},
zugang: {
benutzer: w.benutzer,
sshSchluessel: w.schluessel
.split("\n")
.map((z) => z.trim())
.filter(Boolean),
sshSchluessel: [
...hinterlegt.filter((s) => !w.ohneSchluessel.includes(s.fingerabdruck)).map((s) => s.oeffentlich),
...w.schluessel
.split("\n")
.map((z) => z.trim())
.filter(Boolean),
],
},
profile: w.profile,
variablen: Object.fromEntries(Object.entries(w.variablen).filter(([p]) => w.profile.includes(p))),
@@ -196,7 +205,7 @@ function vorlageUebernehmen(w: Werte, name: string, p: VorlagenParameter, daten:
}
function werteZuVorlage(w: Werte): VorlagenParameter {
const a = anfrageAus(w);
const a = anfrageAus(w, []);
return {
typ: a.typ,
server: a.server,
@@ -256,6 +265,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
suchdomain: "",
benutzer: daten.benutzer.replace(/[^a-z0-9_-]/g, "") || "admin",
schluessel: "",
ohneSchluessel: [],
profile: ["base"],
variablen: {},
templateBauen: false,
@@ -305,19 +315,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
};
const gehe = (ziel: number) => {
let naechste = werte;
// Gespeicherte Schlüssel als Bequemlichkeit vorbelegen (bis die Schlüsselverwaltung kommt, #015).
if (SCHRITTE[ziel] === "Zugang" && !werte.schluessel) {
try {
const gemerkt = localStorage.getItem(SCHLUESSEL_SPEICHER);
if (gemerkt) {
naechste = { ...werte, schluessel: gemerkt };
setWerte(naechste);
}
} catch {
// kein Speicher verfügbar – dann eben nicht
}
}
const naechste = werte;
if (SCHRITTE[ziel] === "Zusammenfassung") void planen(naechste);
const neu = new URLSearchParams({ s: String(ziel), d: kodieren(naechste) });
router.replace(`${pfad}?${neu.toString()}`, { scroll: false });
@@ -357,7 +355,9 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
.filter(Boolean);
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(werte.benutzer) || werte.benutzer === "root")
return "Gültigen Benutzernamen angeben (nicht root).";
if (schluessel.length === 0) return "Mindestens einen öffentlichen SSH-Schlüssel angeben.";
const hinterlegt = daten.schluessel.filter((s) => !werte.ohneSchluessel.includes(s.fingerabdruck));
if (schluessel.length + hinterlegt.length === 0)
return "Mindestens einen öffentlichen SSH-Schlüssel wählen oder angeben.";
if (schluessel.some((z) => !SSH_SCHLUESSEL.test(z)))
return "Eine Zeile ist kein öffentlicher SSH-Schlüssel.";
return null;
@@ -372,7 +372,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
setLaeuft(true);
setFehler(null);
try {
setPlan(await apiAufruf<Plan>("POST", "/api/v1/auftraege/plan", anfrageAus(stand)));
setPlan(await apiAufruf<Plan>("POST", "/api/v1/auftraege/plan", anfrageAus(stand, daten.schluessel)));
} catch (ursache) {
setFehler(
ursache instanceof ApiFehler
@@ -388,12 +388,11 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
setLaeuft(true);
setFehler(null);
try {
try {
localStorage.setItem(SCHLUESSEL_SPEICHER, werte.schluessel);
} catch {
// egal
}
const { kurzId } = await apiAufruf<{ kurzId: string }>("POST", "/api/v1/auftraege", anfrageAus(werte));
const { kurzId } = await apiAufruf<{ kurzId: string }>(
"POST",
"/api/v1/auftraege",
anfrageAus(werte, daten.schluessel),
);
router.push(`/auftraege/${kurzId}`);
} catch (ursache) {
setFehler(
@@ -871,10 +870,35 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
autoComplete="off"
/>
</Feld>
{daten.schluessel.length > 0 ? (
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Hinterlegte Schlüssel</legend>
{daten.schluessel.map((s) => (
<Schalter
key={s.fingerabdruck}
id={`schluessel-${s.fingerabdruck}`}
label={`${s.name}${s.bereich === "team" ? " (Team)" : ""}`}
hilfe={s.fingerabdruck}
checked={!werte.ohneSchluessel.includes(s.fingerabdruck)}
onChange={(an) =>
setze({
ohneSchluessel: an
? werte.ohneSchluessel.filter((f) => f !== s.fingerabdruck)
: [...werte.ohneSchluessel, s.fingerabdruck],
})
}
/>
))}
</fieldset>
) : null}
<Feld
id="schluessel"
label="Öffentliche SSH-Schlüssel"
hilfe="Einer je Zeile, z. B. aus ~/.ssh/id_ed25519.pub. Gespeicherte Schlüssel folgen mit #015."
label={
daten.schluessel.length > 0
? "Weitere öffentliche SSH-Schlüssel"
: "Öffentliche SSH-Schlüssel"
}
hilfe="Einer je Zeile, z. B. aus ~/.ssh/id_ed25519.pub. Dauerhaft hinterlegen: Einstellungen."
>
<textarea
id="schluessel"
+7
View File
@@ -9,6 +9,7 @@ import { versionText } from "@/lib/version";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { distributionenListe, profileListe } from "@/server/services/katalog";
import { vorlagenListe } from "@/server/services/vorlagen";
import { schluesselFuer } from "@/server/services/schluessel";
import { Assistent, type AssistentDaten } from "./assistent";
export const metadata: Metadata = { title: "Ausrollen" };
@@ -75,6 +76,12 @@ export default async function Ausrollen() {
benutzer: benutzer.benutzername,
version: versionText(),
vorlagen: await vorlagenListe(),
schluessel: (await schluesselFuer(benutzer)).map((x) => ({
name: x.name,
oeffentlich: x.oeffentlich,
fingerabdruck: x.fingerabdruck,
bereich: x.bereich,
})),
};
const vorauswahl = aktiverServer(
daten.server.map((s) => s.name),
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { schluesselFuer } from "@/server/services/schluessel";
import { SchluesselVerwaltung } from "./schluessel-verwaltung";
export const metadata: Metadata = { title: "Einstellungen" };
/** Einstellungen. Heute: SSH-Schlüssel. Benutzer, CLI-Tokens und Standardwerte folgen mit #015. */
export default async function Einstellungen() {
const benutzer = await seiteSchuetzen("eigeneZugaenge", "/einstellungen");
return (
<div className="flex flex-col gap-6">
<header>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Einstellungen</h1>
<p className="text-muted text-sm">Angemeldet als {benutzer.benutzername}</p>
</header>
<SchluesselVerwaltung
anfang={await schluesselFuer(benutzer)}
teamErlaubt={darf(benutzer.rolle, "benutzerVerwalten")}
/>
</div>
);
}
@@ -0,0 +1,147 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { EINGABE_KLASSE, Feld, Schalter } from "@/components/formular";
import { Karte } from "@/components/karte";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
type Schluessel = {
id: string;
name: string;
fingerabdruck: string;
bereich: "persoenlich" | "team";
quelle: string;
oeffentlich: string;
};
/**
* Öffentliche SSH-Schlüssel, die automatisch in jeden Gast kommen – eigene und (von Admins
* gepflegte) Team-Schlüssel. Im Assistenten lassen sie sich je Gast abwählen.
*/
export function SchluesselVerwaltung({
anfang,
teamErlaubt,
}: {
anfang: Schluessel[];
teamErlaubt: boolean;
}) {
const router = useRouter();
const [neu, setNeu] = useState({ name: "", oeffentlich: "", team: false });
const [meldung, setMeldung] = useState<{
art: "erledigt" | "fehler";
text: string;
felder?: Record<string, string>;
} | null>(null);
async function hinzufuegen(ereignis: React.FormEvent) {
ereignis.preventDefault();
try {
await apiAufruf("POST", "/api/v1/schluessel", neu);
setNeu({ name: "", oeffentlich: "", team: false });
setMeldung({ art: "erledigt", text: "Schlüssel hinzugefügt." });
router.refresh();
} catch (fehler) {
setMeldung({
art: "fehler",
text: fehler instanceof ApiFehler ? fehler.message : "Fehler.",
felder: fehler instanceof ApiFehler ? fehler.felder : undefined,
});
}
}
async function entfernen(s: Schluessel) {
if (!window.confirm(`Schlüssel „${s.name}“ entfernen? Bereits ausgerollte Gäste behalten ihn.`)) return;
try {
await apiAufruf("DELETE", `/api/v1/schluessel/${s.id}`);
router.refresh();
} catch (fehler) {
setMeldung({ art: "fehler", text: fehler instanceof ApiFehler ? fehler.message : "Fehler." });
}
}
return (
<Karte titel="SSH-Schlüssel">
<div className="flex flex-col gap-5">
<p className="text-muted text-sm">
Diese öffentlichen Schlüssel kommen in jeden Gast, den du ausrollst – auch per CLI, wenn dort keine
angegeben sind.
</p>
{anfang.length === 0 ? (
<Status art="offen">Noch keine Schlüssel hinterlegt.</Status>
) : (
<ul className="divide-linie flex flex-col divide-y">
{anfang.map((s) => (
<li key={s.id} className="flex flex-col gap-2 py-3 first:pt-0 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<p className="font-medium">
{s.name}{" "}
<span className="text-muted text-sm">
· {s.bereich === "team" ? "Team" : "persönlich"}
</span>
</p>
<p className="text-muted font-mono text-xs break-all">{s.fingerabdruck}</p>
</div>
{s.bereich === "persoenlich" || teamErlaubt ? (
<Knopf
variante="gefahr"
onClick={() => void entfernen(s)}
aria-label={`Schlüssel ${s.name} entfernen`}
>
Entfernen
</Knopf>
) : null}
</li>
))}
</ul>
)}
<form onSubmit={hinzufuegen} className="border-linie flex flex-col gap-4 border-t pt-5">
<Feld id="schluessel-name" label="Name" fehler={meldung?.felder?.["name"]}>
<input
id="schluessel-name"
className={`${EINGABE_KLASSE} sm:max-w-sm`}
value={neu.name}
onChange={(e) => setNeu({ ...neu, name: e.target.value })}
placeholder="Laptop"
/>
</Feld>
<Feld
id="schluessel-text"
label="Öffentlicher Schlüssel"
hilfe="Inhalt von ~/.ssh/id_ed25519.pub"
fehler={meldung?.felder?.["oeffentlich"]}
>
<textarea
id="schluessel-text"
rows={3}
className={`${EINGABE_KLASSE} h-auto py-2 font-mono text-xs`}
value={neu.oeffentlich}
onChange={(e) => setNeu({ ...neu, oeffentlich: e.target.value })}
placeholder="ssh-ed25519 AAAA… name@rechner"
spellCheck={false}
/>
</Feld>
{teamErlaubt ? (
<Schalter
id="schluessel-team"
label="Team-Schlüssel"
hilfe="Kommt in jeden Gast, egal wer ausrollt."
checked={neu.team}
onChange={(an) => setNeu({ ...neu, team: an })}
/>
) : null}
<div className="flex flex-wrap items-center gap-3">
<Knopf type="submit" disabled={!neu.name || !neu.oeffentlich}>
Hinzufügen
</Knopf>
<div aria-live="polite" className="text-sm">
{meldung ? <Status art={meldung.art}>{meldung.text}</Status> : null}
</div>
</div>
</form>
</div>
</Karte>
);
}
+2 -2
View File
@@ -5,6 +5,6 @@ import { ausrollenPlanen } from "@/server/services/ausrollen";
/** Dry-Run: Phasen, geplante Proxmox-Aufrufe, Skript und CLI-Befehl – angelegt wird nichts. */
export const POST = api(async (anfrage: Request) => {
await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(await ausrollenPlanen(await jsonLesen(anfrage)));
const aufrufer = await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(await ausrollenPlanen(await jsonLesen(anfrage), aufrufer));
});
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { schluesselLoeschen } from "@/server/services/schluessel";
type Kontext = { params: Promise<{ id: string }> };
export const DELETE = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
await schluesselLoeschen((await params).id, aufrufer, aufruferAdresse(anfrage.headers));
return new NextResponse(null, { status: 204 });
});
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { schluesselAnlegen, schluesselFuer } from "@/server/services/schluessel";
/** Die eigenen und die Team-Schlüssel – das, was ohne Angabe in jeden Gast kommt. */
export const GET = api(async (anfrage: Request) => {
const { benutzer } = await rechtErforderlich(anfrage, "eigeneZugaenge");
return NextResponse.json(await schluesselFuer(benutzer));
});
/** `{ name, oeffentlich, team? }` – Team-Schlüssel nur für Admins. */
export const POST = api(async (anfrage: Request) => {
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
return NextResponse.json(
await schluesselAnlegen(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers)),
{ status: 201 },
);
});
+5 -1
View File
@@ -58,9 +58,13 @@ export const Zugang = z.object({
.trim()
.regex(/^[a-z_][a-z0-9_-]{0,31}$/, "Benutzername: Kleinbuchstaben, Ziffern, _ und -")
.refine((name) => name !== "root", "root ist nicht erlaubt – ProxDeploy legt einen sudo-Benutzer an."),
/** Leer = die hinterlegten Schlüssel des Benutzers und die Team-Schlüssel (beim Anlegen ergänzt). */
sshSchluessel: z
.array(z.string().trim().regex(SSH_SCHLUESSEL, "Kein gültiger öffentlicher SSH-Schlüssel."))
.min(1, "Mindestens ein SSH-Schlüssel – Passwörter vergibt ProxDeploy nicht."),
.min(
1,
"Mindestens ein SSH-Schlüssel – Passwörter vergibt ProxDeploy nicht. In den Einstellungen hinterlegen oder hier angeben.",
),
});
export const AusrollAnfrage = z
+21
View File
@@ -0,0 +1,21 @@
import { prisma } from "@/lib/db";
import type { GastSchluessel, SchluesselQuelle } from "./quelle";
/** Schlüssel aus der eigenen Datenbank: die des Benutzers und die Team-Schlüssel. */
export const lokaleQuelle: SchluesselQuelle = {
name: "lokal",
async schluesselFuer(benutzer) {
const zeilen = await prisma.sshSchluessel.findMany({
where: { OR: [{ benutzerId: benutzer.id }, { benutzerId: null }] },
orderBy: [{ benutzerId: "asc" }, { name: "asc" }],
});
return zeilen.map((z): GastSchluessel => ({
id: z.id,
name: z.name,
oeffentlich: z.oeffentlich,
fingerabdruck: z.fingerabdruck,
bereich: z.benutzerId ? "persoenlich" : "team",
quelle: z.quelle,
}));
},
};
+33
View File
@@ -0,0 +1,33 @@
import { createHash } from "node:crypto";
import { SSH_SCHLUESSEL } from "@/lib/schemas/ausrollen";
/**
* Woher die SSH-Schlüssel eines Benutzers kommen. Heute aus der eigenen Datenbank („lokal“); eine
* externe Schlüsselverwaltung ist eine weitere Umsetzung dieses Interfaces, die in
* `schluesselQuellen()` eingetragen wird – Assistent, API und CLI merken davon nichts.
*/
export type GastSchluessel = {
id: string;
name: string;
oeffentlich: string;
fingerabdruck: string;
/** Persönlich oder für alle (Team). */
bereich: "persoenlich" | "team";
quelle: string;
};
export interface SchluesselQuelle {
readonly name: string;
/** Schlüssel, die standardmäßig in Gäste dieses Benutzers kommen. */
schluesselFuer(benutzer: { id: string; benutzername: string }): Promise<GastSchluessel[]>;
}
/** Fingerprint wie `ssh-keygen -lf`: SHA256 über die Base64-dekodierten Schlüsseldaten. */
export function fingerabdruck(oeffentlich: string): string {
const daten = oeffentlich.trim().split(/\s+/)[1] ?? "";
return `SHA256:${createHash("sha256").update(Buffer.from(daten, "base64")).digest("base64").replace(/=+$/, "")}`;
}
export function istOeffentlicherSchluessel(text: string): boolean {
return SSH_SCHLUESSEL.test(text.trim());
}
+18 -3
View File
@@ -16,6 +16,7 @@ import { auftragAnlegen } from "@/server/services/auftraege";
import { serverHolen, verbindungAus } from "@/server/services/server";
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
import { bereitstellungsschluessel } from "@/server/services/bereitstellung";
import { schluesselFuer } from "@/server/services/schluessel";
import { vmidVorschlagen } from "@/server/services/vmid";
/**
@@ -78,8 +79,22 @@ export async function ausrollenVorbereiten(roh: unknown): Promise<Vorbereitet> {
return { anfrage, server, distribution, profile: aufloesung.profile, automatisch: aufloesung.automatisch };
}
/**
* Gibt die Anfrage keine Schlüssel an, kommen die hinterlegten des Benutzers und die Team-Schlüssel
* hinein. Das passiert beim Anlegen, nicht im worker: dort ist bekannt, wer ausrollt, und die
* Anfrage im Verlauf zeigt, welche Schlüssel tatsächlich im Gast gelandet sind.
*/
export async function schluesselErgaenzen(roh: unknown, aufrufer: Aufrufer): Promise<unknown> {
if (!roh || typeof roh !== "object") return roh;
const anfrage = roh as { zugang?: { benutzer?: unknown; sshSchluessel?: unknown } };
const angegeben = Array.isArray(anfrage.zugang?.sshSchluessel) ? anfrage.zugang.sshSchluessel : [];
if (angegeben.length > 0) return roh;
const hinterlegt = (await schluesselFuer(aufrufer.benutzer)).map((s) => s.oeffentlich);
return { ...anfrage, zugang: { ...anfrage.zugang, sshSchluessel: hinterlegt } };
}
export async function ausrollenStarten(roh: unknown, aufrufer: Aufrufer, ip: string | null) {
const v = await ausrollenVorbereiten(roh);
const v = await ausrollenVorbereiten(await schluesselErgaenzen(roh, aufrufer));
return await auftragAnlegen(
{
art: "AUSROLLEN",
@@ -178,8 +193,8 @@ export type Plan = {
* geht wirklich an Proxmox (VMID frei? Template da? Bridge VLAN-fähig?), Schreibendes wird nur
* aufgezeichnet. Was hier steht, ist also genau das, was „Ausrollen“ tun würde.
*/
export async function ausrollenPlanen(roh: unknown): Promise<Plan> {
const v = await ausrollenVorbereiten(roh);
export async function ausrollenPlanen(roh: unknown, aufrufer: Aufrufer): Promise<Plan> {
const v = await ausrollenVorbereiten(await schluesselErgaenzen(roh, aufrufer));
const client = new ProxmoxClient(verbindungAus(v.server), { trocken: true, zeitlimitMs: 15_000 });
const phasen: Plan["phasen"] = [];
let skript: string | null = null;
+100
View File
@@ -0,0 +1,100 @@
import { prisma } from "@/lib/db";
import { z } from "@/lib/validierung";
import type { Aufrufer } from "@/server/auth/zugang";
import { darf } from "@/server/auth/rechte";
import { AnwendungsFehler, keinRecht, nichtGefunden } from "@/server/fehler";
import { protokollieren } from "@/server/services/protokoll";
import { lokaleQuelle } from "@/server/schluessel/lokal";
import {
fingerabdruck,
istOeffentlicherSchluessel,
type GastSchluessel,
type SchluesselQuelle,
} from "@/server/schluessel/quelle";
/** Alle angebundenen Quellen. Eine externe Verwaltung kommt hier dazu. */
export function schluesselQuellen(): SchluesselQuelle[] {
return [lokaleQuelle];
}
export async function schluesselFuer(benutzer: {
id: string;
benutzername: string;
}): Promise<GastSchluessel[]> {
const listen = await Promise.all(schluesselQuellen().map((q) => q.schluesselFuer(benutzer)));
// Derselbe Schlüssel aus zwei Quellen nur einmal.
const gesehen = new Set<string>();
return listen.flat().filter((s) => !gesehen.has(s.fingerabdruck) && gesehen.add(s.fingerabdruck));
}
const Eingabe = z.object({
name: z.string().trim().min(1, "Name fehlt.").max(80),
oeffentlich: z
.string()
.trim()
.refine(
istOeffentlicherSchluessel,
"Kein öffentlicher SSH-Schlüssel (ssh-ed25519 …, ssh-rsa …, ecdsa-sha2-… ). Den .pub-Inhalt einfügen, nie den privaten Schlüssel.",
),
team: z.boolean().default(false),
});
export async function schluesselAnlegen(
roh: unknown,
aufrufer: Aufrufer,
ip: string | null,
): Promise<GastSchluessel> {
const eingabe = Eingabe.parse(roh);
if (eingabe.team && !darf(aufrufer.benutzer.rolle, "benutzerVerwalten")) throw keinRecht();
const abdruck = fingerabdruck(eingabe.oeffentlich);
const benutzerId = eingabe.team ? null : aufrufer.benutzer.id;
if (await prisma.sshSchluessel.findFirst({ where: { benutzerId, fingerabdruck: abdruck } })) {
throw new AnwendungsFehler(409, "vergeben", "Diesen Schlüssel gibt es hier schon.");
}
const zeile = await prisma.$transaction(async (tx) => {
const neu = await tx.sshSchluessel.create({
data: { benutzerId, name: eingabe.name, oeffentlich: eingabe.oeffentlich, fingerabdruck: abdruck },
});
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: eingabe.team ? "Team-Schlüssel hinzugefügt" : "SSH-Schlüssel hinzugefügt",
ziel: `schluessel:${abdruck}`,
details: { name: eingabe.name },
ip,
},
tx,
);
return neu;
});
return {
id: zeile.id,
name: zeile.name,
oeffentlich: zeile.oeffentlich,
fingerabdruck: abdruck,
bereich: benutzerId ? "persoenlich" : "team",
quelle: "lokal",
};
}
export async function schluesselLoeschen(id: string, aufrufer: Aufrufer, ip: string | null): Promise<void> {
const zeile = await prisma.sshSchluessel.findUnique({ where: { id } });
if (!zeile) throw nichtGefunden("Den Schlüssel");
const eigener = zeile.benutzerId === aufrufer.benutzer.id;
if (!eigener && !(zeile.benutzerId === null && darf(aufrufer.benutzer.rolle, "benutzerVerwalten")))
throw keinRecht();
await prisma.$transaction(async (tx) => {
await tx.sshSchluessel.delete({ where: { id } });
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: zeile.benutzerId ? "SSH-Schlüssel entfernt" : "Team-Schlüssel entfernt",
ziel: `schluessel:${zeile.fingerabdruck}`,
ip,
},
tx,
);
});
}