feat(benutzer): Benutzerverwaltung, Passwort ändern, Audit-Log-Ansicht

Admins verwalten Benutzer (anlegen, Rolle, sperren, Passwort setzen); jeder ändert sein Passwort; das Audit-Log ist filterbar einsehbar. Sperre und Passwortwechsel beenden ältere Sitzungen über Benutzer.sitzungenAb. Der letzte Admin und das eigene Konto sind geschützt.

Closes #015

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 16:49:47 +02:00
co-authored by Claude Opus 5.5
parent 7f6cf00a0e
commit 99357d59bd
21 changed files with 1292 additions and 22 deletions
+5
View File
@@ -8,6 +8,9 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht]
### Hinzugefügt
- Benutzerverwaltung für Admins: anlegen, Rolle ändern, sperren und entsperren, Passwort neu setzen; der letzte Admin und das eigene Konto sind geschützt (#015)
- Eigenes Passwort in den Einstellungen ändern (#015)
- Audit-Log-Ansicht für Admins, filterbar nach Benutzer, Quelle, Text und Zeitraum; auch `GET /api/v1/protokoll` (#015)
- Verlauf filterbar nach Server, Typ, Distribution und Status, mit Seitenblättern; die Filter stehen in der URL. Auch per API und `proxdeploy history` (#014)
- „Wiederholen“: der Assistent übernimmt alle Werte eines früheren Auftrags, Name, VMID und feste IP gibt man neu an (#014)
- „Aufräumen“ nach Fehlschlag oder Abbruch: löscht nach Bestätigung den halb angelegten Gast – nur, wenn seine Beschreibung auf den Auftrag verweist; Templates nie. Web, API und `proxdeploy cleanup` (#014)
@@ -15,11 +18,13 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Geändert
### Behoben
- „Einstellungen“ fehlte in der Navigation (#015)
- Erzeugte SSH-Schlüssel (je Server und Bereitstellungsschlüssel) waren in etwa 4 von 1000 Fällen für ssh2 selbst nicht lesbar; jetzt wird geprüft und neu erzeugt (#014)
### Entfernt
### Sicherheit
- Sperren, Passwort-Reset und Passwortwechsel beenden alle bestehenden Sitzungen des Benutzers (#015)
## [0.4.0] – 2026-10-12
+12 -5
View File
@@ -1,5 +1,5 @@
# #015 Rollen, Benutzer, Tokens, Audit-Log
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.5.0
**Labels:** feature, sicherheit
**Geschätzt:** 6 h **Tatsächlich:** –
@@ -8,16 +8,23 @@
Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
## Aufgaben
- [ ] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test)
- [ ] Benutzerverwaltung, CLI-Tokens
- [x] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test) – seit #001, jetzt mit den neuen Routen
- [x] Benutzerverwaltung (anlegen, Rolle, sperren, Passwort setzen), eigenes Passwort ändern; CLI-Tokens seit v0.4.0
- [x] SSH-Keys über `SchluesselQuelle` – vorgezogen in v0.3.0: eigene und Team-Schlüssel, automatisch in jedem Gast, im Assistenten abwählbar
- [ ] Audit-Log-Ansicht mit Filter
- [x] Audit-Log-Ansicht mit Filter (Benutzer, Quelle, Text, Zeitraum), auch `GET /api/v1/protokoll`
## Akzeptanzkriterien
- [ ] Operator kann keine Server/Katalog/Benutzer ändern (Tests)
- [x] Operator kann keine Server/Katalog/Benutzer ändern (Tests in `tests/benutzer.test.ts`)
## Abhängigkeiten
#001
## Notizen / Entscheidungen
- **Sitzungen beenden ohne Sitzungstabelle:** `Benutzer.sitzungenAb`; ältere JWTs gelten nicht mehr.
Gesetzt bei Sperre, Passwort-Reset und eigenem Passwortwechsel (die eigene Sitzung bekommt ein
neues Cookie). Auf volle Sekunden, weil `iat` Sekunden hat.
- **Schutzregeln serverseitig:** der letzte aktive Admin bleibt Admin und aktiv (Zeilensperre gegen
zwei gleichzeitige Herabstufungen), das eigene Konto lässt sich nicht sperren.
- Benutzer werden nicht gelöscht, nur gesperrt – Aufträge und Audit-Log verweisen auf sie.
- „Einstellungen“ fehlte seit v0.3.0 in der Navigation (`ab: "#015"`) – jetzt sichtbar, dazu „Audit-Log“ für Admins.
- SSH-Schlüssel auf Florians Frage nach Schlüsseln im Template vorgezogen (2026-10-12): Schlüssel gehören in die Verwaltung, nicht ins Template. `SchluesselQuelle` ist die Andockstelle für eine externe Verwaltung (z. B. sshkeyvault).
+1 -1
View File
@@ -18,7 +18,7 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#012](012-vorlagen.md) | Vorlagen (Presets) | erledigt | v0.3.0 | 3 h | 0 h 11 min |
| [#013](013-cli.md) | CLI proxdeploy (Go) | erledigt | v0.4.0 | 12 h | 0 h 42 min |
| [#014](014-verlauf.md) | Verlauf und Wiederholen | erledigt | v0.5.0 | 4 h | – |
| [#015](015-rollen-audit.md) | Rollen, Benutzer, Tokens, Audit-Log | offen | v0.5.0 | 6 h | – |
| [#015](015-rollen-audit.md) | Rollen, Benutzer, Tokens, Audit-Log | erledigt | v0.5.0 | 6 h | – |
| [#016](016-oidc.md) | Anmeldung per OIDC | offen | v0.5.0 | 4 h | – |
| [#017](017-branding.md) | Branding-Assets | offen | v0.6.0 | 4 h | – |
| [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – |
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Benutzer" ADD COLUMN "sitzungenAb" TIMESTAMP(3);
+2
View File
@@ -33,6 +33,8 @@ model Benutzer {
oidcSubjekt String? @unique
rolle Rolle @default(OPERATOR)
aktiv Boolean @default(true)
/// Sitzungen, die vorher ausgestellt wurden, gelten nicht mehr (Passwortwechsel, Sperre).
sitzungenAb DateTime?
/// Persönliche Standardwerte im Assistenten.
standards Json?
erstelltAm DateTime @default(now())
@@ -0,0 +1,258 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { EINGABE_KLASSE, Feld } from "@/components/formular";
import { Karte } from "@/components/karte";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
type Benutzer = {
id: string;
benutzername: string;
anzeigename: string;
email: string | null;
rolle: "ADMIN" | "OPERATOR";
aktiv: boolean;
passwort: boolean;
oidc: boolean;
tokens: number;
};
type Meldung = { art: "erledigt" | "fehler"; text: string; felder?: Record<string, string> } | null;
const leer = { benutzername: "", anzeigename: "", email: "", rolle: "OPERATOR" as const, passwort: "" };
/**
* Benutzer anlegen, Rolle ändern, sperren, Passwort neu setzen. Admin: Server, Katalog, Benutzer;
* Operator: ansehen und ausrollen. Das eigene Konto lässt sich nicht sperren, der letzte Admin
* nicht herabstufen – das prüft der Server.
*/
export function BenutzerVerwaltung({ anfang, ich }: { anfang: Benutzer[]; ich: string }) {
const router = useRouter();
const [neu, setNeu] = useState<{
benutzername: string;
anzeigename: string;
email: string;
rolle: "ADMIN" | "OPERATOR";
passwort: string;
}>(leer);
const [meldung, setMeldung] = useState<Meldung>(null);
const [passwortFuer, setPasswortFuer] = useState<{ id: string; passwort: string } | null>(null);
const fehlerZeigen = (fehler: unknown) =>
setMeldung({
art: "fehler",
text:
fehler instanceof ApiFehler ? [fehler.message, fehler.hinweis].filter(Boolean).join(" ") : "Fehler.",
felder: fehler instanceof ApiFehler ? fehler.felder : undefined,
});
async function aendern(b: Benutzer, aenderung: Partial<Pick<Benutzer, "rolle" | "aktiv">>, text: string) {
try {
await apiAufruf("PATCH", `/api/v1/benutzer/${b.id}`, aenderung);
setMeldung({ art: "erledigt", text });
router.refresh();
} catch (fehler) {
fehlerZeigen(fehler);
}
}
async function anlegen(ereignis: React.FormEvent) {
ereignis.preventDefault();
try {
await apiAufruf("POST", "/api/v1/benutzer", neu);
setMeldung({ art: "erledigt", text: `${neu.benutzername} angelegt.` });
setNeu(leer);
router.refresh();
} catch (fehler) {
fehlerZeigen(fehler);
}
}
async function passwortSetzen(ereignis: React.FormEvent, b: Benutzer) {
ereignis.preventDefault();
if (!passwortFuer) return;
try {
await apiAufruf("POST", `/api/v1/benutzer/${b.id}/passwort`, { passwort: passwortFuer.passwort });
setPasswortFuer(null);
setMeldung({
art: "erledigt",
text: `Passwort von ${b.benutzername} gesetzt – alle Sitzungen beendet.`,
});
router.refresh();
} catch (fehler) {
fehlerZeigen(fehler);
}
}
return (
<Karte titel="Benutzer">
<div className="flex flex-col gap-5">
<ul className="divide-linie flex flex-col divide-y">
{anfang.map((b) => (
<li key={b.id} className="flex flex-col gap-3 py-3 first:pt-0">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<p className="font-medium">
{b.anzeigename} <span className="text-muted font-mono text-sm">· {b.benutzername}</span>
{b.benutzername === ich ? <span className="text-muted text-sm"> · du</span> : null}
</p>
<p className="text-muted text-xs">
{[
b.email,
b.passwort ? "Passwort" : null,
b.oidc ? "OIDC" : null,
b.tokens > 0 ? `${b.tokens} API-Token${b.tokens === 1 ? "" : "s"}` : null,
]
.filter(Boolean)
.join(" · ") || "–"}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{!b.aktiv ? <Status art="fehler">gesperrt</Status> : null}
<label className="sr-only" htmlFor={`rolle-${b.id}`}>
Rolle von {b.benutzername}
</label>
<select
id={`rolle-${b.id}`}
className={`${EINGABE_KLASSE} w-36`}
value={b.rolle}
onChange={(e) =>
void aendern(
b,
{ rolle: e.target.value as Benutzer["rolle"] },
`${b.benutzername} ist jetzt ${e.target.value === "ADMIN" ? "Admin" : "Operator"}.`,
)
}
>
<option value="OPERATOR">Operator</option>
<option value="ADMIN">Admin</option>
</select>
<Knopf
variante="sekundaer"
onClick={() =>
setPasswortFuer(passwortFuer?.id === b.id ? null : { id: b.id, passwort: "" })
}
aria-label={`Passwort von ${b.benutzername} setzen`}
>
Passwort
</Knopf>
{b.benutzername !== ich ? (
<Knopf
variante={b.aktiv ? "gefahr" : "sekundaer"}
onClick={() =>
void aendern(
b,
{ aktiv: !b.aktiv },
b.aktiv
? `${b.benutzername} gesperrt – Sitzungen beendet.`
: `${b.benutzername} entsperrt.`,
)
}
aria-label={`${b.benutzername} ${b.aktiv ? "sperren" : "entsperren"}`}
>
{b.aktiv ? "Sperren" : "Entsperren"}
</Knopf>
) : null}
</div>
</div>
{passwortFuer?.id === b.id ? (
<form
onSubmit={(e) => void passwortSetzen(e, b)}
className="flex flex-col gap-2 sm:flex-row sm:items-end"
>
<Feld
id={`pw-${b.id}`}
label={`Neues Passwort für ${b.benutzername}`}
hilfe="Mindestens 12 Zeichen"
>
<input
id={`pw-${b.id}`}
type="password"
autoComplete="new-password"
className={`${EINGABE_KLASSE} sm:w-72`}
value={passwortFuer.passwort}
onChange={(e) => setPasswortFuer({ id: b.id, passwort: e.target.value })}
/>
</Feld>
<Knopf type="submit" disabled={passwortFuer.passwort.length < 12}>
Setzen
</Knopf>
</form>
) : null}
</li>
))}
</ul>
<div aria-live="polite" className="text-sm">
{meldung ? <Status art={meldung.art}>{meldung.text}</Status> : null}
</div>
<form onSubmit={(e) => void anlegen(e)} className="border-linie flex flex-col gap-4 border-t pt-5">
<p className="font-semibold">Neuer Benutzer</p>
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="b-name" label="Benutzername" fehler={meldung?.felder?.["benutzername"]}>
<input
id="b-name"
autoComplete="off"
className={EINGABE_KLASSE}
value={neu.benutzername}
onChange={(e) => setNeu({ ...neu, benutzername: e.target.value })}
/>
</Feld>
<Feld id="b-anzeige" label="Anzeigename" fehler={meldung?.felder?.["anzeigename"]}>
<input
id="b-anzeige"
className={EINGABE_KLASSE}
value={neu.anzeigename}
onChange={(e) => setNeu({ ...neu, anzeigename: e.target.value })}
/>
</Feld>
<Feld id="b-email" label="E-Mail (optional)" fehler={meldung?.felder?.["email"]}>
<input
id="b-email"
type="email"
className={EINGABE_KLASSE}
value={neu.email}
onChange={(e) => setNeu({ ...neu, email: e.target.value })}
/>
</Feld>
<Feld id="b-rolle" label="Rolle">
<select
id="b-rolle"
className={EINGABE_KLASSE}
value={neu.rolle}
onChange={(e) => setNeu({ ...neu, rolle: e.target.value as Benutzer["rolle"] })}
>
<option value="OPERATOR">Operator – ansehen und ausrollen</option>
<option value="ADMIN">Admin – auch Server, Katalog, Benutzer</option>
</select>
</Feld>
<Feld
id="b-passwort"
label="Erstes Passwort"
hilfe="Mindestens 12 Zeichen; der Benutzer kann es in den Einstellungen ändern."
fehler={meldung?.felder?.["passwort"]}
>
<input
id="b-passwort"
type="password"
autoComplete="new-password"
className={EINGABE_KLASSE}
value={neu.passwort}
onChange={(e) => setNeu({ ...neu, passwort: e.target.value })}
/>
</Feld>
</div>
<div>
<Knopf type="submit" disabled={!neu.benutzername || !neu.anzeigename || neu.passwort.length < 12}>
Benutzer anlegen
</Knopf>
</div>
</form>
</div>
</Karte>
);
}
+12 -7
View File
@@ -1,31 +1,36 @@
import type { Metadata } from "next";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { benutzerListe } from "@/server/services/benutzer";
import { schluesselFuer } from "@/server/services/schluessel";
import { tokensVon } from "@/server/services/tokens";
import { BenutzerVerwaltung } from "./benutzer-verwaltung";
import { PasswortAendern } from "./passwort-aendern";
import { SchluesselVerwaltung } from "./schluessel-verwaltung";
import { TokenVerwaltung } from "./token-verwaltung";
export const metadata: Metadata = { title: "Einstellungen" };
/** Einstellungen. Heute: SSH-Schlüssel und CLI-Tokens. Benutzer und Standardwerte folgen mit #015. */
/** Einstellungen: eigene SSH-Schlüssel, CLI-Tokens, Passwort; für Admins die Benutzer. */
export default async function Einstellungen() {
const benutzer = await seiteSchuetzen("eigeneZugaenge", "/einstellungen");
const admin = darf(benutzer.rolle, "benutzerVerwalten");
return (
<div className="flex flex-col gap-6">
<header>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Einstellungen</h1>
<p className="text-muted text-sm">Angemeldet als {benutzer.benutzername}</p>
<p className="text-muted text-sm">
Angemeldet als {benutzer.benutzername} · {benutzer.rolle === "ADMIN" ? "Admin" : "Operator"}
</p>
</header>
<SchluesselVerwaltung
anfang={await schluesselFuer(benutzer)}
teamErlaubt={darf(benutzer.rolle, "benutzerVerwalten")}
/>
<SchluesselVerwaltung anfang={await schluesselFuer(benutzer)} teamErlaubt={admin} />
<TokenVerwaltung
anfang={await tokensVon(benutzer.id)}
adresse={oeffentlicheAdresse("/", "http://localhost:3000").origin}
/>
{benutzer.passwortHash ? <PasswortAendern /> : null}
{admin ? <BenutzerVerwaltung anfang={await benutzerListe()} ich={benutzer.benutzername} /> : null}
</div>
);
}
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { EINGABE_KLASSE, Feld } from "@/components/formular";
import { Karte } from "@/components/karte";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
/** Eigenes Passwort ändern. Andere Sitzungen (andere Geräte) enden, diese bleibt angemeldet. */
export function PasswortAendern() {
const [werte, setWerte] = useState({ alt: "", neu: "", wiederholt: "" });
const [meldung, setMeldung] = useState<{
art: "erledigt" | "fehler";
text: string;
felder?: Record<string, string>;
} | null>(null);
const abweichend = werte.wiederholt !== "" && werte.neu !== werte.wiederholt;
async function speichern(ereignis: React.FormEvent) {
ereignis.preventDefault();
try {
await apiAufruf("POST", "/api/v1/ich/passwort", { alt: werte.alt, neu: werte.neu });
setWerte({ alt: "", neu: "", wiederholt: "" });
setMeldung({ art: "erledigt", text: "Passwort geändert. Andere Sitzungen sind beendet." });
} catch (fehler) {
setMeldung({
art: "fehler",
text: fehler instanceof ApiFehler ? fehler.message : "Fehler.",
felder: fehler instanceof ApiFehler ? fehler.felder : undefined,
});
}
}
return (
<Karte titel="Passwort">
<form onSubmit={(e) => void speichern(e)} className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-3">
<Feld id="pw-alt" label="Bisheriges Passwort" fehler={meldung?.felder?.["alt"]}>
<input
id="pw-alt"
type="password"
autoComplete="current-password"
className={EINGABE_KLASSE}
value={werte.alt}
onChange={(e) => setWerte({ ...werte, alt: e.target.value })}
/>
</Feld>
<Feld
id="pw-neu"
label="Neues Passwort"
hilfe="Mindestens 12 Zeichen"
fehler={meldung?.felder?.["neu"]}
>
<input
id="pw-neu"
type="password"
autoComplete="new-password"
className={EINGABE_KLASSE}
value={werte.neu}
onChange={(e) => setWerte({ ...werte, neu: e.target.value })}
/>
</Feld>
<Feld
id="pw-wieder"
label="Neues Passwort wiederholen"
fehler={abweichend ? "Stimmt nicht überein." : undefined}
>
<input
id="pw-wieder"
type="password"
autoComplete="new-password"
className={EINGABE_KLASSE}
value={werte.wiederholt}
onChange={(e) => setWerte({ ...werte, wiederholt: e.target.value })}
/>
</Feld>
</div>
<div className="flex flex-wrap items-center gap-3">
<Knopf
type="submit"
disabled={!werte.alt || werte.neu.length < 12 || werte.neu !== werte.wiederholt}
>
Passwort ändern
</Knopf>
<div aria-live="polite" className="text-sm">
{meldung ? <Status art={meldung.art}>{meldung.text}</Status> : null}
</div>
</div>
</form>
</Karte>
);
}
+172
View File
@@ -0,0 +1,172 @@
import type { Metadata } from "next";
import Link from "next/link";
import { EINGABE_KLASSE, Feld } from "@/components/formular";
import { Karte } from "@/components/karte";
import { Status } from "@/components/status";
import { prisma } from "@/lib/db";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { protokollFilterAus, protokollSuchen } from "@/server/services/protokoll";
export const metadata: Metadata = { title: "Audit-Log" };
type Suche = Record<string, string | string[] | undefined>;
const QUELLEN = { WEB: "Web", CLI: "CLI", API: "API", SYSTEM: "System" } as const;
/**
* Audit-Log: jede Aktion mit Benutzer, Quelle und Adresse. Nur lesbar – die Datenbank verweigert
* Ändern und Löschen per Trigger. Der Filter ist ein schlichtes GET-Formular, der Stand steht in der URL.
*/
export default async function Protokoll({ searchParams }: { searchParams: Promise<Suche> }) {
await seiteSchuetzen("protokollLesen", "/protokoll");
const filter = protokollFilterAus(await searchParams);
const [{ eintraege, gesamt, seite, seiten }, benutzer] = await Promise.all([
protokollSuchen(filter),
prisma.benutzer.findMany({ select: { benutzername: true }, orderBy: { benutzername: "asc" } }),
]);
const seitenLink = (ziel: number) => {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(filter)) if (v !== undefined && k !== "seite") p.set(k, String(v));
if (ziel > 1) p.set("seite", String(ziel));
return `/protokoll${p.size > 0 ? `?${p.toString()}` : ""}`;
};
return (
<div className="flex flex-col gap-6">
<header className="flex flex-wrap items-end justify-between gap-2">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Audit-Log</h1>
<p className="text-muted text-sm">{gesamt} Einträge</p>
</header>
<form
method="get"
action="/protokoll"
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:flex lg:items-end"
>
<Feld id="p-suche" label="Suche" hilfe="Aktion oder Ziel">
<input
id="p-suche"
name="suche"
defaultValue={filter.suche ?? ""}
className={`${EINGABE_KLASSE} lg:w-48`}
/>
</Feld>
<Feld id="p-benutzer" label="Benutzer">
<select
id="p-benutzer"
name="benutzer"
defaultValue={filter.benutzer ?? ""}
className={`${EINGABE_KLASSE} lg:w-40`}
>
<option value="">alle</option>
{benutzer.map((b) => (
<option key={b.benutzername}>{b.benutzername}</option>
))}
</select>
</Feld>
<Feld id="p-quelle" label="Quelle">
<select
id="p-quelle"
name="quelle"
defaultValue={filter.quelle ?? ""}
className={`${EINGABE_KLASSE} lg:w-32`}
>
<option value="">alle</option>
{Object.entries(QUELLEN).map(([wert, text]) => (
<option key={wert} value={wert}>
{text}
</option>
))}
</select>
</Feld>
<Feld id="p-von" label="Von">
<input
id="p-von"
name="von"
type="date"
defaultValue={filter.von ?? ""}
className={`${EINGABE_KLASSE} lg:w-40`}
/>
</Feld>
<Feld id="p-bis" label="Bis">
<input
id="p-bis"
name="bis"
type="date"
defaultValue={filter.bis ?? ""}
className={`${EINGABE_KLASSE} lg:w-40`}
/>
</Feld>
<div className="flex items-end gap-3">
<button
type="submit"
className="bg-akzent text-akzent-text inline-flex h-11 items-center rounded-lg px-4 text-sm font-semibold hover:opacity-90 sm:h-10"
>
Filtern
</button>
<Link href="/protokoll" className="text-muted hover:text-text text-sm">
Zurücksetzen
</Link>
</div>
</form>
<Karte>
{eintraege.length === 0 ? (
<Status art="offen">Keine Einträge.</Status>
) : (
<ol className="divide-linie flex flex-col divide-y" aria-label="Einträge">
{eintraege.map((e) => (
<li key={e.id} className="flex flex-col gap-1 py-3 first:pt-0 sm:flex-row sm:gap-4">
<time className="text-muted shrink-0 font-mono text-xs sm:w-36 sm:pt-0.5" dateTime={e.zeit}>
{new Date(e.zeit).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "medium" })}
</time>
<div className="min-w-0 flex-1">
<p>
<span className="font-medium">{e.aktion}</span>
{e.ziel ? (
<span className="text-muted font-mono text-sm break-all"> · {e.ziel}</span>
) : null}
</p>
<p className="text-muted text-xs">
{e.benutzer ?? "–"} · {QUELLEN[e.quelle]}
{e.ip ? ` · ${e.ip}` : ""}
</p>
{e.details ? (
<details className="mt-1">
<summary className="text-muted cursor-pointer text-xs">Details</summary>
<pre className="bg-grund border-linie mt-1 overflow-x-auto rounded-lg border p-2 font-mono text-xs">
{JSON.stringify(e.details, null, 2)}
</pre>
</details>
) : null}
</div>
</li>
))}
</ol>
)}
</Karte>
{seiten > 1 ? (
<nav aria-label="Seiten" className="flex items-center justify-between gap-4 text-sm">
{seite > 1 ? (
<Link className="text-akzent hover:underline" href={seitenLink(seite - 1)}>
← Neuere
</Link>
) : (
<span />
)}
<span className="text-muted">
Seite {seite} von {seiten}
</span>
{seite < seiten ? (
<Link className="text-akzent hover:underline" href={seitenLink(seite + 1)}>
Ältere →
</Link>
) : (
<span />
)}
</nav>
) : null}
</div>
);
}
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { passwortZuruecksetzen } from "@/server/services/benutzer";
type Kontext = { params: Promise<{ id: string }> };
/** Admin setzt ein neues Passwort `{ passwort }`; alle Sitzungen des Benutzers enden. */
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "benutzerVerwalten");
await passwortZuruecksetzen(
(await params).id,
await jsonLesen(anfrage),
aufrufer,
aufruferAdresse(anfrage.headers),
);
return new NextResponse(null, { status: 204 });
});
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { benutzerAendern } from "@/server/services/benutzer";
type Kontext = { params: Promise<{ id: string }> };
/** `{ anzeigename?, email?, rolle?, aktiv? }` – Sperren beendet alle Sitzungen. */
export const PATCH = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "benutzerVerwalten");
return NextResponse.json(
await benutzerAendern(
(await params).id,
await jsonLesen(anfrage),
aufrufer,
aufruferAdresse(anfrage.headers),
),
);
});
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { benutzerAnlegen, benutzerListe } from "@/server/services/benutzer";
export const GET = api(async (anfrage: Request) => {
await rechtErforderlich(anfrage, "benutzerVerwalten");
return NextResponse.json(await benutzerListe());
});
/** `{ benutzername, anzeigename, email?, rolle, passwort }` */
export const POST = api(async (anfrage: Request) => {
const aufrufer = await rechtErforderlich(anfrage, "benutzerVerwalten");
return NextResponse.json(
await benutzerAnlegen(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers)),
{ status: 201 },
);
});
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { aufruferAdresse, istHttps } from "@/lib/adressen";
import { SITZUNG_COOKIE, SITZUNG_LAUFZEIT_STUNDEN, sitzungErzeugen } from "@/lib/sitzung";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { eigenesPasswortAendern } from "@/server/services/benutzer";
/**
* Eigenes Passwort ändern `{ alt, neu }`. Alle anderen Sitzungen enden; die aufrufende Web-Sitzung
* bekommt ein frisches Cookie, damit man angemeldet bleibt.
*/
export const POST = api(async (anfrage: Request) => {
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
await eigenesPasswortAendern(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers));
const antwort = new NextResponse(null, { status: 204 });
if (aufrufer.quelle === "WEB") {
antwort.cookies.set(
SITZUNG_COOKIE,
await sitzungErzeugen({ benutzerId: aufrufer.benutzer.id, herkunft: "passwort" }),
{
httpOnly: true,
sameSite: "lax",
secure: istHttps(anfrage.headers, anfrage.url),
path: "/",
maxAge: SITZUNG_LAUFZEIT_STUNDEN * 3600,
},
);
}
return antwort;
});
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { protokollFilterAus, protokollSuchen } from "@/server/services/protokoll";
/** Audit-Log, neueste zuerst: `?benutzer=&quelle=&suche=&von=YYYY-MM-DD&bis=YYYY-MM-DD&seite=`. Nur Admins. */
export const GET = api(async (anfrage: Request) => {
await rechtErforderlich(anfrage, "protokollLesen");
const filter = protokollFilterAus(Object.fromEntries(new URL(anfrage.url).searchParams));
return NextResponse.json(await protokollSuchen(filter));
});
+2
View File
@@ -9,6 +9,7 @@ import {
LogOut,
Menu,
Rocket,
ScrollText,
Server,
Settings,
Layers,
@@ -33,6 +34,7 @@ const SYMBOLE: Record<
vorlagen: FileStack,
katalog: BookOpen,
server: Server,
protokoll: ScrollText,
einstellungen: Settings,
};
+4 -8
View File
@@ -14,8 +14,9 @@ export type NavigationsEintrag = {
| "vorlagen"
| "katalog"
| "server"
| "protokoll"
| "einstellungen";
recht: "lesen" | "ausrollen" | "serverVerwalten" | "katalogVerwalten" | "eigeneZugaenge";
recht: "lesen" | "ausrollen" | "serverVerwalten" | "katalogVerwalten" | "eigeneZugaenge" | "protokollLesen";
/** Erscheint erst, wenn das Issue umgesetzt ist – bis dahin nicht anzeigen statt ins Leere führen. */
ab?: string;
};
@@ -28,13 +29,8 @@ export const NAVIGATION: readonly NavigationsEintrag[] = [
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen" },
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" },
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" },
{
pfad: "/einstellungen",
titel: "Einstellungen",
symbol: "einstellungen",
recht: "eigeneZugaenge",
ab: "#015",
},
{ pfad: "/protokoll", titel: "Audit-Log", symbol: "protokoll", recht: "protokollLesen" },
{ pfad: "/einstellungen", titel: "Einstellungen", symbol: "einstellungen", recht: "eigeneZugaenge" },
];
/** Aktiv ist ein Eintrag auch für Unterseiten – außer der Übersicht, sonst wäre sie immer aktiv. */
+4 -1
View File
@@ -19,7 +19,10 @@ async function benutzerAusSitzung(jwt: string | undefined): Promise<Benutzer | n
const sitzung = await sitzungLesen(jwt);
if (!sitzung) return null;
const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } });
return benutzer?.aktiv ? benutzer : null;
if (!benutzer?.aktiv) return null;
// Nach Passwortwechsel oder Sperre gelten ältere Sitzungen nicht mehr.
if (benutzer.sitzungenAb && sitzung.ausgestelltAm * 1000 < benutzer.sitzungenAb.getTime()) return null;
return benutzer;
}
/** Für Server Components. */
+265
View File
@@ -0,0 +1,265 @@
import type { Benutzer, Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { passwortHashen, passwortPruefen, PASSWORT_MINDESTLAENGE } from "@/lib/passwort";
import { z } from "@/lib/validierung";
import type { Aufrufer } from "@/server/auth/zugang";
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
import { protokollieren } from "@/server/services/protokoll";
/**
* Benutzerverwaltung (nur Admins) und das eigene Passwort. Zwei Schutzregeln: der letzte aktive
* Admin bleibt Admin und aktiv, und niemand sperrt sich selbst aus. Passwortwechsel und Sperre
* beenden alle bestehenden Sitzungen (`sitzungenAb`); API-Tokens prüfen den Aktiv-Status je Anfrage.
*/
export type BenutzerSicht = {
id: string;
benutzername: string;
anzeigename: string;
email: string | null;
rolle: "ADMIN" | "OPERATOR";
aktiv: boolean;
/** Lokales Passwort gesetzt (sonst nur OIDC). */
passwort: boolean;
oidc: boolean;
erstelltAm: string;
tokens: number;
};
const Passwort = z
.string()
.min(PASSWORT_MINDESTLAENGE, `Mindestens ${PASSWORT_MINDESTLAENGE} Zeichen.`)
.max(1024, "Höchstens 1024 Zeichen.");
export const BenutzerAnlegen = z.object({
benutzername: z
.string()
.trim()
.toLowerCase()
.regex(
/^[a-z][a-z0-9._-]{1,31}$/,
"2–32 Zeichen: Kleinbuchstaben, Ziffern, Punkt, _ und -; vorne ein Buchstabe.",
),
anzeigename: z.string().trim().min(1, "Anzeigename fehlt.").max(80),
email: z
.email("Keine gültige E-Mail-Adresse.")
.trim()
.optional()
.or(z.literal("").transform(() => undefined)),
rolle: z.enum(["ADMIN", "OPERATOR"]).default("OPERATOR"),
passwort: Passwort,
});
export const BenutzerAendern = z.object({
anzeigename: z.string().trim().min(1, "Anzeigename fehlt.").max(80).optional(),
email: z
.email("Keine gültige E-Mail-Adresse.")
.trim()
.nullable()
.optional()
.or(z.literal("").transform(() => null)),
rolle: z.enum(["ADMIN", "OPERATOR"]).optional(),
aktiv: z.boolean().optional(),
});
const MIT_TOKENS = { _count: { select: { tokens: true } } } as const;
function sicht(b: Benutzer & { _count: { tokens: number } }): BenutzerSicht {
return {
id: b.id,
benutzername: b.benutzername,
anzeigename: b.anzeigename,
email: b.email,
rolle: b.rolle,
aktiv: b.aktiv,
passwort: Boolean(b.passwortHash),
oidc: Boolean(b.oidcSubjekt),
erstelltAm: b.erstelltAm.toISOString(),
tokens: b._count.tokens,
};
}
/** Auf volle Sekunden: JWT-`iat` hat Sekunden, eine im selben Augenblick neu ausgestellte Sitzung gilt. */
const jetztSekunde = () => new Date(Math.floor(Date.now() / 1000) * 1000);
export async function benutzerListe(): Promise<BenutzerSicht[]> {
const liste = await prisma.benutzer.findMany({ include: MIT_TOKENS, orderBy: { benutzername: "asc" } });
return liste.map(sicht);
}
export async function benutzerAnlegen(
roh: unknown,
aufrufer: Aufrufer,
ip: string | null,
): Promise<BenutzerSicht> {
const e = BenutzerAnlegen.parse(roh);
try {
const neu = await prisma.$transaction(async (tx) => {
const b = await tx.benutzer.create({
data: {
benutzername: e.benutzername,
anzeigename: e.anzeigename,
email: e.email ?? null,
rolle: e.rolle,
passwortHash: await passwortHashen(e.passwort),
},
include: MIT_TOKENS,
});
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "Benutzer angelegt",
ziel: `benutzer:${b.benutzername}`,
details: { rolle: b.rolle },
ip,
},
tx,
);
return b;
});
return sicht(neu);
} catch (fehler) {
if ((fehler as { code?: string }).code === "P2002") {
throw new AnwendungsFehler(409, "vergeben", `„${e.benutzername}“ gibt es schon.`, undefined, {
benutzername: "Benutzername schon vergeben.",
});
}
throw fehler;
}
}
async function letzterAdmin(tx: Prisma.TransactionClient, ohne: string): Promise<boolean> {
return (await tx.benutzer.count({ where: { rolle: "ADMIN", aktiv: true, id: { not: ohne } } })) === 0;
}
export async function benutzerAendern(
id: string,
roh: unknown,
aufrufer: Aufrufer,
ip: string | null,
): Promise<BenutzerSicht> {
const e = BenutzerAendern.parse(roh);
const ergebnis = await prisma.$transaction(async (tx) => {
// Sperre auf die Admin-Zeilen: zwei gleichzeitige Herabstufungen dürfen nicht beide durchgehen.
await tx.$queryRaw`SELECT id FROM "Benutzer" WHERE rolle = 'ADMIN' FOR UPDATE`;
const alt = await tx.benutzer.findUnique({ where: { id } });
if (!alt) throw nichtGefunden("Den Benutzer");
const selbst = alt.id === aufrufer.benutzer.id;
if (selbst && e.aktiv === false) {
throw new AnwendungsFehler(409, "selbst", "Das eigene Konto lässt sich nicht sperren.");
}
const verliertAdmin = alt.rolle === "ADMIN" && alt.aktiv && (e.rolle === "OPERATOR" || e.aktiv === false);
if (verliertAdmin && (await letzterAdmin(tx, alt.id))) {
throw new AnwendungsFehler(
409,
"letzter_admin",
"Das ist der letzte aktive Admin.",
"Erst einen anderen Benutzer zum Admin machen.",
);
}
const gesperrt = e.aktiv === false && alt.aktiv;
const neu = await tx.benutzer.update({
where: { id },
data: {
...(e.anzeigename !== undefined ? { anzeigename: e.anzeigename } : {}),
...(e.email !== undefined ? { email: e.email } : {}),
...(e.rolle !== undefined ? { rolle: e.rolle } : {}),
...(e.aktiv !== undefined ? { aktiv: e.aktiv } : {}),
...(gesperrt ? { sitzungenAb: jetztSekunde() } : {}),
},
include: MIT_TOKENS,
});
const aenderungen: Record<string, unknown> = {};
if (e.rolle !== undefined && e.rolle !== alt.rolle) aenderungen["rolle"] = [alt.rolle, e.rolle];
if (e.aktiv !== undefined && e.aktiv !== alt.aktiv) aenderungen["aktiv"] = [alt.aktiv, e.aktiv];
if (e.anzeigename !== undefined && e.anzeigename !== alt.anzeigename) aenderungen["anzeigename"] = true;
if (e.email !== undefined && e.email !== alt.email) aenderungen["email"] = true;
if (Object.keys(aenderungen).length > 0) {
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: gesperrt
? "Benutzer gesperrt"
: e.aktiv === true && !alt.aktiv
? "Benutzer entsperrt"
: "Benutzer geändert",
ziel: `benutzer:${alt.benutzername}`,
details: aenderungen as Prisma.InputJsonValue,
ip,
},
tx,
);
}
return neu;
});
return sicht(ergebnis);
}
/** Admin setzt ein neues Passwort (z. B. vergessen); alle Sitzungen des Benutzers enden. */
export async function passwortZuruecksetzen(
id: string,
roh: unknown,
aufrufer: Aufrufer,
ip: string | null,
): Promise<void> {
const { passwort } = z.object({ passwort: Passwort }).parse(roh);
const ziel = await prisma.benutzer.findUnique({ where: { id } });
if (!ziel) throw nichtGefunden("Den Benutzer");
const hashWert = await passwortHashen(passwort);
await prisma.$transaction(async (tx) => {
await tx.benutzer.update({
where: { id },
data: { passwortHash: hashWert, sitzungenAb: jetztSekunde() },
});
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "Passwort zurückgesetzt",
ziel: `benutzer:${ziel.benutzername}`,
ip,
},
tx,
);
});
}
/** Eigenes Passwort ändern – mit dem alten. Andere Sitzungen enden; die aufrufende stellt der Handler neu aus. */
export async function eigenesPasswortAendern(
roh: unknown,
aufrufer: Aufrufer,
ip: string | null,
): Promise<void> {
const e = z
.object({ alt: z.string().min(1, "Bisheriges Passwort fehlt.").max(1024), neu: Passwort })
.parse(roh);
const ich = await prisma.benutzer.findUniqueOrThrow({ where: { id: aufrufer.benutzer.id } });
if (!ich.passwortHash || !(await passwortPruefen(ich.passwortHash, e.alt))) {
throw new AnwendungsFehler(400, "ungueltig", "Das bisherige Passwort stimmt nicht.", undefined, {
alt: "Stimmt nicht.",
});
}
if (e.alt === e.neu) {
throw new AnwendungsFehler(400, "ungueltig", "Das neue Passwort ist das alte.", undefined, {
neu: "Bitte ein anderes Passwort wählen.",
});
}
const hashWert = await passwortHashen(e.neu);
await prisma.$transaction(async (tx) => {
await tx.benutzer.update({
where: { id: ich.id },
data: { passwortHash: hashWert, sitzungenAb: jetztSekunde() },
});
await protokollieren(
{
benutzerId: ich.id,
quelle: aufrufer.quelle,
aktion: "Eigenes Passwort geändert",
ziel: `benutzer:${ich.benutzername}`,
ip,
},
tx,
);
});
}
+90
View File
@@ -1,4 +1,5 @@
import type { Quelle } from "@/generated/prisma/enums";
import { z } from "@/lib/validierung";
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
@@ -28,3 +29,92 @@ export async function protokollieren(eintrag: ProtokollEintrag, client: Client =
},
});
}
/** Filter der Audit-Log-Ansicht (`/protokoll?…`, `GET /api/v1/protokoll?…`). */
export const ProtokollFilter = z.object({
benutzer: z.string().trim().optional(),
quelle: z.enum(["WEB", "CLI", "API", "SYSTEM"]).optional(),
/** Teilwort in Aktion oder Ziel, z. B. „Server“ oder „d-7f3k2“. */
suche: z.string().trim().max(100).optional(),
von: z.iso.date().optional(),
bis: z.iso.date().optional(),
seite: z.coerce.number().int().min(1).max(100_000).default(1),
});
export type ProtokollFilter = z.infer<typeof ProtokollFilter>;
export const PROTOKOLL_PRO_SEITE = 50;
export type ProtokollZeile = {
id: string;
zeit: string;
benutzer: string | null;
quelle: Quelle;
aktion: string;
ziel: string | null;
details: unknown;
ip: string | null;
};
/** Wie beim Verlauf: Gültiges übernehmen, Unsinn still fallen lassen. */
export function protokollFilterAus(
parameter: Record<string, string | string[] | undefined>,
): ProtokollFilter {
const geprueft: Record<string, unknown> = {};
for (const schluessel of Object.keys(ProtokollFilter.shape) as Array<keyof ProtokollFilter>) {
const roh = parameter[schluessel];
const wert = Array.isArray(roh) ? roh[0] : roh;
if (!wert) continue;
const feld = ProtokollFilter.shape[schluessel].safeParse(wert);
if (feld.success && feld.data !== undefined) geprueft[schluessel] = feld.data;
}
return ProtokollFilter.parse(geprueft);
}
export async function protokollSuchen(filter: ProtokollFilter): Promise<{
eintraege: ProtokollZeile[];
gesamt: number;
seite: number;
seiten: number;
}> {
const zeit: Prisma.DateTimeFilter = {};
if (filter.von) zeit.gte = new Date(`${filter.von}T00:00:00`);
if (filter.bis) zeit.lt = new Date(new Date(`${filter.bis}T00:00:00`).getTime() + 86_400_000);
const where: Prisma.ProtokollWhereInput = {
...(filter.benutzer ? { benutzer: { benutzername: filter.benutzer } } : {}),
...(filter.quelle ? { quelle: filter.quelle } : {}),
...(filter.suche
? {
OR: [
{ aktion: { contains: filter.suche, mode: "insensitive" as const } },
{ ziel: { contains: filter.suche, mode: "insensitive" as const } },
],
}
: {}),
...(filter.von || filter.bis ? { zeit } : {}),
};
const [gesamt, zeilen] = await prisma.$transaction([
prisma.protokoll.count({ where }),
prisma.protokoll.findMany({
where,
include: { benutzer: { select: { benutzername: true } } },
orderBy: { id: "desc" },
skip: (filter.seite - 1) * PROTOKOLL_PRO_SEITE,
take: PROTOKOLL_PRO_SEITE,
}),
]);
return {
eintraege: zeilen.map((z) => ({
id: z.id.toString(),
zeit: z.zeit.toISOString(),
benutzer: z.benutzer?.benutzername ?? null,
quelle: z.quelle,
aktion: z.aktion,
ziel: z.ziel,
details: z.details,
ip: z.ip,
})),
gesamt,
seite: filter.seite,
seiten: Math.max(1, Math.ceil(gesamt / PROTOKOLL_PRO_SEITE)),
};
}
+229
View File
@@ -0,0 +1,229 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as benutzerListe from "@/app/api/v1/benutzer/route";
import * as benutzerEinzeln from "@/app/api/v1/benutzer/[id]/route";
import * as benutzerPasswort from "@/app/api/v1/benutzer/[id]/passwort/route";
import * as ich from "@/app/api/v1/ich/route";
import * as ichPasswort from "@/app/api/v1/ich/passwort/route";
import * as profile from "@/app/api/v1/profile/route";
import * as protokoll from "@/app/api/v1/protokoll/route";
import * as server from "@/app/api/v1/server/route";
import * as tokens from "@/app/api/v1/tokens/route";
import { passwortHashen } from "@/lib/passwort";
import { SITZUNG_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
import { protokollFilterAus } from "@/server/services/protokoll";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
/** Eine Sitzung, die vor zehn Sekunden ausgestellt wurde – damit `sitzungenAb` sie sicher trifft. */
async function alteSitzung(benutzerId: string): Promise<string> {
vi.useFakeTimers({ toFake: ["Date"], now: Date.now() - 10_000 });
try {
return `${SITZUNG_COOKIE}=${await sitzungErzeugen({ benutzerId, herkunft: "passwort" })}`;
} finally {
vi.useRealTimers();
}
}
const wer = async (cookie: string) => (await ich.GET(anfrage("GET", "/api/v1/ich", cookie))).status;
describe("Audit-Log-Filter", () => {
it("übernimmt Gültiges, verwirft Unsinn", () => {
expect(protokollFilterAus({ quelle: "CLI", von: "2026-10-01", suche: "Server", seite: "2" })).toEqual({
quelle: "CLI",
von: "2026-10-01",
suche: "Server",
seite: 2,
});
expect(protokollFilterAus({ quelle: "FAX", von: "gestern" })).toEqual({ seite: 1 });
});
});
describe.runIf(datenbankVerfuegbar)("Benutzer, Rollen, Audit-Log", () => {
let admin: Awaited<ReturnType<typeof angemeldet>>;
let operator: Awaited<ReturnType<typeof angemeldet>>;
beforeEach(async () => {
await datenbankLeeren();
admin = await angemeldet("ADMIN", "chefin");
operator = await angemeldet("OPERATOR", "florian");
});
const patch = (cookie: string, id: string, rumpf: unknown) =>
benutzerEinzeln.PATCH(anfrage("PATCH", `/api/v1/benutzer/${id}`, cookie, rumpf), kontext({ id }));
it("Operator darf weder Benutzer noch Server noch Katalog ändern und kein Audit-Log lesen", async () => {
const c = operator.cookie;
const versuche = [
await benutzerListe.GET(anfrage("GET", "/api/v1/benutzer", c)),
await benutzerListe.POST(
anfrage("POST", "/api/v1/benutzer", c, {
benutzername: "x",
anzeigename: "x",
passwort: "x".repeat(12),
}),
),
await patch(c, admin.benutzer.id, { rolle: "OPERATOR" }),
await patch(c, operator.benutzer.id, { rolle: "ADMIN" }),
await benutzerPasswort.POST(
anfrage("POST", `/api/v1/benutzer/${admin.benutzer.id}/passwort`, c, { passwort: "y".repeat(12) }),
kontext({ id: admin.benutzer.id }),
),
await server.POST(anfrage("POST", "/api/v1/server", c, { name: "x" })),
await profile.POST(anfrage("POST", "/api/v1/profile", c, { quelltext: "id: x" })),
await protokoll.GET(anfrage("GET", "/api/v1/protokoll", c)),
];
expect(versuche.map((v) => v.status)).toEqual([403, 403, 403, 403, 403, 403, 403, 403]);
expect((await testPrisma.benutzer.findUniqueOrThrow({ where: { id: operator.benutzer.id } })).rolle).toBe(
"OPERATOR",
);
});
it("Admin legt an, ändert die Rolle; doppelte Namen und kurze Passwörter sind Feldfehler", async () => {
const neu = await benutzerListe.POST(
anfrage("POST", "/api/v1/benutzer", admin.cookie, {
benutzername: "Max.Muster",
anzeigename: "Max Muster",
email: "",
rolle: "OPERATOR",
passwort: "ein-langes-passwort",
}),
);
expect(neu.status).toBe(201);
const sicht = (await neu.json()) as { id: string; benutzername: string; passwort: boolean; email: null };
expect(sicht).toMatchObject({ benutzername: "max.muster", passwort: true, email: null });
expect(JSON.stringify(sicht)).not.toContain("argon2");
const doppelt = await benutzerListe.POST(
anfrage("POST", "/api/v1/benutzer", admin.cookie, {
benutzername: "max.muster",
anzeigename: "x",
passwort: "ein-langes-passwort",
}),
);
expect(doppelt.status).toBe(409);
expect((await doppelt.json()).fehler.felder.benutzername).toBeTruthy();
const kurz = await benutzerListe.POST(
anfrage("POST", "/api/v1/benutzer", admin.cookie, {
benutzername: "kurz",
anzeigename: "x",
passwort: "123",
}),
);
expect((await kurz.json()).fehler.felder.passwort).toMatch(/12 Zeichen/);
expect((await patch(admin.cookie, sicht.id, { rolle: "ADMIN" })).status).toBe(200);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({ where: { aktion: "Benutzer geändert" } });
expect(eintrag.details).toEqual({ rolle: ["OPERATOR", "ADMIN"] });
});
it("der letzte Admin bleibt Admin; niemand sperrt sich selbst", async () => {
const herab = await patch(admin.cookie, admin.benutzer.id, { rolle: "OPERATOR" });
expect(herab.status).toBe(409);
expect((await herab.json()).fehler.code).toBe("letzter_admin");
expect((await patch(admin.cookie, admin.benutzer.id, { aktiv: false })).status).toBe(409);
// Mit zweitem Admin geht es.
await patch(admin.cookie, operator.benutzer.id, { rolle: "ADMIN" });
expect((await patch(admin.cookie, admin.benutzer.id, { rolle: "OPERATOR" })).status).toBe(200);
});
it("Sperren beendet Sitzungen und Tokens; Entsperren lässt neue Anmeldungen zu", async () => {
const alt = await alteSitzung(operator.benutzer.id);
const token = (await (
await tokens.POST(anfrage("POST", "/api/v1/tokens", alt, { name: "cli" }))
).json()) as {
token: string;
};
const mitToken = anfrage("GET", "/api/v1/ich", null);
mitToken.headers.set("authorization", `Bearer ${token.token}`);
expect(await wer(alt)).toBe(200);
expect((await patch(admin.cookie, operator.benutzer.id, { aktiv: false })).status).toBe(200);
expect(await wer(alt)).toBe(401);
expect((await ich.GET(mitToken)).status).toBe(401);
await patch(admin.cookie, operator.benutzer.id, { aktiv: true });
expect(await wer(alt)).toBe(401); // die alte Sitzung bleibt beendet
const frisch = `${SITZUNG_COOKIE}=${await sitzungErzeugen({ benutzerId: operator.benutzer.id, herkunft: "passwort" })}`;
expect(await wer(frisch)).toBe(200);
expect(
(
await testPrisma.protokoll.findMany({ where: { ziel: "benutzer:florian" }, orderBy: { id: "asc" } })
).map((p) => p.aktion),
).toEqual(["Benutzer gesperrt", "Benutzer entsperrt"]);
});
it("Passwort zurücksetzen (Admin) und selbst ändern beenden alte Sitzungen", async () => {
await testPrisma.benutzer.update({
where: { id: operator.benutzer.id },
data: { passwortHash: await passwortHashen("das-alte-passwort") },
});
const alt = await alteSitzung(operator.benutzer.id);
const falsch = await ichPasswort.POST(
anfrage("POST", "/api/v1/ich/passwort", alt, { alt: "falsch", neu: "das-neue-passwort" }),
);
expect(falsch.status).toBe(400);
expect((await falsch.json()).fehler.felder.alt).toBeTruthy();
const geaendert = await ichPasswort.POST(
anfrage("POST", "/api/v1/ich/passwort", alt, { alt: "das-alte-passwort", neu: "das-neue-passwort" }),
);
expect(geaendert.status).toBe(204);
const neuesCookie = geaendert.headers.get("set-cookie")!.split(";")[0]!;
expect(await wer(alt)).toBe(401);
expect(await wer(neuesCookie)).toBe(200);
const alt2 = await alteSitzung(operator.benutzer.id);
// alt2 ist älter als der Passwortwechsel – schon ungültig. Nach dem Zurücksetzen auch das neue Cookie:
expect(await wer(alt2)).toBe(401);
vi.useFakeTimers({ toFake: ["Date"], now: Date.now() + 2000 });
try {
const zurueck = await benutzerPasswort.POST(
anfrage("POST", `/api/v1/benutzer/${operator.benutzer.id}/passwort`, admin.cookie, {
passwort: "vom-admin-gesetzt",
}),
kontext({ id: operator.benutzer.id }),
);
expect(zurueck.status).toBe(204);
expect(await wer(neuesCookie)).toBe(401);
} finally {
vi.useRealTimers();
}
});
it("Audit-Log: neueste zuerst, filterbar nach Benutzer, Quelle und Text", async () => {
await testPrisma.protokoll.createMany({
data: [
{ benutzerId: admin.benutzer.id, quelle: "WEB", aktion: "Server angelegt", ziel: "server:rz1" },
{
benutzerId: operator.benutzer.id,
quelle: "CLI",
aktion: "Ausrollen gestartet",
ziel: "auftrag:d-abcde",
},
{ benutzerId: null, quelle: "SYSTEM", aktion: "Erster Administrator angelegt" },
],
});
const holen = async (abfrage: string) =>
(await (await protokoll.GET(anfrage("GET", `/api/v1/protokoll?${abfrage}`, admin.cookie))).json()) as {
eintraege: Array<{ aktion: string; benutzer: string | null }>;
gesamt: number;
};
expect((await holen("")).eintraege.map((e) => e.aktion)).toEqual([
"Erster Administrator angelegt",
"Ausrollen gestartet",
"Server angelegt",
]);
expect((await holen("benutzer=florian")).eintraege).toEqual([
expect.objectContaining({ aktion: "Ausrollen gestartet", benutzer: "florian" }),
]);
expect((await holen("quelle=SYSTEM")).gesamt).toBe(1);
expect((await holen("suche=d-abc")).gesamt).toBe(1);
expect((await holen("suche=server")).gesamt).toBe(1);
// Der Filter rechnet in lokaler Zeit – also auch „heute“ lokal bilden.
const d = new Date();
const heute = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
expect((await holen(`von=${heute}&bis=${heute}`)).gesamt).toBe(3);
expect((await holen("bis=2000-01-01")).gesamt).toBe(0);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { expect, test } from "@playwright/test";
import { anmelden, E2E_PRAEFIX, keinQuerscrollen } from "./helfer";
test("Admin legt einen Benutzer an, stuft ihn hoch, sperrt ihn; das Audit-Log zeigt es", async ({ page }) => {
const name = `${E2E_PRAEFIX}-benutzer-${test.info().project.name}`;
await anmelden(page, "/einstellungen");
const vorhanden = (await (await page.request.get("/api/v1/benutzer")).json()) as Array<{
id: string;
benutzername: string;
}>;
const alt = vorhanden.find((b) => b.benutzername === name);
if (alt) {
// Vom letzten Lauf: zurück auf Operator und aktiv.
await page.request.patch(`/api/v1/benutzer/${alt.id}`, { data: { rolle: "OPERATOR", aktiv: true } });
await page.reload();
} else {
await page.locator("#b-name").fill(name);
await page.locator("#b-anzeige").fill("E2E Test");
await page.locator("#b-passwort").fill("e2e-passwort-lang");
await page.getByRole("button", { name: "Benutzer anlegen" }).click();
await expect(page.getByText(`${name} angelegt.`)).toBeVisible();
}
await keinQuerscrollen(page);
await page.getByLabel(`Rolle von ${name}`).selectOption("ADMIN");
await expect(page.getByText(`${name} ist jetzt Admin.`)).toBeVisible();
await page.getByRole("button", { name: `${name} sperren` }).click();
await expect(page.getByText(`${name} gesperrt – Sitzungen beendet.`)).toBeVisible();
await expect(page.getByRole("button", { name: `${name} entsperren` })).toBeVisible();
// Am Handy steckt die Navigation in der Schublade.
const menue = page.getByRole("button", { name: "Navigation öffnen" });
if (await menue.isVisible()) await menue.click();
await page.getByRole("link", { name: "Audit-Log" }).filter({ visible: true }).click();
await expect(page).toHaveURL(/\/protokoll/);
await page.getByLabel("Suche").fill(`benutzer:${name}`);
await page.getByRole("button", { name: "Filtern" }).click();
await expect(page).toHaveURL(new RegExp(`suche=benutzer%3A${name}`));
await expect(page.getByText("Benutzer gesperrt").first()).toBeVisible();
await keinQuerscrollen(page);
});