Fachdokumente nach docs/, Entscheidungen festgehalten

CLAUDE.md und README.md verweisen auf docs/, die Dateien lagen aber im
Wurzelverzeichnis. Verschoben.

docs/13-entscheidungen.md hält fest, wo die Dokumente sich widersprechen und
wie es aufgelöst wurde. Der wichtigste Punkt: 04-datenmodell.md verlangt eine
Regel, die jedes UPDATE auf CaseEntryVersion verwirft, 05-akten-und-vorlagen.md
verlangt, dass eine Umklassifizierung rückwirkend auf alle Versionen wirkt.
Beides zusammen geht nicht. Aufgelöst über einen Trigger, der die Tabelle
anhängend hält, aber das Anheben von minClearance zulässt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harlekin
2026-09-01 00:00:58 +02:00
co-authored by Claude Opus 5
commit 4446e3f0ac
16 changed files with 1919 additions and 0 deletions
+124
View File
@@ -0,0 +1,124 @@
# Authentifizierung und Berechtigungen
## Grundsatz
**OAuth klärt, wer jemand ist. Der Bot klärt, was er darf.** Das Discord-Zugangstoken des
Nutzers wird nach dem Callback verworfen und nie wieder benutzt.
## Login-Ablauf
1. Nutzer klickt „Mit Discord anmelden". Auth.js startet OAuth2 mit Scope `identify`,
dazu `state` und PKCE.
2. Im Callback liest die App nur `id`, `username`, `global_name`, `avatar`.
3. Die App fragt **serverseitig mit dem Bot-Token** `GET /guilds/{GUILD_ID}/members/{userId}`.
- 404 → Login abgelehnt, Seite „Du bist nicht auf dem AfiS-Discord". Kein Konto anlegen.
- 200 → Rollen werden übernommen, `User` wird angelegt oder aktualisiert.
4. Eigene Session in der Datenbank, Cookie mit `HttpOnly`, `Secure`, `SameSite=Lax`,
Laufzeit 30 Tage, gleitende Verlängerung.
5. In der Session steht nur `userId` und `sessionId`. Rechte werden pro Request aufgelöst.
Im Developer Portal muss das **Server Members Intent** aktiviert sein, sonst liefert der Bot
keine Rollen.
## Rechteauflösung
```ts
type EffectivePermissions = {
userId: string
clearanceLevel: number // höchste Stufe aller Rollen
permissions: Set<Permission> // Vereinigung aller Rollen
isAdmin: boolean // aus BOOTSTRAP_ADMIN_IDS oder Recht 'admin'
}
```
Auflösung aus `UserRole` × `RoleMapping`. Ergebnis wird pro Request einmal berechnet und in
einem prozesslokalen LRU-Cache (60 s TTL) gehalten. Der Cache wird bei `pg_notify`
`member_changed` oder `role_mapping_changed` sofort verworfen.
Rechte-Enum:
```
case.read case.create case.edit case.close case.reopen case.delete
case.edit_closed # Änderungen an abgeschlossenen Fällen
entry.create entry.correct
entry.edit_own # eigene Einträge nachträglich ändern
entry.edit_any # fremde Einträge ändern, für die Leitung
entry.retract # Eintrag zurückziehen (kein Löschen)
personnel.read personnel.edit
template.manage roles.manage audit.read export.approve
admin
`entry.edit_own`, `entry.edit_any` und `entry.retract` sind im Auslieferungszustand **keiner
Rolle zugeordnet**. Der Leiter schaltet sie bewusst frei, wenn er sie will.
```
## Notfallzugang
`BOOTSTRAP_ADMIN_IDS` in der `.env` enthält eine oder zwei Discord-IDs. Diese Nutzer haben
immer Stufe 5 und alle Rechte, unabhängig vom Rollen-Mapping. Ohne das sperrt man sich beim
ersten Fehler in der Rollenverwaltung selbst aus.
## Zugriffsprüfung auf Akten
Reihenfolge, erste zutreffende Regel gewinnt:
1. Nutzer steht in `CaseExclusion` → **kein Zugriff**, egal welche Stufe.
Deckt den Fall ab, dass gegen einen hochrangigen Mitarbeiter ermittelt wird.
2. Nutzer ist Admin → Zugriff, wird protokolliert.
3. Nutzer ist Beteiligter (`CaseParticipant`) → Zugriff.
4. `user.clearanceLevel >= case.classification` → Zugriff.
5. Sonst kein Zugriff.
Kein Zugriff bedeutet:
- Ist die Stufe als `hiddenBelow` markiert: **404**, die Akte existiert für den Nutzer nicht.
- Sonst: Eintrag in Listen sichtbar, Titel und Inhalt gesperrt, Detailseite zeigt Sperrhinweis.
## Umsetzung in Prisma
Die Prüfung wird nie in JavaScript nachgelagert. `lib/access/caseFilter.ts` liefert ein
wiederverwendbares Where-Fragment:
```ts
export function visibleCaseWhere(p: EffectivePermissions): Prisma.CaseWhereInput {
if (p.isAdmin) return {}
return {
AND: [
{ exclusions: { none: { userId: p.userId } } },
{
OR: [
{ participants: { some: { userId: p.userId } } },
{ classification: { lte: p.clearanceLevel } },
],
},
],
}
}
```
Dasselbe Muster für `CaseEntry`, `Attachment`, `PersonnelFile` und die Suche. Trefferzahlen
und Paginierung laufen über dieselbe Abfrage, damit sich aus Lücken nichts ableiten lässt.
Für Abschnitte und Schwärzungen gibt es eine zweite Ebene: `CaseSection.minClearance`.
Felder oberhalb der Nutzerstufe werden serverseitig durch einen Platzhalter ersetzt,
**bevor** die Daten den Server verlassen. Nie im Client ausblenden.
## Sessions beenden
Der Worker meldet `guildMemberRemove` oder den Verlust einer AfiS-Rolle. Die App löscht dann
alle Sessions des Nutzers und markiert `User.isActive = false`. Der Datensatz bleibt, damit
Autorenangaben in alten Akten gültig bleiben.
## Tests, die es geben muss
- Nutzer ohne Guild-Mitgliedschaft kann sich nicht anmelden
- Nutzer mit Stufe 2 bekommt für eine Stufe-4-Akte 404, nicht 403
- Ausgeschlossener Nutzer mit Stufe 5 bekommt 404
- Suche mit Stufe 1 liefert für eine Stufe-3-Akte keinen Treffer und keine veränderte Trefferzahl
- Rollenentzug in Discord beendet laufende Sessions innerhalb von 5 Sekunden
- Nutzer ohne `entry.edit_any` kann einen fremden Eintrag nicht ändern, auch nicht über
einen direkten Aufruf der Server Action
- Nutzer ohne `entry.edit_own` kann nach Ablauf der Karenzzeit den eigenen Eintrag nicht ändern
- Ein Eintrag, dessen `minClearance` erhöht wurde, ist auch in der Versionshistorie für
niedrigere Stufen nicht lesbar
- Ein `DELETE` auf `CaseEntry` verändert nichts, weder über Prisma noch über rohes SQL
- Autor und `createdAt` bleiben nach einer Änderung unverändert