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, widerrufEingeleitetAm: 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, widerrufEingeleitetAm: true, angelegtAm: true, personId: true, vorlageId: true, person: { select: { id: true, name: true } }, vorlage: { select: { id: true, name: true } }, erstelltVon: { select: { name: true } }, vorgaenger: { select: { id: true, name: true, status: true } }, nachfolger: { select: { id: true, name: true, status: 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); } /** * Heute 00:00 UTC nach dem Kalender in Berlin – Ablaufdaten liegen als UTC-Mitternacht ihres * Tages in der Datenbank, und `ablaufZustand()` rechnet ebenfalls mit dem Berliner Datum. */ function heute(jetzt = new Date()): Date { const tag = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin" }).format(jetzt); return new Date(`${tag}T00:00:00Z`); } /** * Status als Filter (#024): ein aktiver Schluessel mit Ablaufdatum in der Vergangenheit gilt als * abgelaufen, auch wenn niemand den Status von Hand gesetzt hat. Automatisch entfernt wird nichts. */ function statusFilter(status: SchluesselStatus | undefined): Prisma.SchluesselWhereInput { if (status === "AKTIV") return { status, OR: [{ ablaufAm: null }, { ablaufAm: { gte: heute() } }] }; if (status === "ABGELAUFEN") { return { OR: [{ status }, { status: "AKTIV", ablaufAm: { lt: heute() } }] }; } return status ? { status } : {}; } 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 } : {}), AND: [statusFilter(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>[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>>; /** Nur Slug und Public Key – fuer den Download `.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 }, }); } // ---------------------------------------------------------------- Rotationskette (#023) export type KettenGlied = { readonly id: string; readonly name: string; readonly status: SchluesselStatus }; /** * Die ganze Rotationskette eines Schluessels, aeltester zuerst. Begrenzt, damit eine (durch * Fehler entstandene) Schleife die Seite nicht aufhaengt. */ export async function rotationsKette(id: string, maximal = 25): Promise { const glied = (schluesselId: string) => prisma.schluessel.findUnique({ where: { id: schluesselId }, select: { id: true, name: true, status: true, vorgaengerId: true, nachfolger: { select: { id: true } }, }, }); const start = await glied(id); if (!start) return []; const kette: KettenGlied[] = [{ id: start.id, name: start.name, status: start.status }]; const gesehen = new Set([start.id]); for (let davor = start.vorgaengerId; davor && kette.length < maximal && !gesehen.has(davor);) { const g = await glied(davor); if (!g) break; gesehen.add(g.id); kette.unshift({ id: g.id, name: g.name, status: g.status }); davor = g.vorgaengerId; } for (let danach = start.nachfolger?.id; danach && kette.length < maximal && !gesehen.has(danach);) { const g = await glied(danach); if (!g) break; gesehen.add(g.id); kette.push({ id: g.id, name: g.name, status: g.status }); danach = g.nachfolger?.id; } return kette; }