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
+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,
);
});
}