feat(personen): Personen und Self-Service Meine Schluessel (#013)

Personen lassen sich anlegen, bearbeiten und archivieren; ihr Detail
zeigt ihre Schluessel. Unter "Meine Schluessel" hinterlegt jede Rolle
eigene Public Keys (Freigabe Frage 4), wirksam erst nach Zuweisung
durch einen Operator oder Admin.

Warum so:
- Die eigene Person entsteht beim ersten Aufruf oder wird verknuepft:
  per benutzerId, sonst per E-Mail - aber nur, wenn der OIDC-Anbieter
  die Adresse bestaetigt hat. Dafuer merkt sich Benutzer jetzt
  emailBestaetigt (Migration); sonst koennte jemand mit selbst
  gewaehlter Adresse die Keys einer fremden Person sehen.
- Die Self-Service-Services nehmen nur den angemeldeten Benutzer und
  leiten die Person selbst ab; fremde Schluessel-IDs gelten als nicht
  vorhanden.
- Entfernen nur fuer eigene, selbst hinterlegte Keys ohne Verwendung,
  bedingt geloescht, damit eine gleichzeitige Zuweisung nicht per
  Cascade verschwindet.
- Archivieren statt Loeschen: wer bis wann welchen Key hatte, bleibt
  nachvollziehbar.

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