feat(auth): Anmeldung, Rollen und Benutzerverwaltung (#21)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

OIDC mit Discovery, PKCE, state und nonce; lokaler Login mit argon2 als
Notfallzugang; Rechte in einer Tabelle, Prüfung ausschließlich serverseitig,
Rolle und Aktiv-Status pro Anfrage aus der Datenbank.

Ein unabhängiger Review fand eine offene Weiterleitung: die Präfix-Prüfung des
Rücksprungziels ließ /<TAB>/fremd.example durch, woraus der Browser
//fremd.example macht. Das Ziel wird jetzt aufgelöst und die Herkunft
verglichen. Dazu acht weitere Befunde, alle behoben.

274 Tests.

Closes #7

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #21.
This commit is contained in:
2026-09-25 01:35:02 +00:00
co-authored by Claude Opus 5
parent 7a0f875f55
commit 40e95dbe5f
43 changed files with 3426 additions and 56 deletions
+66
View File
@@ -0,0 +1,66 @@
import { Rolle } from "@/generated/prisma/enums";
import { ROLLEN_RANG, ROLLEN_TEXT } from "@/lib/rollen-text";
/**
* Wer darf was (#007).
*
* Die Rechte stehen bewusst als eine Tabelle an einer Stelle -- nicht als
* `if (rolle === "ADMIN")` verstreut ueber Seiten, Aktionen und Services. Drei Gruende:
*
* 1. Eine Rechteaenderung hat genau einen Ort. Kommt spaeter eine Rolle hinzu oder darf ein
* `REDAKTEUR` etwas mehr, wird eine Zeile hier geaendert und nicht ein Dutzend Abfragen
* gesucht, von denen eine uebersehen wird.
* 2. Die Tabelle ist pruefbar. `tests/rollen.test.ts` schreibt jede Rolle gegen jedes Recht aus;
* eine versehentlich erweiterte Liste faellt sofort auf, statt erst im Betrieb.
* 3. Wer eine neue Aktion baut, sucht sich hier das passende Recht und prueft es mit `darf()`.
* Ein Recht kann so nie doppelt und unterschiedlich ausgelegt werden.
*
* Geprueft wird ausschliesslich serverseitig. Die Rolle kommt dabei pro Anfrage aus der
* Datenbank, nicht aus dem Sitzungstoken -- ein Rechteentzug wirkt sonst erst nach Ablauf
* des Cookies.
*/
export const RECHTE = {
/** Vorlagen, Laeufe, Geraete und Verzeichnis ansehen. Jede Rolle darf das. */
lesen: ["ADMIN", "REDAKTEUR", "TECHNIKER", "LESER"],
/** Laeufe starten, abarbeiten, abschliessen und archivieren. */
laeufeBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"],
/** Geraete und deren Stammdaten pflegen, Tags registrieren. */
geraeteBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"],
/** Vorlagen anlegen, bearbeiten und veroeffentlichen. */
vorlagenBearbeiten: ["ADMIN", "REDAKTEUR"],
/**
* Einen archivierten Lauf zurueckholen. Nur `ADMIN`: das Archiv ist die Zusage, dass ein
* abgeschlossener Lauf sich nicht mehr aendert -- diese Zusage darf nur eine Stelle aufheben.
*/
archivVerwalten: ["ADMIN"],
/** App-Benutzer, Verzeichnis, Abteilungen, Einstellungen, Kunden. */
verwaltung: ["ADMIN"],
} as const satisfies Record<string, readonly Rolle[]>;
export type Recht = keyof typeof RECHTE;
export function darf(rolle: Rolle, recht: Recht): boolean {
return (RECHTE[recht] as readonly Rolle[]).includes(rolle);
}
/**
* Die staerkste der uebergebenen Rollen.
*
* Der OIDC-Login braucht das: ist jemand in mehreren abgebildeten Gruppen, gewinnt die
* staerkere Rolle. Eine leere Liste ergibt `LESER` -- nie ein Fehler und nie `ADMIN`. Wer
* sich anmelden darf, aber keiner abgebildeten Gruppe angehoert, soll hineinsehen koennen
* und nichts aendern; ein Fehler an dieser Stelle wuerde stattdessen den Login blockieren.
*/
export function staerksteRolle(rollen: readonly Rolle[]): Rolle {
let staerkste: Rolle = Rolle.LESER;
for (const rolle of rollen) {
if (ROLLEN_RANG[rolle] > ROLLEN_RANG[staerkste]) {
staerkste = rolle;
}
}
return staerkste;
}
// Beschriftung und Rang liegen in src/lib, damit Client-Komponenten sie ohne
// die Rechtetabelle bekommen. Hier weitergereicht fuer serverseitige Aufrufer.
export { ROLLEN_RANG, ROLLEN_TEXT };