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:
@@ -72,3 +72,23 @@ test("Leser sieht Schlüssel, darf aber keine anlegen", async ({ page }) => {
|
||||
await page.goto("/schluessel/neu");
|
||||
await expect(page).toHaveURL(/\/kein-zugriff/);
|
||||
});
|
||||
|
||||
test("Leser hinterlegt einen eigenen Public Key und entfernt ihn wieder", async ({ page }) => {
|
||||
const name = `${eindeutig()} Laptop`;
|
||||
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "reader@laptop" });
|
||||
await oidcAnmelden(page, "reader");
|
||||
await page.getByRole("link", { name: "Meine Schlüssel" }).first().click();
|
||||
await expect(page.getByRole("heading", { name: "Meine Schlüssel" })).toBeVisible();
|
||||
await expect(page.getByText(/Wirksam erst, wenn ein Operator oder Admin/).first()).toBeVisible();
|
||||
|
||||
await page.getByLabel("Bezeichnung").fill(name);
|
||||
await page.getByLabel("Public Key").fill(paar.publicKeyZeile);
|
||||
await page.getByRole("button", { name: "Hinterlegen" }).click();
|
||||
await expect(page.getByText(`Hinterlegt: ${paar.fingerprint}`)).toBeVisible();
|
||||
const eintrag = page.locator("li", { hasText: name });
|
||||
await expect(eintrag.getByText("noch nicht zugewiesen")).toBeVisible();
|
||||
|
||||
await eintrag.getByRole("button", { name: `${name} entfernen` }).click();
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Entfernen", exact: true }).click();
|
||||
await expect(page.locator("li", { hasText: name })).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { Benutzer } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { kontenAuswahl, personDetail, personenListe } from "@/server/queries/personen";
|
||||
import { FachFehler } from "@/server/services/fachfehler";
|
||||
import {
|
||||
eigenePersonSicherstellen,
|
||||
eigenenPublicKeyHinterlegen,
|
||||
eigenenSchluesselEntfernen,
|
||||
eigeneSchluessel,
|
||||
} from "@/server/services/meine-schluessel";
|
||||
import { personAnlegen, personArchivieren, personBearbeiten } from "@/server/services/personen";
|
||||
import { publicKeyImportieren } from "@/server/services/schluessel";
|
||||
import { datenbankLeeren } from "./hilfen/db";
|
||||
import { warteschlangeStoppen } from "./hilfen/vorgang";
|
||||
import { ed25519Blob, zeile } from "./ssh/hilfen";
|
||||
|
||||
/** Personen und Self-Service „Meine Schluessel“ (#013). */
|
||||
|
||||
let operator: Benutzer;
|
||||
let leser: Benutzer;
|
||||
let andererLeser: Benutzer;
|
||||
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
operator = await prisma.benutzer.create({ data: { name: "Olga Operator", rolle: "OPERATOR" } });
|
||||
leser = await prisma.benutzer.create({
|
||||
data: { name: "Lea Leser", rolle: "LESER", email: "lea@example.org", emailBestaetigt: true },
|
||||
});
|
||||
andererLeser = await prisma.benutzer.create({
|
||||
data: { name: "Max Mitleser", rolle: "LESER", email: "max@example.org", emailBestaetigt: true },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await warteschlangeStoppen();
|
||||
});
|
||||
|
||||
async function fachfehlerVon(versuch: Promise<unknown>): Promise<FachFehler> {
|
||||
const fehler = await versuch.then(
|
||||
() => undefined,
|
||||
(f: unknown) => f,
|
||||
);
|
||||
expect(fehler).toBeInstanceOf(FachFehler);
|
||||
return fehler as FachFehler;
|
||||
}
|
||||
|
||||
const akteur = () => ({ id: operator.id, name: operator.name });
|
||||
const neueZeile = (kommentar = "test") => zeile("ssh-ed25519", ed25519Blob(), kommentar);
|
||||
|
||||
describe("Personen verwalten", () => {
|
||||
it("legt an, bearbeitet, archiviert und protokolliert", async () => {
|
||||
const person = await personAnlegen(
|
||||
{ name: "Anna Berger", email: "A.Berger@Example.org", organisation: "Netzwerk GmbH" },
|
||||
akteur(),
|
||||
);
|
||||
const detail = await personDetail(person.id);
|
||||
expect(detail).toMatchObject({ email: "a.berger@example.org", organisation: "Netzwerk GmbH" });
|
||||
|
||||
await personBearbeiten(
|
||||
person.id,
|
||||
{ name: "Anna Berger", email: "a.berger@example.org", notiz: "privat" },
|
||||
akteur(),
|
||||
);
|
||||
const geaendert = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "person.geaendert" } });
|
||||
// Die Notiz steht nicht im Protokoll, nur dass sie sich aenderte.
|
||||
expect(JSON.stringify(geaendert.details)).not.toContain("privat");
|
||||
expect(geaendert.details).toMatchObject({ notiz: "geändert", organisation: { vorher: "Netzwerk GmbH" } });
|
||||
|
||||
await personArchivieren(person.id, akteur());
|
||||
expect((await personenListe()).map((p) => p.name)).toEqual([]);
|
||||
expect((await personenListe({ archivierte: true })).map((p) => p.name)).toEqual(["Anna Berger"]);
|
||||
await fachfehlerVon(personBearbeiten(person.id, { name: "X" }, akteur()));
|
||||
expect(await prisma.protokoll.count({ where: { aktion: { startsWith: "person." } } })).toBe(3);
|
||||
});
|
||||
|
||||
it("prueft E-Mail und verknuepft ein Konto nur einmal", async () => {
|
||||
const fehler = await fachfehlerVon(personAnlegen({ name: "X", email: "keine-mail" }, akteur()));
|
||||
expect(fehler.feldfehler["email"]).toBeDefined();
|
||||
|
||||
await personAnlegen({ name: "Lea", benutzerId: leser.id }, akteur());
|
||||
const doppelt = await fachfehlerVon(personAnlegen({ name: "Lea 2", benutzerId: leser.id }, akteur()));
|
||||
expect(doppelt.feldfehler["benutzerId"]).toBeDefined();
|
||||
expect((await kontenAuswahl()).map((k) => k.name)).toEqual(["Max Mitleser", "Olga Operator"]);
|
||||
});
|
||||
|
||||
it("loest beim Archivieren die Kontoverknuepfung", async () => {
|
||||
const person = await personAnlegen({ name: "Lea", benutzerId: leser.id }, akteur());
|
||||
await personArchivieren(person.id, akteur());
|
||||
expect((await personDetail(person.id))?.benutzerId).toBeNull();
|
||||
// Der Self-Service landet danach bei einer neuen Person, nicht bei der archivierten.
|
||||
expect((await eigenePersonSicherstellen(leser)).id).not.toBe(person.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Eigene Person", () => {
|
||||
it("wird beim ersten Aufruf angelegt und danach wiederverwendet", async () => {
|
||||
const [a, b] = await Promise.all([eigenePersonSicherstellen(leser), eigenePersonSicherstellen(leser)]);
|
||||
expect(a.id).toBe(b.id);
|
||||
expect(await prisma.person.count()).toBe(1);
|
||||
expect((await personDetail(a.id))?.benutzerId).toBe(leser.id);
|
||||
});
|
||||
|
||||
it("verknuepft per bestaetigter E-Mail mit einer vorhandenen Person", async () => {
|
||||
const person = await personAnlegen({ name: "Lea Leser (IT)", email: "LEA@example.org" }, akteur());
|
||||
expect((await eigenePersonSicherstellen(leser)).id).toBe(person.id);
|
||||
expect(await prisma.protokoll.count({ where: { aktion: "person.verknuepft" } })).toBe(1);
|
||||
});
|
||||
|
||||
it("verknuepft nicht ueber eine unbestaetigte Adresse", async () => {
|
||||
const person = await personAnlegen({ name: "Chefin", email: "chefin@example.org" }, akteur());
|
||||
const angreifer = await prisma.benutzer.create({
|
||||
data: { name: "Mallory", rolle: "LESER", email: "chefin@example.org", emailBestaetigt: false },
|
||||
});
|
||||
expect((await eigenePersonSicherstellen(angreifer)).id).not.toBe(person.id);
|
||||
expect((await personDetail(person.id))?.benutzerId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Meine Schluessel", () => {
|
||||
it("hinterlegt eigene Public Keys als Personen-Keys", async () => {
|
||||
const neu = await eigenenPublicKeyHinterlegen(leser, {
|
||||
name: "Laptop",
|
||||
publicKey: neueZeile("lea@laptop"),
|
||||
});
|
||||
const schluessel = await prisma.schluessel.findUniqueOrThrow({
|
||||
where: { id: neu.id },
|
||||
select: {
|
||||
kategorie: true,
|
||||
selbstHinterlegt: true,
|
||||
personId: true,
|
||||
erstelltVonId: true,
|
||||
masterKeyVersion: true,
|
||||
},
|
||||
});
|
||||
const person = await eigenePersonSicherstellen(leser);
|
||||
expect(schluessel).toEqual({
|
||||
kategorie: "PERSON",
|
||||
selbstHinterlegt: true,
|
||||
personId: person.id,
|
||||
erstelltVonId: leser.id,
|
||||
masterKeyVersion: null,
|
||||
});
|
||||
const eintrag = await prisma.protokoll.findFirstOrThrow({
|
||||
where: { aktion: "schluessel.selbst_hinterlegt" },
|
||||
});
|
||||
expect(eintrag.akteurId).toBe(leser.id);
|
||||
});
|
||||
|
||||
it("zeigt nur eigene Keys und laesst fremde nicht entfernen", async () => {
|
||||
const eigener = await eigenenPublicKeyHinterlegen(leser, { name: "Meiner", publicKey: neueZeile() });
|
||||
const fremder = await eigenenPublicKeyHinterlegen(andererLeser, {
|
||||
name: "Fremd",
|
||||
publicKey: neueZeile(),
|
||||
});
|
||||
|
||||
expect((await eigeneSchluessel(leser)).schluessel.map((s) => s.name)).toEqual(["Meiner"]);
|
||||
expect((await eigeneSchluessel(andererLeser)).schluessel.map((s) => s.name)).toEqual(["Fremd"]);
|
||||
|
||||
const fehler = await fachfehlerVon(eigenenSchluesselEntfernen(leser, fremder.id));
|
||||
expect(fehler.message).toMatch(/unter Ihren Schlüsseln nicht/);
|
||||
expect(await prisma.schluessel.count({ where: { id: fremder.id } })).toBe(1);
|
||||
|
||||
await eigenenSchluesselEntfernen(leser, eigener.id);
|
||||
expect(await prisma.schluessel.count({ where: { id: eigener.id } })).toBe(0);
|
||||
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.selbst_entfernt" } })).toBe(1);
|
||||
});
|
||||
|
||||
it("entfernt keine zugewiesenen und keine von Operatoren angelegten Keys", async () => {
|
||||
const zugewiesen = await eigenenPublicKeyHinterlegen(leser, {
|
||||
name: "Zugewiesen",
|
||||
publicKey: neueZeile(),
|
||||
});
|
||||
const host = await prisma.host.create({ data: { name: "srv-test01", adresse: "10.0.0.1" } });
|
||||
await prisma.publicVerwendung.create({
|
||||
data: { schluesselId: zugewiesen.id, hostId: host.id, zielbenutzer: "lea" },
|
||||
});
|
||||
await fachfehlerVon(eigenenSchluesselEntfernen(leser, zugewiesen.id));
|
||||
|
||||
const person = await eigenePersonSicherstellen(leser);
|
||||
const vomOperator = await publicKeyImportieren(
|
||||
{ name: "Vom Operator", kategorie: "PERSON", publicKey: neueZeile(), personId: person.id },
|
||||
akteur(),
|
||||
);
|
||||
await fachfehlerVon(eigenenSchluesselEntfernen(leser, vomOperator.id));
|
||||
expect(await prisma.schluessel.count()).toBe(2);
|
||||
});
|
||||
|
||||
it("meldet Duplikate auch im Self-Service", async () => {
|
||||
const z = neueZeile();
|
||||
await eigenenPublicKeyHinterlegen(leser, { name: "Einmal", publicKey: z });
|
||||
const fehler = await fachfehlerVon(
|
||||
eigenenPublicKeyHinterlegen(andererLeser, { name: "Nochmal", publicKey: z }),
|
||||
);
|
||||
expect(fehler.message).toMatch(/bereits verwaltet/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rechte (Freigabe Frage 4)", () => {
|
||||
it("Leser darf eigene Keys hinterlegen, aber weder verwalten noch zuweisen", () => {
|
||||
expect(darf("LESER", "eigeneSchluesselHinterlegen")).toBe(true);
|
||||
expect(darf("LESER", "schluesselVerwalten")).toBe(false);
|
||||
expect(darf("LESER", "publicKeyAusrollen")).toBe(false);
|
||||
});
|
||||
|
||||
it("die Self-Service-Aktionen verlangen das Self-Service-Recht und reichen nur den eigenen Benutzer weiter", () => {
|
||||
const text = readFileSync("src/server/actions/meine-schluessel.ts", "utf8");
|
||||
expect(text.match(/rechtErforderlich\("eigeneSchluesselHinterlegen"\)/g)).toHaveLength(2);
|
||||
expect(text).not.toMatch(/personId/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user