Merge branch 'phase/012-014' (Schluessel, Personen, Private Key)
# Conflicts: # src/components/vorgang/useVorgang.ts # src/worker/handler.ts
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user