Merge branch 'phase/012-014' (Schluessel, Personen, Private Key)

# Conflicts:
#	src/components/vorgang/useVorgang.ts
#	src/worker/handler.ts
This commit is contained in:
2026-10-12 10:54:09 +02:00
76 changed files with 5899 additions and 325 deletions
+43
View File
@@ -0,0 +1,43 @@
"use server";
import { revalidatePath } from "next/cache";
import { fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { eigenenPublicKeyHinterlegen, eigenenSchluesselEntfernen } from "@/server/services/meine-schluessel";
/**
* Self-Service (#013). Recht `eigeneSchluesselHinterlegen` hat jede Rolle; der Service
* arbeitet ausschliesslich mit der Person des angemeldeten Kontos.
*/
export type HinterlegenZustand = FormularZustand & { readonly erfolg?: string };
export async function eigenenKeyHinterlegenAktion(
_vorher: HinterlegenZustand,
formular: FormData,
): Promise<HinterlegenZustand> {
try {
const { benutzer } = await rechtErforderlich("eigeneSchluesselHinterlegen");
const neu = await eigenenPublicKeyHinterlegen(benutzer, {
name: textfeld(formular, "name") ?? "",
publicKey: textfeld(formular, "publicKey") ?? "",
});
revalidatePath("/meine-schluessel");
return { erfolg: `Hinterlegt: ${neu.fingerprint}` };
} catch (fehler) {
return fehlerZustand(fehler);
}
}
export async function eigenenKeyEntfernenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
try {
const { benutzer } = await rechtErforderlich("eigeneSchluesselHinterlegen");
await eigenenSchluesselEntfernen(benutzer, textfeld(formular, "id") ?? "");
revalidatePath("/meine-schluessel");
return {};
} catch (fehler) {
return fehlerZustand(fehler);
}
}
+68
View File
@@ -0,0 +1,68 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import type { PersonEingabe } from "@/lib/personen";
import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { personAnlegen, personArchivieren, personBearbeiten } from "@/server/services/personen";
/**
* Formular-Aktionen fuer Personen (#013). Personen pflegt, wer Schluessel verwaltet (Admin,
* Operator) – eine Person ist die Klammer um Personen-Keys.
*/
function personFelder(formular: FormData): PersonEingabe {
return {
name: textfeld(formular, "name") ?? "",
email: textfeld(formular, "email"),
organisation: textfeld(formular, "organisation"),
notiz: textfeld(formular, "notiz"),
benutzerId: textfeld(formular, "benutzerId"),
};
}
export async function personAnlegenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
let id: string;
try {
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
({ id } = await personAnlegen(personFelder(formular), akteurVon(benutzer)));
} catch (fehler) {
return fehlerZustand(fehler);
}
revalidatePath("/personen");
redirect(`/personen/${id}`);
}
export async function personBearbeitenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
const id = textfeld(formular, "id") ?? "";
try {
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
await personBearbeiten(id, personFelder(formular), akteurVon(benutzer));
} catch (fehler) {
return fehlerZustand(fehler);
}
revalidatePath("/personen", "layout");
redirect(`/personen/${id}`);
}
export async function personArchivierenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
const id = textfeld(formular, "id") ?? "";
try {
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
await personArchivieren(id, akteurVon(benutzer));
} catch (fehler) {
return fehlerZustand(fehler);
}
revalidatePath("/personen", "layout");
redirect("/personen");
}
+70
View File
@@ -0,0 +1,70 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { akteurVon } from "@/server/actions/formular";
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { HostFehler } from "@/server/services/hosts";
import {
abholungAnfordern,
privateKeyAbholen,
privateKeyLoeschenAnfordern,
} from "@/server/services/privatekey";
/**
* Private Key anzeigen und loeschen (#014). Jede Aktion prueft selbst Recht **und** frische
* Bestaetigung (`rechtErforderlich` mit einem Recht aus BESTAETIGUNG_NOETIG). Fehlt die
* Bestaetigung, meldet die Antwort `bestaetigen: true`, und die Oberflaeche leitet nach
* /bestaetigen weiter.
*
* `privateKeyAnzeigenAktion` ist die einzige Stelle, an der ein Private Key als Antwort an den
* Browser geht – genau einmal, fuer die Anzeige im Dialog.
*/
export type PrivateKeyAntwort = {
readonly vorgangId?: string;
readonly fehler?: string;
readonly bestaetigen?: boolean;
readonly privateKey?: string;
};
const id = z.string().min(1).max(64);
const zweck = z.enum(["anzeigen", "herunterladen"]);
function fehlerAntwort(fehler: unknown): PrivateKeyAntwort {
if (fehler instanceof BestaetigungFehler) return { fehler: fehler.message, bestaetigen: true };
if (fehler instanceof RechteFehler || fehler instanceof HostFehler) return { fehler: fehler.message };
if (fehler instanceof z.ZodError) return { fehler: "Ungültige Anfrage." };
throw fehler;
}
export async function abholungAnfordernAktion(schluesselId: string, art: string): Promise<PrivateKeyAntwort> {
try {
const { benutzer } = await rechtErforderlich("privateKeyAnzeigen");
return {
vorgangId: await abholungAnfordern(id.parse(schluesselId), zweck.parse(art), akteurVon(benutzer)),
};
} catch (fehler) {
return fehlerAntwort(fehler);
}
}
export async function privateKeyAnzeigenAktion(vorgangId: string): Promise<PrivateKeyAntwort> {
try {
const { benutzer } = await rechtErforderlich("privateKeyAnzeigen");
const { privateKey } = await privateKeyAbholen(id.parse(vorgangId), "anzeigen", akteurVon(benutzer));
return { privateKey };
} catch (fehler) {
return fehlerAntwort(fehler);
}
}
export async function privateKeyLoeschenAktion(schluesselId: string): Promise<PrivateKeyAntwort> {
try {
const { benutzer } = await rechtErforderlich("privateKeyLoeschen");
const vorgangId = await privateKeyLoeschenAnfordern(id.parse(schluesselId), akteurVon(benutzer));
revalidatePath(`/schluessel/${schluesselId}`);
return { vorgangId };
} catch (fehler) {
return fehlerAntwort(fehler);
}
}
+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}`);
}
+4
View File
@@ -16,6 +16,7 @@ export type Recht =
| "publicKeyAusrollen"
| "privateKeyAusrollen"
| "privateKeyAnzeigen"
| "privateKeyLoeschen"
| "deploymentKeyVerwalten"
| "massenwiderruf"
| "zielkontenVerwalten"
@@ -33,6 +34,8 @@ export const RECHTE: Readonly<Record<Recht, readonly Rolle[]>> = {
// Zugriff hat, kann ihn faktisch lesen – deshalb nur Admins.
privateKeyAusrollen: ["ADMIN"],
privateKeyAnzeigen: ["ADMIN"],
// Endgueltig und nicht rueckgaengig zu machen (#014) – wie Anzeigen nur mit Re-Auth.
privateKeyLoeschen: ["ADMIN"],
deploymentKeyVerwalten: ["ADMIN"],
massenwiderruf: ["ADMIN"],
zielkontenVerwalten: ["ADMIN"],
@@ -42,6 +45,7 @@ export const RECHTE: Readonly<Record<Recht, readonly Rolle[]>> = {
/** Rechte, die zusaetzlich eine frische Bestaetigung (Re-Auth) verlangen. */
export const BESTAETIGUNG_NOETIG: ReadonlySet<Recht> = new Set<Recht>([
"privateKeyAnzeigen",
"privateKeyLoeschen",
"deploymentKeyVerwalten",
"massenwiderruf",
"zielkontenVerwalten",
+103
View File
@@ -0,0 +1,103 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
/**
* Lesemodelle fuer Personen (#013). Jede angemeldete Rolle darf sie sehen; Schluessel nur mit
* festem `select` ohne Chiffrat-Felder.
*/
export type PersonFilter = { readonly q?: string; readonly archivierte?: boolean };
export async function personenListe(filter: PersonFilter = {}) {
const q = filter.q?.trim().slice(0, 100);
const where: Prisma.PersonWhereInput = {
...(filter.archivierte ? {} : { archiviertAm: null }),
...(q
? {
OR: [
{ name: { contains: q, mode: "insensitive" } },
{ email: { contains: q, mode: "insensitive" } },
{ organisation: { contains: q, mode: "insensitive" } },
],
}
: {}),
};
const personen = await prisma.person.findMany({
where,
orderBy: { name: "asc" },
select: {
id: true,
name: true,
email: true,
organisation: true,
archiviertAm: true,
benutzer: { select: { id: true, name: true } },
_count: { select: { schluessel: { where: { status: "AKTIV" } } } },
},
});
return personen.map(({ _count, ...rest }) => ({ ...rest, aktiveSchluessel: _count.schluessel }));
}
export type PersonListenEintrag = Awaited<ReturnType<typeof personenListe>>[number];
export async function personDetail(id: string) {
const person = await prisma.person.findUnique({
where: { id },
select: {
id: true,
name: true,
email: true,
organisation: true,
notiz: true,
archiviertAm: true,
angelegtAm: true,
benutzerId: true,
benutzer: { select: { id: true, name: true, email: true, rolle: true } },
schluessel: {
orderBy: [{ status: "asc" }, { name: "asc" }],
select: {
id: true,
name: true,
typ: true,
kategorie: true,
status: true,
publicKey: true,
fingerprint: true,
ablaufAm: true,
selbstHinterlegt: true,
_count: {
select: {
publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
},
},
},
},
},
});
if (!person) return null;
return {
...person,
schluessel: person.schluessel.map(({ _count, ...rest }) => ({
...rest,
verwendungen: _count.publicVerwendungen + _count.privateVerwendungen,
})),
};
}
export type PersonDetail = NonNullable<Awaited<ReturnType<typeof personDetail>>>;
/**
* Konten, die sich mit einer Person verknuepfen lassen: noch keiner Person zugeordnet, oder
* der Person `personId` selbst. Der Notfall-Admin ist kein Mensch mit eigenen Keys.
*/
export async function kontenAuswahl(personId?: string) {
return prisma.benutzer.findMany({
where: {
istNotfall: false,
OR: [{ person: null }, ...(personId ? [{ person: { id: personId } }] : [])],
},
orderBy: { name: "asc" },
select: { id: true, name: true, email: true },
});
}
+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 },
});
}
+2
View File
@@ -73,6 +73,8 @@ export async function oidcBenutzerUebernehmen(anspruecke: OidcAnspruecke): Promi
const daten = {
oidcSub: anspruecke.sub,
email: anspruecke.email,
// Fuer die Verknuepfung im Self-Service (#013).
emailBestaetigt: anspruecke.emailBestaetigt,
name: anspruecke.name,
rolle,
letzteAnmeldungAm: new Date(),
+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";
}
}
+195
View File
@@ -0,0 +1,195 @@
import type { Benutzer } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { feldfehlerAus } from "@/lib/hosts";
import { eigenerKeySchema, type EigenerKeyEingabe } from "@/lib/personen";
import { FachFehler } from "@/server/services/fachfehler";
import { istEindeutigkeitsFehler } from "@/server/services/hosts";
import { protokollieren } from "@/server/services/protokoll";
import { publicKeyAnlegen } from "@/server/services/schluessel";
/**
* Self-Service „Meine Schluessel“ (#013, Freigabe Frage 4): jeder angemeldete Benutzer
* hinterlegt eigene Public Keys. Wirksam werden sie erst, wenn ein Operator oder Admin sie
* einem Host zuweist – hier wird nichts ausgerollt.
*
* Jede Funktion nimmt den angemeldeten Benutzer und arbeitet nur mit **seiner** Person; eine
* Schluessel-ID von aussen wird immer gegen sie geprueft.
*/
type Konto = Pick<Benutzer, "id" | "name" | "email" | "emailBestaetigt">;
/**
* Liefert die Person des Kontos und legt sie beim ersten Aufruf an. Verknuepft per
* `benutzerId`, sonst per E-Mail – aber nur, wenn der Anbieter die Adresse bestaetigt hat und
* genau eine freie, nicht archivierte Person dazu passt.
*/
export async function eigenePersonSicherstellen(konto: Konto): Promise<{ id: string; name: string }> {
const vorhanden = await prisma.person.findUnique({
where: { benutzerId: konto.id },
select: { id: true, name: true },
});
if (vorhanden) return vorhanden;
const akteur = { id: konto.id, name: konto.name };
try {
if (konto.email && konto.emailBestaetigt) {
const passend = await prisma.person.findMany({
where: {
email: { equals: konto.email, mode: "insensitive" },
benutzerId: null,
archiviertAm: null,
},
select: { id: true },
take: 2,
});
// Bedingt: hat inzwischen jemand anderes die Person verknuepft, aendert sich nichts,
// und das Konto bekommt unten eine eigene Person.
const verknuepft =
passend.length === 1
? await prisma.person.updateMany({
where: { id: passend[0]!.id, benutzerId: null },
data: { benutzerId: konto.id },
})
: { count: 0 };
if (verknuepft.count === 1) {
const person = await prisma.person.findUniqueOrThrow({
where: { benutzerId: konto.id },
select: { id: true, name: true },
});
await protokollieren({
akteur,
aktion: "person.verknuepft",
ergebnis: "ERFOLG",
details: { person: person.name, ueber: "email" },
});
return person;
}
}
const person = await prisma.person.create({
data: { name: konto.name, email: konto.email, benutzerId: konto.id },
select: { id: true, name: true },
});
await protokollieren({
akteur,
aktion: "person.angelegt",
ergebnis: "ERFOLG",
details: { name: konto.name, ueber: "selbstauskunft" },
});
return person;
} catch (fehler) {
// Zwei gleichzeitige erste Aufrufe: der zweite findet die Person des ersten.
if (istEindeutigkeitsFehler(fehler)) {
return prisma.person.findUniqueOrThrow({
where: { benutzerId: konto.id },
select: { id: true, name: true },
});
}
throw fehler;
}
}
/** Die eigenen Schluessel – mit genau den Feldern, die die Seite zeigt. */
export async function eigeneSchluessel(konto: Konto) {
const person = await eigenePersonSicherstellen(konto);
const schluessel = await prisma.schluessel.findMany({
where: { personId: person.id },
orderBy: { angelegtAm: "desc" },
select: {
id: true,
name: true,
typ: true,
status: true,
publicKey: true,
fingerprint: true,
kommentar: true,
selbstHinterlegt: true,
angelegtAm: true,
_count: {
select: {
publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
},
},
},
});
return {
person,
schluessel: schluessel.map(({ _count, ...rest }) => ({
...rest,
verwendungen: _count.publicVerwendungen + _count.privateVerwendungen,
})),
};
}
export async function eigenenPublicKeyHinterlegen(konto: Konto, eingabe: EigenerKeyEingabe) {
const ergebnis = eigenerKeySchema.safeParse(eingabe);
if (!ergebnis.success) {
throw new FachFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error));
}
const person = await eigenePersonSicherstellen(konto);
return publicKeyAnlegen(
{
name: ergebnis.data.name,
slug: null,
kategorie: "PERSON",
publicKey: ergebnis.data.publicKey,
kommentar: null,
tags: [],
ablaufAm: null,
personId: person.id,
vorlageId: null,
selbstHinterlegt: true,
},
{ id: konto.id, name: konto.name },
"schluessel.selbst_hinterlegt",
);
}
/**
* Entfernt einen eigenen, selbst hinterlegten Key, solange er nirgends zugewiesen ist – auch
* keine entfernte Verwendung, damit der Verlauf am Key nicht verloren geht. Fremde Keys sind
* fuer diese Funktion „nicht vorhanden“, damit sie nichts ueber fremde IDs verraet.
*/
export async function eigenenSchluesselEntfernen(konto: Konto, schluesselId: string): Promise<void> {
const person = await eigenePersonSicherstellen(konto);
const schluessel = await prisma.schluessel.findFirst({
where: { id: schluesselId, personId: person.id },
select: {
id: true,
name: true,
fingerprint: true,
selbstHinterlegt: true,
_count: { select: { publicVerwendungen: true, privateVerwendungen: true } },
},
});
if (!schluessel) throw new FachFehler("Diesen Schlüssel gibt es unter Ihren Schlüsseln nicht.");
if (!schluessel.selbstHinterlegt) {
throw new FachFehler(
"Diesen Schlüssel hat ein Operator oder Admin angelegt – bitte dort entfernen lassen.",
);
}
if (schluessel._count.publicVerwendungen + schluessel._count.privateVerwendungen > 0) {
throw new FachFehler(
"Der Schlüssel ist schon einem Host zugewiesen. Bitte einen Operator oder Admin bitten.",
);
}
await prisma.$transaction(async (tx) => {
// Bedingt geloescht: weist ein Operator den Key gerade zu, darf das Loeschen die neue
// Verwendung nicht per Cascade mitnehmen.
const geloescht = await tx.schluessel.deleteMany({
where: { id: schluessel.id, publicVerwendungen: { none: {} }, privateVerwendungen: { none: {} } },
});
if (geloescht.count !== 1) {
throw new FachFehler("Der Schlüssel wurde gerade zugewiesen und bleibt deshalb bestehen.");
}
await protokollieren(
{
akteur: { id: konto.id, name: konto.name },
aktion: "schluessel.selbst_entfernt",
ergebnis: "ERFOLG",
fingerprint: schluessel.fingerprint,
details: { name: schluessel.name },
},
tx,
);
});
}
+139
View File
@@ -0,0 +1,139 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { feldfehlerAus } from "@/lib/hosts";
import { personEingabeSchema, type PersonDaten, type PersonEingabe } from "@/lib/personen";
import { FachFehler } from "@/server/services/fachfehler";
import { istEindeutigkeitsFehler, type Akteur } from "@/server/services/hosts";
import { protokollieren } from "@/server/services/protokoll";
/**
* Personen anlegen, bearbeiten, archivieren (#013).
*
* Archivieren statt Loeschen: an einer Person haengen Schluessel und Protokoll-Bezuege. Die
* Schluessel bleiben der archivierten Person zugeordnet – wer bis wann welchen Key hatte, soll
* nachvollziehbar bleiben. Widerrufen ist ein eigener Schritt (#024).
*/
function pruefen(eingabe: PersonEingabe): PersonDaten {
const ergebnis = personEingabeSchema.safeParse(eingabe);
if (!ergebnis.success) {
throw new FachFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error));
}
return ergebnis.data;
}
function benutzerVergeben(): FachFehler {
return new FachFehler("Dieses Konto ist schon mit einer anderen Person verknüpft.", {
benutzerId: "Schon mit einer anderen Person verknüpft.",
});
}
/** Das gewaehlte Konto muss existieren und frei sein (oder schon zu dieser Person gehoeren). */
async function benutzerPruefen(
tx: Prisma.TransactionClient,
benutzerId: string | null,
personId?: string,
): Promise<string | null> {
if (!benutzerId) return null;
const benutzer = await tx.benutzer.findUnique({
where: { id: benutzerId },
select: { name: true, person: { select: { id: true } } },
});
if (!benutzer)
throw new FachFehler("Das gewählte Konto gibt es nicht.", { benutzerId: "Bitte neu wählen." });
if (benutzer.person && benutzer.person.id !== personId) throw benutzerVergeben();
return benutzer.name;
}
export async function personAnlegen(eingabe: PersonEingabe, akteur: Akteur) {
const daten = pruefen(eingabe);
try {
return await prisma.$transaction(async (tx) => {
const konto = await benutzerPruefen(tx, daten.benutzerId);
const person = await tx.person.create({ data: daten, select: { id: true, name: true } });
await protokollieren(
{
akteur,
aktion: "person.angelegt",
ergebnis: "ERFOLG",
details: { name: daten.name, email: daten.email, organisation: daten.organisation, konto },
},
tx,
);
return person;
});
} catch (fehler) {
if (istEindeutigkeitsFehler(fehler)) throw benutzerVergeben();
throw fehler;
}
}
export async function personBearbeiten(id: string, eingabe: PersonEingabe, akteur: Akteur) {
const daten = pruefen(eingabe);
const alt = await prisma.person.findUnique({
where: { id },
select: {
name: true,
email: true,
organisation: true,
notiz: true,
benutzerId: true,
archiviertAm: true,
},
});
if (!alt) throw new FachFehler("Diese Person gibt es nicht.");
if (alt.archiviertAm) throw new FachFehler("Archivierte Personen lassen sich nicht bearbeiten.");
const geaendert = Object.fromEntries(
(Object.keys(daten) as (keyof PersonDaten)[])
.filter((feld) => daten[feld] !== alt[feld])
// Die Notiz kann Persoenliches enthalten – im Protokoll steht nur, dass sie sich aenderte.
.map((feld) => [feld, feld === "notiz" ? "geändert" : { vorher: alt[feld], nachher: daten[feld] }]),
);
try {
return await prisma.$transaction(async (tx) => {
await benutzerPruefen(tx, daten.benutzerId, id);
const person = await tx.person.update({ where: { id }, data: daten, select: { id: true, name: true } });
if (Object.keys(geaendert).length > 0) {
await protokollieren(
{ akteur, aktion: "person.geaendert", ergebnis: "ERFOLG", details: geaendert },
tx,
);
}
return person;
});
} catch (fehler) {
if (istEindeutigkeitsFehler(fehler)) throw benutzerVergeben();
throw fehler;
}
}
/**
* Archiviert eine Person. Die Verknuepfung zum Konto faellt weg, damit der Self-Service beim
* naechsten Aufruf nicht wieder bei einer archivierten Person landet.
*/
export async function personArchivieren(id: string, akteur: Akteur) {
const person = await prisma.person.findUnique({
where: { id },
select: { name: true, archiviertAm: true, _count: { select: { schluessel: true } } },
});
if (!person) throw new FachFehler("Diese Person gibt es nicht.");
if (person.archiviertAm) throw new FachFehler("Diese Person ist bereits archiviert.");
return prisma.$transaction(async (tx) => {
const archiviert = await tx.person.update({
where: { id },
data: { archiviertAm: new Date(), benutzerId: null },
select: { id: true },
});
await protokollieren(
{
akteur,
aktion: "person.archiviert",
ergebnis: "ERFOLG",
details: { name: person.name, schluessel: person._count.schluessel },
},
tx,
);
return archiviert;
});
}
+123
View File
@@ -0,0 +1,123 @@
import { prisma } from "@/lib/db";
import { entschluesseln } from "@/lib/geheimnisse";
import { ABHOLFRIST_MS } from "@/lib/schluessel";
import { FachFehler } from "@/server/services/fachfehler";
import type { Akteur } from "@/server/services/hosts";
import { protokollieren } from "@/server/services/protokoll";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
/**
* Private Key anzeigen, herunterladen, loeschen (#014). Die Rechte (`privateKeyAnzeigen`,
* `privateKeyLoeschen`, beide nur Admin mit frischer Bestaetigung) prueft die Action bzw. der
* Route Handler; hier stehen die fachlichen Regeln.
*
* Der Web-Prozess hat keinen Master-Key. Er bittet den Worker per Vorgang, den Key zu oeffnen;
* der legt ihn mit SSHKV_APP_SECRET verschluesselt in `Vorgang.ergebnis` ab, und
* `privateKeyAbholen` holt ihn genau einmal ab. Der Key steht nie im Protokoll, in einem Log
* oder einer Fehlermeldung – nur Fingerprint und Name.
*/
export type Zweck = "anzeigen" | "herunterladen";
/** Felder fuer die Pruefung – ob ein Key da ist, verraet `masterKeyVersion`, nie das Chiffrat. */
async function schluesselMitPrivateKey(schluesselId: string) {
const schluessel = await prisma.schluessel.findUnique({
where: { id: schluesselId },
select: { id: true, name: true, slug: true, kategorie: true, fingerprint: true, masterKeyVersion: true },
});
if (!schluessel) throw new FachFehler("Diesen Schlüssel gibt es nicht.");
if (schluessel.masterKeyVersion === null) {
throw new FachFehler("Zu diesem Schlüssel ist kein Private Key gespeichert.");
}
// Der Deployment-Key ist root auf allen Hosts; er verlaesst sshkv nie. Im Notfall wird er
// rotiert, nicht exportiert.
if (schluessel.kategorie === "DEPLOYMENT") {
throw new FachFehler(
"Den Private Key eines Deployment-Keys gibt sshkv nicht heraus und löscht ihn nicht.",
);
}
return schluessel;
}
/** Bittet den Worker, den Private Key zur einmaligen Abholung bereitzulegen. */
export async function abholungAnfordern(schluesselId: string, zweck: Zweck, akteur: Akteur): Promise<string> {
const schluessel = await schluesselMitPrivateKey(schluesselId);
const vorgangId = await vorgangEinreihen({
art: "PRIVATE_KEY_ABHOLEN",
angefordertVonId: akteur.id,
schluesselId: schluessel.id,
eingabe: { zweck },
});
await protokollieren({
akteur,
aktion: "privatekey.angefordert",
ergebnis: "ERFOLG",
fingerprint: schluessel.fingerprint,
details: { name: schluessel.name, zweck },
});
return vorgangId;
}
type Abholung = { ergebnis: unknown; beendetAm: Date | null; schluesselId: string | null };
/**
* Holt den bereitgelegten Private Key genau einmal ab: Lesen und Ueberschreiben geschehen in
* einer Anweisung unter Zeilensperre, ein zweiter Aufruf findet nichts mehr. Nur wer den
* Vorgang angefordert hat, kann abholen – und nur innerhalb der Abholfrist.
*/
export async function privateKeyAbholen(
vorgangId: string,
zweck: Zweck,
akteur: Akteur,
jetzt: Date = new Date(),
): Promise<{ privateKey: string; slug: string; name: string; fingerprint: string }> {
const [zeile] = await prisma.$queryRaw<Abholung[]>`
WITH alt AS (
SELECT "id", "ergebnis", "beendetAm", "schluesselId" FROM "Vorgang"
WHERE "id" = ${vorgangId}
AND "art" = 'PRIVATE_KEY_ABHOLEN'
AND "status" = 'ERLEDIGT'
AND "angefordertVonId" = ${akteur.id}
AND "ergebnis" ? 'abholung'
FOR UPDATE
)
UPDATE "Vorgang" v SET "ergebnis" = '{"abgeholt": true}'::jsonb
FROM alt WHERE v."id" = alt."id"
RETURNING alt."ergebnis", alt."beendetAm", alt."schluesselId"`;
if (!zeile || !zeile.schluesselId) {
throw new FachFehler("Nichts abzuholen – der Private Key wurde schon abgeholt oder ist verfallen.");
}
if (!zeile.beendetAm || jetzt.getTime() - zeile.beendetAm.getTime() > ABHOLFRIST_MS) {
throw new FachFehler("Die Abholung ist verfallen. Bitte den Private Key erneut anfordern.");
}
const chiffrat = (zeile.ergebnis as { abholung?: unknown }).abholung;
if (typeof chiffrat !== "string") throw new FachFehler("Die Abholung ist unvollständig.");
const schluessel = await prisma.schluessel.findUniqueOrThrow({
where: { id: zeile.schluesselId },
select: { name: true, slug: true, fingerprint: true },
});
const privateKey = entschluesseln(chiffrat);
await protokollieren({
akteur,
aktion: zweck === "anzeigen" ? "privatekey.angezeigt" : "privatekey.heruntergeladen",
ergebnis: "ERFOLG",
fingerprint: schluessel.fingerprint,
details: { name: schluessel.name },
});
return { privateKey, ...schluessel };
}
/**
* Fordert das endgueltige Loeschen des gespeicherten Private Keys an (#014, „Private Key aus
* SSHKeyVault loeschen“). Ausgefuehrt im Worker – nur src/worker/geheimnisse.ts fasst die
* Chiffrat-Felder an, auch zum Leeren.
*/
export async function privateKeyLoeschenAnfordern(schluesselId: string, akteur: Akteur): Promise<string> {
const schluessel = await schluesselMitPrivateKey(schluesselId);
return vorgangEinreihen({
art: "PRIVATE_KEY_LOESCHEN",
angefordertVonId: akteur.id,
schluesselId: schluessel.id,
});
}
+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;
});
}
+13 -1
View File
@@ -35,8 +35,20 @@ export async function vorgangEinreihen(daten: VorgangAnlegen): Promise<string> {
return vorgang.id;
}
/** Was die Oberflaeche ueber einen Vorgang erfahren darf – ohne Job-Interna. */
/**
* Was die Oberflaeche ueber einen Vorgang erfahren darf – ohne Job-Interna. Eine bereitliegende
* Private-Key-Abholung (#014) wird nie ausgeliefert, auch nicht verschluesselt: abgeholt wird
* nur ueber src/server/services/privatekey.ts.
*/
export async function vorgangLesen(id: string) {
const vorgang = await vorgangRoh(id);
if (vorgang?.art !== "PRIVATE_KEY_ABHOLEN") return vorgang;
const ergebnis = vorgang.ergebnis;
const bereit = typeof ergebnis === "object" && ergebnis !== null && "abholung" in ergebnis;
return { ...vorgang, ergebnis: bereit ? { bereit: true } : ergebnis };
}
async function vorgangRoh(id: string) {
return prisma.vorgang.findUnique({
where: { id },
select: {