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:
@@ -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 },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user