feat(schluessel): erzeugen, importieren, exportieren (#012)

Schluessel werden jetzt verwaltet statt nur im Prototyp gezeigt: Liste
mit Filter, Anlegen (erzeugen oder importieren), Detail nach dem
Prototyp aus #002, Bearbeiten der Metadaten und Download <slug>.pub.

Warum so:
- Erzeugen und Import eines Paars laufen als Vorgang im Worker, weil
  nur er den Master-Key hat. Passphrase und importierter Private Key
  reisen wie das Bootstrap-Passwort mit SSHKV_APP_SECRET verschluesselt
  im Job, nie in Vorgang.eingabe; der Worker loescht den Job danach.
- Ein reiner Public Key ist kein Geheimnis und wird direkt im Web
  angelegt, mit verstaendlicher Meldung bei einem Duplikat.
- Das Web liest nie das Chiffrat: ob ein Private Key gespeichert ist,
  verraet masterKeyVersion (per CHECK an das Chiffrat gekoppelt). Ein
  struktureller Test verlangt fuer jede zeilenliefernde Abfrage auf
  Schluessel ausserhalb des Workers ein festes select.
- Migration: Vorgangsarten fuer Erzeugen, Import und (fuer #014) das
  Loeschen des Private Keys; CHECKs fuer den Slug (landet in
  Dateinamen) und "geloescht nur ohne Chiffrat".
- Platzhalter PublicAusrollenAktion/PrivateAusrollenAktion fuer #016/#017.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:29:52 +02:00
co-authored by Claude Opus 5.5
parent f0833aa1db
commit 263143134f
33 changed files with 3322 additions and 287 deletions
+137
View File
@@ -0,0 +1,137 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
erzeugenAnfordern,
paarImportAnfordern,
publicKeyImportieren,
schluesselBearbeiten,
} from "@/server/services/schluessel";
/**
* Aktionen fuer Schluessel (#012). Duenn: Recht pruefen, Felder lesen, Service rufen.
*
* Passphrase und Private Key verlassen diese Funktionen nur verschluesselt im Job (der
* Service verschluesselt sie) – nie in einer Antwort, einem Log oder dem Protokoll.
*/
export type SchluesselAntwort = FormularZustand & {
/** Vorgang im Worker (Erzeugen, Paar-Import) – die Oberflaeche verfolgt ihn. */
readonly vorgangId?: string;
/** Sofort angelegt (Public-Key-Import). */
readonly schluesselId?: string;
};
function metadaten(formular: FormData) {
return {
name: textfeld(formular, "name") ?? "",
slug: textfeld(formular, "slug"),
kommentar: textfeld(formular, "kommentar"),
tags: textfeld(formular, "tags") ?? "",
ablaufAm: textfeld(formular, "ablaufAm"),
personId: textfeld(formular, "personId"),
vorlageId: textfeld(formular, "vorlageId"),
};
}
/** Passphrase mit Wiederholung – die Wiederholung prueft der Server, nicht nur das Formular. */
function passphraseLesen(formular: FormData): { passphrase?: string; fehler?: SchluesselAntwort } {
const passphrase = textfeld(formular, "passphrase");
const wiederholung = textfeld(formular, "passphraseWiederholung");
if (passphrase && passphrase !== wiederholung) {
return {
fehler: {
fehler: "Die Passphrasen stimmen nicht überein.",
feldfehler: { passphraseWiederholung: "Stimmt nicht mit der Passphrase überein." },
},
};
}
return passphrase ? { passphrase } : {};
}
export async function schluesselErzeugenAktion(
_vorher: SchluesselAntwort,
formular: FormData,
): Promise<SchluesselAntwort> {
try {
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
const { passphrase, fehler } = passphraseLesen(formular);
if (fehler) return fehler;
const vorgangId = await erzeugenAnfordern(
{ ...metadaten(formular), typ: textfeld(formular, "typ") as "ED25519" | "RSA4096" | undefined },
passphrase,
akteurVon(benutzer),
);
return { vorgangId };
} catch (fehler) {
return fehlerZustand(fehler);
}
}
export async function paarImportierenAktion(
_vorher: SchluesselAntwort,
formular: FormData,
): Promise<SchluesselAntwort> {
try {
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
const vorgangId = await paarImportAnfordern(
metadaten(formular),
textfeld(formular, "privateKey"),
// Beim Import nur zur Pruefung – keine Wiederholung noetig, falsche merkt der Worker.
textfeld(formular, "passphrase"),
akteurVon(benutzer),
);
return { vorgangId };
} catch (fehler) {
return fehlerZustand(fehler);
}
}
export async function publicKeyImportierenAktion(
_vorher: SchluesselAntwort,
formular: FormData,
): Promise<SchluesselAntwort> {
try {
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
const schluessel = await publicKeyImportieren(
{
...metadaten(formular),
kategorie: textfeld(formular, "kategorie") as "PERSON" | "DIENST" | undefined,
publicKey: textfeld(formular, "publicKey") ?? "",
},
akteurVon(benutzer),
);
revalidatePath("/schluessel");
return { schluesselId: schluessel.id };
} catch (fehler) {
return fehlerZustand(fehler);
}
}
export async function schluesselBearbeitenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
const id = textfeld(formular, "id") ?? "";
try {
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
await schluesselBearbeiten(
id,
{
name: textfeld(formular, "name") ?? "",
tags: textfeld(formular, "tags") ?? "",
ablaufAm: textfeld(formular, "ablaufAm"),
personId: textfeld(formular, "personId"),
vorlageId: textfeld(formular, "vorlageId"),
status: textfeld(formular, "status") as "AKTIV" | "ABGELAUFEN" | undefined,
},
akteurVon(benutzer),
);
} catch (fehler) {
return fehlerZustand(fehler);
}
revalidatePath("/schluessel", "layout");
redirect(`/schluessel/${id}`);
}
+168
View File
@@ -0,0 +1,168 @@
import type { Prisma } from "@/generated/prisma/client";
import type { SchluesselKategorie, SchluesselStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
/**
* Lesemodelle fuer Schluessel (#012). Alles hier darf jede angemeldete Rolle sehen.
*
* Feste `select`-Listen ohne Chiffrat-Felder (CLAUDE.md, „Feste Regeln“). Ob ein Private Key
* gespeichert ist, verraet `masterKeyVersion`: der DB-CHECK `schluessel_umschlag_vollstaendig`
* setzt sie genau dann, wenn auch ein Chiffrat da ist – so muss das Chiffrat nie gelesen
* werden, nicht einmal fuer ein „ist null?“.
*/
export const SCHLUESSEL_LISTE_SELECT = {
id: true,
name: true,
slug: true,
typ: true,
kategorie: true,
status: true,
publicKey: true,
fingerprint: true,
ablaufAm: true,
masterKeyVersion: true,
passphraseGeschuetzt: true,
person: { select: { id: true, name: true } },
_count: {
select: {
publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
},
},
} satisfies Prisma.SchluesselSelect;
export const SCHLUESSEL_DETAIL_SELECT = {
id: true,
name: true,
slug: true,
typ: true,
kategorie: true,
status: true,
publicKey: true,
fingerprint: true,
kommentar: true,
masterKeyVersion: true,
privatGeloeschtAm: true,
passphraseGeschuetzt: true,
instanzStandard: true,
selbstHinterlegt: true,
tags: true,
ablaufAm: true,
zuletztVerwendetAm: true,
angelegtAm: true,
personId: true,
vorlageId: true,
person: { select: { id: true, name: true } },
vorlage: { select: { id: true, name: true } },
erstelltVon: { select: { name: true } },
publicVerwendungen: {
where: { zustand: { not: "ENTFERNT" } },
orderBy: { angelegtAm: "asc" },
select: {
id: true,
zielbenutzer: true,
optionen: true,
zustand: true,
zuletztAngewendetAm: true,
host: { select: { id: true, name: true } },
hostgruppe: { select: { id: true, name: true } },
},
},
privateVerwendungen: {
where: { zustand: { not: "ENTFERNT" } },
orderBy: { angelegtAm: "asc" },
select: {
id: true,
zielbenutzer: true,
pfad: true,
sshConfig: true,
zustand: true,
zuletztAngewendetAm: true,
host: { select: { id: true, name: true } },
},
},
} satisfies Prisma.SchluesselSelect;
export type SchluesselFilter = {
readonly q?: string;
readonly kategorie?: string;
readonly status?: string;
};
const KATEGORIEN: readonly SchluesselKategorie[] = ["PERSON", "DIENST", "DEPLOYMENT"];
const STATI: readonly SchluesselStatus[] = ["AKTIV", "ROTIERT", "WIDERRUFEN", "ABGELAUFEN"];
function alsKategorie(wert: string | undefined): SchluesselKategorie | undefined {
return KATEGORIEN.find((k) => k === wert);
}
function alsStatus(wert: string | undefined): SchluesselStatus | undefined {
return STATI.find((s) => s === wert);
}
export async function schluesselListe(filter: SchluesselFilter = {}) {
const q = filter.q?.trim().slice(0, 100);
const kategorie = alsKategorie(filter.kategorie);
const status = alsStatus(filter.status);
const where: Prisma.SchluesselWhereInput = {
...(kategorie ? { kategorie } : {}),
...(status ? { status } : {}),
...(q
? {
OR: [
{ name: { contains: q, mode: "insensitive" } },
{ slug: { contains: q.toLowerCase() } },
{ fingerprint: { contains: q } },
{ kommentar: { contains: q, mode: "insensitive" } },
{ tags: { has: q.toLowerCase() } },
{ person: { name: { contains: q, mode: "insensitive" } } },
],
}
: {}),
};
const zeilen = await prisma.schluessel.findMany({
where,
orderBy: [{ name: "asc" }],
select: SCHLUESSEL_LISTE_SELECT,
});
return zeilen.map(({ _count, masterKeyVersion, ...rest }) => ({
...rest,
privatGespeichert: masterKeyVersion !== null,
verwendungen: _count.publicVerwendungen + _count.privateVerwendungen,
}));
}
export type SchluesselListenEintrag = Awaited<ReturnType<typeof schluesselListe>>[number];
export async function schluesselDetail(id: string) {
const schluessel = await prisma.schluessel.findUnique({ where: { id }, select: SCHLUESSEL_DETAIL_SELECT });
if (!schluessel) return null;
const { masterKeyVersion, ...rest } = schluessel;
return { ...rest, privatGespeichert: masterKeyVersion !== null };
}
export type SchluesselDetail = NonNullable<Awaited<ReturnType<typeof schluesselDetail>>>;
/** Nur Slug und Public Key – fuer den Download `<slug>.pub`. */
export async function publicKeyExport(id: string) {
return prisma.schluessel.findUnique({ where: { id }, select: { slug: true, publicKey: true } });
}
// ---------------------------------------------------------------- Auswahllisten
export async function personenAuswahl() {
return prisma.person.findMany({
where: { archiviertAm: null },
orderBy: { name: "asc" },
select: { id: true, name: true, organisation: true },
});
}
export async function vorlagenAuswahl() {
return prisma.vorlage.findMany({
where: { archiviertAm: null },
orderBy: { name: "asc" },
select: { id: true, name: true, standardLaufzeitTage: true },
});
}
+15
View File
@@ -0,0 +1,15 @@
import { HostFehler } from "@/server/services/hosts";
/**
* Fachlicher Fehler mit Meldung fuer die Oberflaeche und optional je Feld (#012, #013).
*
* Erbt von `HostFehler`, weil `fehlerZustand()` in src/server/actions/formular.ts genau den
* als „Meldung fuers Formular“ erkennt. So bleibt die eine Stelle, die Fehler in
* Formularzustaende uebersetzt, unveraendert.
*/
export class FachFehler extends HostFehler {
constructor(nachricht: string, feldfehler: Record<string, string> = {}) {
super(nachricht, feldfehler);
this.name = "FachFehler";
}
}
+396
View File
@@ -0,0 +1,396 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { verschluesseln } from "@/lib/geheimnisse";
import { feldfehlerAus } from "@/lib/hosts";
import {
bearbeitenSchema,
erzeugenSchema,
freierSlug,
PASSPHRASE_MAX,
paarImportSchema,
PRIVATE_KEY_MAX,
publicImportSchema,
publicKeyZeileMitKommentar,
schluesselTypVon,
slugAus,
type BearbeitenEingabe,
type ErzeugenEingabe,
type PaarImportEingabe,
type PublicImportEingabe,
type SchluesselGeheimnis,
type SchluesselVorgangEingabe,
} from "@/lib/schluessel";
import { publicKeyZeileLesen, SchluesselFormatFehler } from "@/lib/ssh/openssh";
import type { z } from "@/lib/validierung";
import { FachFehler } from "@/server/services/fachfehler";
import { istEindeutigkeitsFehler, type Akteur } from "@/server/services/hosts";
import { protokollieren } from "@/server/services/protokoll";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
/**
* Schluessel anlegen, importieren, bearbeiten (#012).
*
* Alles, was einen Private Key beruehrt (Erzeugen, Import eines Paars), wird hier nur
* geprueft und als Vorgang an den Worker gegeben – der Private Key reist mit
* SSHKV_APP_SECRET verschluesselt im Job, nie in `Vorgang.eingabe`. Ein reiner Public Key
* ist kein Geheimnis und wird direkt hier angelegt.
*
* Jede Prisma-Abfrage auf `Schluessel` hat ein festes `select` ohne Chiffrat-Felder
* (tests/schluessel-grenze.test.ts).
*/
/** Was die Oberflaeche nach dem Anlegen braucht – und nicht mehr. */
const ANGELEGT = { id: true, slug: true, fingerprint: true } satisfies Prisma.SchluesselSelect;
function pruefen<S extends z.ZodType>(schema: S, eingabe: unknown): z.output<S> {
const ergebnis = schema.safeParse(eingabe);
if (!ergebnis.success) {
throw new FachFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error));
}
return ergebnis.data;
}
async function slugFrei(slug: string, client: Prisma.TransactionClient = prisma): Promise<boolean> {
return (await client.schluessel.findUnique({ where: { slug }, select: { id: true } })) === null;
}
/**
* Ein ausdruecklich gewuenschter Slug muss frei sein; ein aus dem Namen abgeleiteter bekommt
* bei Bedarf eine Nummer angehaengt.
*/
export async function eindeutigerSlug(wunsch: string | null, name: string): Promise<string> {
if (wunsch) {
if (!(await slugFrei(wunsch))) {
throw new FachFehler(`Der Kurzname „${wunsch}“ ist schon vergeben.`, {
slug: "Dieser Kurzname ist schon vergeben.",
});
}
return wunsch;
}
return freierSlug(slugAus(name), (kandidat) => slugFrei(kandidat));
}
/** Gibt es diesen Fingerprint schon? Fuer eine Meldung mit Verweis auf den vorhandenen Key. */
export async function schluesselMitFingerprint(fingerprint: string) {
return prisma.schluessel.findUnique({
where: { fingerprint },
select: { id: true, name: true, slug: true },
});
}
export function bereitsVerwaltet(vorhanden: { id: string; name: string }): FachFehler {
return new FachFehler(
`Dieser Schlüssel wird bereits verwaltet: „${vorhanden.name}“ (/schluessel/${vorhanden.id}).`,
{ publicKey: "Dieser Schlüssel ist schon in sshkv." },
);
}
/** Heute 00:00 UTC – Vergleichsbasis fuer „Ablaufdatum liegt nicht in der Vergangenheit“. */
function heute(jetzt = new Date()): Date {
return new Date(Date.UTC(jetzt.getUTCFullYear(), jetzt.getUTCMonth(), jetzt.getUTCDate()));
}
type Bezuege = { readonly personId: string | null; readonly vorlageId: string | null };
/**
* Prueft Person und Vorlage und setzt ein fehlendes Ablaufdatum aus der Standardlaufzeit der
* Vorlage. Exportiert, weil der Worker dieselbe Pruefung beim Ausfuehren wiederholt.
*/
export async function bezuegePruefen(
daten: Bezuege & { readonly ablaufAm: Date | null },
client: Prisma.TransactionClient = prisma,
): Promise<{ ablaufAm: Date | null }> {
if (daten.personId) {
const person = await client.person.findUnique({
where: { id: daten.personId },
select: { archiviertAm: true },
});
if (!person || person.archiviertAm) {
throw new FachFehler("Die gewählte Person gibt es nicht (mehr).", { personId: "Bitte neu wählen." });
}
}
let ablaufAm = daten.ablaufAm;
if (daten.vorlageId) {
const vorlage = await client.vorlage.findUnique({
where: { id: daten.vorlageId },
select: { archiviertAm: true, standardLaufzeitTage: true },
});
if (!vorlage || vorlage.archiviertAm) {
throw new FachFehler("Die gewählte Vorlage gibt es nicht (mehr).", { vorlageId: "Bitte neu wählen." });
}
if (!ablaufAm && vorlage.standardLaufzeitTage) {
ablaufAm = new Date(heute().getTime() + vorlage.standardLaufzeitTage * 24 * 60 * 60 * 1000);
}
}
return { ablaufAm };
}
function ablaufNichtVergangen(ablaufAm: Date | null): void {
if (ablaufAm && ablaufAm < heute()) {
throw new FachFehler("Das Ablaufdatum liegt in der Vergangenheit.", {
ablaufAm: "Bitte ein Datum ab heute wählen.",
});
}
}
function passphrasePruefen(passphrase: string | undefined): string | undefined {
if (!passphrase) return undefined;
if (passphrase.length > PASSPHRASE_MAX) {
throw new FachFehler("Die Passphrase ist zu lang.", {
passphrase: `Höchstens ${PASSPHRASE_MAX} Zeichen.`,
});
}
return passphrase;
}
// ---------------------------------------------------------------- Erzeugen und Paar-Import
/**
* Fordert ein neues Schluesselpaar an (#012). Liefert die Vorgangs-ID; die Oberflaeche
* verfolgt den Vorgang und springt nach Erfolg zum Schluessel.
*/
export async function erzeugenAnfordern(
eingabe: ErzeugenEingabe,
passphraseRoh: string | undefined,
akteur: Akteur,
): Promise<string> {
const daten = pruefen(erzeugenSchema, eingabe);
const passphrase = passphrasePruefen(passphraseRoh);
ablaufNichtVergangen(daten.ablaufAm);
const { ablaufAm } = await bezuegePruefen(daten);
const slug = await eindeutigerSlug(daten.slug, daten.name);
const vorgangsEingabe: SchluesselVorgangEingabe = {
name: daten.name,
slug,
tags: daten.tags,
ablaufAm: ablaufAm?.toISOString() ?? null,
personId: daten.personId,
vorlageId: daten.vorlageId,
kommentar: daten.kommentar,
typ: daten.typ,
mitPassphrase: Boolean(passphrase),
};
const geheimnis: SchluesselGeheimnis = passphrase ? { passphrase } : {};
return vorgangEinreihen({
art: "SCHLUESSEL_ERZEUGEN",
angefordertVonId: akteur.id,
eingabe: vorgangsEingabe,
...(passphrase ? { geheim: verschluesseln(JSON.stringify(geheimnis)) } : {}),
});
}
/**
* Fordert den Import eines Paars an (#012). Der Private Key wird hier nur grob angesehen
* (Groesse, Kopfzeile) – lesen, pruefen und versiegeln tut der Worker. Eine Passphrase dient
* dort nur der Pruefung und wird verworfen; gespeichert wird der Key, wie er kam.
*/
export async function paarImportAnfordern(
eingabe: PaarImportEingabe,
privateKey: string | undefined,
passphraseRoh: string | undefined,
akteur: Akteur,
): Promise<string> {
const daten = pruefen(paarImportSchema, eingabe);
// Wie ssh-keygen ihn schreibt: LF-Zeilenenden und genau ein Zeilenumbruch am Ende.
const text = (privateKey ?? "").replace(/\r\n?/g, "\n").trim();
if (!text) {
throw new FachFehler("Bitte den Private Key einfügen.", {
privateKey: "Bitte den Private Key einfügen.",
});
}
if (text.length > PRIVATE_KEY_MAX || !text.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----")) {
// Feste Meldung – nie etwas aus dem eingefuegten Text zurueckgeben.
throw new FachFehler("Das ist kein Private Key im OpenSSH-Format.", {
privateKey: "Erwartet wird „-----BEGIN OPENSSH PRIVATE KEY-----“ …",
});
}
const passphrase = passphrasePruefen(passphraseRoh);
ablaufNichtVergangen(daten.ablaufAm);
const { ablaufAm } = await bezuegePruefen(daten);
const slug = await eindeutigerSlug(daten.slug, daten.name);
const vorgangsEingabe: SchluesselVorgangEingabe = {
name: daten.name,
slug,
tags: daten.tags,
ablaufAm: ablaufAm?.toISOString() ?? null,
personId: daten.personId,
vorlageId: daten.vorlageId,
kommentar: daten.kommentar,
mitPassphrase: Boolean(passphrase),
};
const geheimnis: SchluesselGeheimnis = { privateKey: `${text}\n`, ...(passphrase ? { passphrase } : {}) };
return vorgangEinreihen({
art: "SCHLUESSEL_IMPORTIEREN",
angefordertVonId: akteur.id,
eingabe: vorgangsEingabe,
geheim: verschluesseln(JSON.stringify(geheimnis)),
});
}
// ---------------------------------------------------------------- Public-Key-Import
/** Liest eine Public-Key-Zeile und uebersetzt Formatfehler in eine Feldmeldung. */
export function publicKeyPruefen(zeile: string) {
try {
// Die RSA-Mindestlaenge gilt beim Import (Standard von publicKeyZeileLesen).
return publicKeyZeileLesen(zeile);
} catch (fehler) {
if (fehler instanceof SchluesselFormatFehler) {
throw new FachFehler(fehler.message, { publicKey: fehler.message });
}
throw fehler;
}
}
/**
* Legt einen Schluessel nur mit Public Key an (#012). Auch Grundlage des Self-Service
* (#013), der Kategorie, Person und `selbstHinterlegt` selbst setzt.
*/
export async function publicKeyAnlegen(
daten: {
readonly name: string;
readonly slug: string | null;
readonly kategorie: "PERSON" | "DIENST";
readonly publicKey: string;
readonly kommentar: string | null;
readonly tags: string[];
readonly ablaufAm: Date | null;
readonly personId: string | null;
readonly vorlageId: string | null;
readonly selbstHinterlegt?: boolean;
},
akteur: Akteur,
aktion = "schluessel.importiert",
) {
const info = publicKeyPruefen(daten.publicKey);
const vorhanden = await schluesselMitFingerprint(info.fingerprint);
if (vorhanden) throw bereitsVerwaltet(vorhanden);
ablaufNichtVergangen(daten.ablaufAm);
const { ablaufAm } = await bezuegePruefen(daten);
const slug = await eindeutigerSlug(daten.slug, daten.name);
const kommentar = daten.kommentar ?? (info.kommentar || `sshkv:${slug}`);
try {
return await prisma.$transaction(async (tx) => {
const schluessel = await tx.schluessel.create({
data: {
name: daten.name,
slug,
typ: schluesselTypVon(info.typ, info.bits),
kategorie: daten.kategorie,
publicKey: publicKeyZeileMitKommentar(daten.publicKey, kommentar),
fingerprint: info.fingerprint,
kommentar,
tags: daten.tags,
ablaufAm,
personId: daten.personId,
vorlageId: daten.vorlageId,
erstelltVonId: akteur.id,
selbstHinterlegt: daten.selbstHinterlegt ?? false,
},
select: ANGELEGT,
});
await protokollieren(
{
akteur,
aktion,
ergebnis: "ERFOLG",
fingerprint: schluessel.fingerprint,
details: {
name: daten.name,
slug,
kategorie: daten.kategorie,
art: "public",
algorithmus: info.typ,
},
},
tx,
);
return schluessel;
});
} catch (fehler) {
// Zwischen Pruefung und Insert kann derselbe Key oder Slug angelegt worden sein.
if (istEindeutigkeitsFehler(fehler)) {
const jetzt = await schluesselMitFingerprint(info.fingerprint);
if (jetzt) throw bereitsVerwaltet(jetzt);
throw new FachFehler("Der Kurzname wurde gerade vergeben. Bitte erneut speichern.");
}
throw fehler;
}
}
export async function publicKeyImportieren(eingabe: PublicImportEingabe, akteur: Akteur) {
const daten = pruefen(publicImportSchema, eingabe);
return publicKeyAnlegen(daten, akteur);
}
// ---------------------------------------------------------------- Bearbeiten
export async function schluesselBearbeiten(id: string, eingabe: BearbeitenEingabe, akteur: Akteur) {
const daten = pruefen(bearbeitenSchema, eingabe);
const alt = await prisma.schluessel.findUnique({
where: { id },
select: {
name: true,
kategorie: true,
status: true,
tags: true,
ablaufAm: true,
personId: true,
vorlageId: true,
fingerprint: true,
},
});
if (!alt) throw new FachFehler("Diesen Schlüssel gibt es nicht.");
if (daten.status && daten.status !== alt.status) {
if (alt.status !== "AKTIV" && alt.status !== "ABGELAUFEN") {
throw new FachFehler("Rotierte oder widerrufene Schlüssel lassen sich nicht wieder aktivieren.", {
status: "Status nicht änderbar.",
});
}
// Der Deployment-Key der Instanz wird nur ueber seine eigene Rotation ersetzt; ein
// „abgelaufener“ Deployment-Key liesse den Worker beim naechsten Start einen neuen erzeugen.
if (alt.kategorie === "DEPLOYMENT") {
throw new FachFehler("Der Status eines Deployment-Keys lässt sich hier nicht ändern.", {
status: "Status nicht änderbar.",
});
}
}
if (daten.ablaufAm?.getTime() !== alt.ablaufAm?.getTime()) ablaufNichtVergangen(daten.ablaufAm);
if (daten.personId !== alt.personId || daten.vorlageId !== alt.vorlageId) {
await bezuegePruefen({ personId: daten.personId, vorlageId: daten.vorlageId, ablaufAm: daten.ablaufAm });
}
const nachher = {
name: daten.name,
tags: daten.tags,
ablaufAm: daten.ablaufAm,
personId: daten.personId,
vorlageId: daten.vorlageId,
status: daten.status ?? alt.status,
};
const geaendert = Object.fromEntries(
Object.entries(nachher)
.map(([feld, wert]) => [feld, { vorher: alt[feld as keyof typeof nachher], nachher: wert }] as const)
.filter(([, a]) => JSON.stringify(a.vorher) !== JSON.stringify(a.nachher)),
);
return prisma.$transaction(async (tx) => {
const schluessel = await tx.schluessel.update({ where: { id }, data: nachher, select: ANGELEGT });
if (Object.keys(geaendert).length > 0) {
await protokollieren(
{
akteur,
aktion: "schluessel.geaendert",
ergebnis: "ERFOLG",
fingerprint: alt.fingerprint,
details: geaendert,
},
tx,
);
}
return schluessel;
});
}