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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:29:52 +02:00
co-authored by Claude Opus 5.5
parent f0833aa1db
commit 263143134f
33 changed files with 3322 additions and 287 deletions
+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 },
});
}