docs: alle Anleitungen gegen den Code geprueft und nachgearbeitet

Anwender-, Administrations-, Betriebs- und Entwicklungsanleitung gegen
Code und Oberflaechentexte abgeglichen; falsche und veraltete Stellen
korrigiert, fehlende Funktionen ergaenzt. Willkommensmail: Hinweis
nennt jetzt sechs statt vier Felder.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 17:44:04 +02:00
parent 201765afd4
commit aadee98bd3
8 changed files with 284 additions and 75 deletions
+184 -40
View File
@@ -138,6 +138,21 @@ Das startet:
`docker compose up` ohne `--build`/`--force-recreate` baut bestehende Images **nicht** neu — nach
Änderungen an Dockerfiles oder Dependencies muss `--build` explizit mitgegeben werden.
Die API läuft im Dev-Stack im Watch-Modus (`nest start --watch`). Dasselbe Skript gibt es für den
Betrieb außerhalb von Compose als `pnpm --filter @tessera/api start:dev`; `pnpm --filter
@tessera/api start` startet dagegen den fertig gebauten Stand (`node dist/main.js`). Im
produktiven Image startet die API nicht direkt, sondern über `apps/api/scripts/migrate-and-start.sh`
(siehe [Datenbank und Migrationen](#datenbank-und-migrationen)).
Neben den beiden Dateien für die Entwicklung liegen zwei weitere Compose-Dateien im Repository, die
Sie lokal nicht brauchen:
- `docker-compose.prod.yml` — der produktive Stack. Er zieht die fertigen Abbilder `web`, `api`
und `db` aus der Registry; der Kanal (`beta` oder `live`) kommt aus `IMAGE_TAG` in der `.env`
des jeweiligen Servers (Vorgabe `beta`).
- `docker-compose.ci.yml` — beschreibt den Gitea-Runner (`act_runner`), der die CI-Aufträge in
Containern ausführt. Details dazu stehen in `docs/ci-cd-setup.md`.
`web`, `api`, `db` und `mailhog` tragen `restart: unless-stopped`: Nach einem Neustart des
Rechners kommen sie von selbst wieder hoch, sofern sie vorher liefen. Mit `docker compose stop`
angehaltene Container bleiben aus.
@@ -224,7 +239,7 @@ vorgesehen. Sprache, Symbol und Bilder des Installers stehen in
sich nur über den CI-Bau auf einem Windows-Rechner prüfen, lokal validiert
`cargo check` lediglich die Schlüssel.
**Im CI wird die Desktop-App nur gebaut, wenn sich etwas an ihr geändert hat.** Der Job `desktop` vergleicht einen Stempel aus Versionsnummer und letztem Commit an `apps/desktop/`, `desktop-version.sh`, `desktop-collect.sh`, `desktop-stamp.sh` und `ci.yml` mit dem Zwischenspeicher des Runners und übernimmt bei Treffer die zuletzt gebauten Pakete (Details: `docs/ci-cd-setup.md`, Abschnitt 4). Eine Änderung außerhalb dieser Pfade – etwa nur in `pnpm-lock.yaml` – löst keinen Desktop-Bau aus; soll trotzdem neu gebaut werden, genügt eine Änderung unter `apps/desktop/`. Stempel lokal ansehen:
**Im CI wird die Desktop-App nur gebaut, wenn sich etwas an ihr geändert hat.** Der Job `desktop` vergleicht einen Stempel aus Versionsnummer und letztem Commit an `apps/desktop/`, `desktop-version.sh`, `desktop-collect.sh`, `desktop-stamp.sh`, `appimage-strip-wayland.sh` und `ci.yml` mit dem Zwischenspeicher des Runners und übernimmt bei Treffer die zuletzt gebauten Pakete (Details: `docs/ci-cd-setup.md`, Abschnitt 4). Eine Änderung außerhalb dieser Pfade – etwa nur in `pnpm-lock.yaml` – löst keinen Desktop-Bau aus; soll trotzdem neu gebaut werden, genügt eine Änderung unter `apps/desktop/`. Stempel lokal ansehen:
```bash
DESKTOP_TAG=v1.2.0 GITHUB_REF=refs/heads/main sh .gitea/scripts/desktop-stamp.sh stamp
@@ -236,20 +251,39 @@ DESKTOP_TAG=v1.2.0 GITHUB_REF=refs/heads/main sh .gitea/scripts/desktop-stamp.sh
```
(auth)/ — /login, /reset-password — öffentliche Routen
(portal)/ — alles hinter Login: /admin, /marketplace, /modules, /settings, /change-password
(portal)/ — alles hinter Login: /admin, /changelog, /marketplace, /modules, /settings,
/change-password
```
Die Route-Groups `(auth)` und `(portal)` teilen sich kein gemeinsames Layout im URL-Pfad, tragen
aber unterschiedliche Layout-Bäume. Innerhalb von `(portal)` liegt `modules/[category]/[moduleSlug]`
als generische Route für beliebige Module sowie vier fest verdrahtete Modulverzeichnisse
(`cert-manager`, `dkv-fleet`, `domaincheck`, `tender-radar`) mit eigenen `layout.tsx`-Dateien —
Details dazu im Abschnitt [Das Modulsystem](#das-modulsystem).
als generische Route für beliebige Module sowie zehn fest verdrahtete Modulverzeichnisse
(`cert-manager`, `dkv-fleet`, `domaincheck`, `domains`, `handelsware-datev`, `kantine-datev`,
`nextcloud-files`, `nextcloud-status`, `proxmox`, `tender-radar`) mit eigenen `layout.tsx`-Dateien.
Dazu kommt `modules/custom/[id]` für die „Eigenen Module“ (ohne Gate-Layout, siehe
[Eigene Module](#eigene-module)) — Details im Abschnitt [Das Modulsystem](#das-modulsystem).
**Backend** (`apps/api/src`, NestJS): ein Modul pro fachlicher Domäne
(`auth`, `user`, `tenant`, `groups`, `module-registry`, `domaincheck`, `dkv`, `cert-manager`,
`tenders`, `calendar`, `dashboard`, `favorites`, `settings`, `ldap`, `mail`, `crypto`, `health`,
`prisma`). Jedes Domänen-Modul folgt dem NestJS-Muster `*.module.ts` / `*.controller.ts` /
`*.service.ts`.
Weitere Bereiche hinter dem Login:
- `/changelog` — die Seite „Was ist neu“ (siehe [Konventionen und Fallstricke](#konventionen-und-fallstricke)).
- `/admin` — der Bereich „Administration“ (Benutzer, Gruppen, Module, Verzeichnisanbindung).
Unterbereiche: `users`, `groups`, `modules`
(mit `grants` für die Freigaben und `categories` für die Modulkategorien), `custom-modules`,
`ldap`, `smtp`, `welcome-mail` und `tenants`.
- `/settings` — persönliche Einstellungen mit `general` (Konto, Desktop), `dashboard` und
`custom-modules`.
**Backend** (`apps/api/src`, NestJS): ein Modul pro fachlicher Domäne. Die Plattform-Bausteine sind
`auth`, `user`, `tenant`, `groups`, `module-registry`, `module-categories`, `custom-modules`,
`dashboard`, `favorites`, `settings`, `reminders`, `bug-reports`, `desktop`, `ldap`, `mail`,
`inbox` (gemeinsame IMAP-/Exchange-Postfachanbindung, die mehrere Fachmodule nutzen), `crypto`,
`health` und `prisma`. Die Fachmodule sind `domaincheck`, `domains`, `dkv`, `cert-manager`,
`tenders`, `calendar`, `proxmox`, `nextcloud-files`, `nextcloud-status`, `handelsware-datev` und
`kantine-datev`; `accounting` enthält Hilfsfunktionen (CSV- und Dateinamen-Dekodierung), die
Handelsware und Kantinenabrechnung gemeinsam nutzen. `common` ist ein reiner Hilfsordner, kein
Fachmodul. Jedes Domänen-Modul folgt dem NestJS-Muster `*.module.ts` / `*.controller.ts` /
`*.service.ts`. Der `desktop`-Controller liefert die Desktop-Pakete aus (`GET /desktop/latest`,
`GET /desktop/update`, `GET /desktop/download/:platform`).
**Weg einer Anfrage** (Beispiel: eine Modulseite lädt Daten):
@@ -271,8 +305,10 @@ Details dazu im Abschnitt [Das Modulsystem](#das-modulsystem).
## Das Modulsystem
Module sind das zentrale Organisationsprinzip von Tessera: fachliche Werkzeuge (Domaincheck,
Zertifikatsmanager, DKV-Rechnung, Ausschreibungs-Radar), die im Marktplatz erscheinen, pro Mandant
aktiviert und dann einzelnen Gruppen oder Benutzern freigegeben werden.
Domains, Zertifikatsmanager, DKV-Rechnung, Ausschreibungs-Radar, Proxmox, Dateien, Nextcloud-Status,
Handelsware und Kantinenabrechnung), die im Marktplatz erscheinen, pro
Mandant aktiviert und dann einzelnen Gruppen oder Benutzern freigegeben werden. Daneben gibt es die
[Eigenen Module](#eigene-module), die ohne Aktivierung und Freigabe auskommen.
### Registrierung
@@ -369,17 +405,26 @@ Zugriff auf ein Modul besteht aus zwei unabhängigen Stufen:
SUPER_ADMIN). Ohne Aktivierung ist das Modul für niemanden im Mandanten erreichbar, auch nicht
über einen Grant.
2. **Grant pro Gruppe oder Benutzer** (`ModuleGrant`) — erst wenn das Modul aktiv ist, entscheidet
ein Grant, wer es tatsächlich sieht. `ModuleGrant` trägt bewusst kein Rechtestufen-Feld, nur
An/Aus (D-04 im Code-Kommentar des Schemas), und ist Gruppe **oder** Benutzer, nie beides.
ein Grant, wer es tatsächlich sieht. `ModuleGrant` trägt
eine Freigabestufe, das Feld `level` (Enum `ModuleGrantLevel`, Vorgabe `USE`): `USE` heißt
Benutzen, `MANAGE` heißt Benutzen und zusätzlich die Einstellungen des Moduls ändern. Freigaben
erteilen bleibt Administratoren vorbehalten. Ein Grant gilt für eine Gruppe **oder** einen
Benutzer, nie beides (D-04).
Beide Stufen werden ausschließlich von einer einzigen Funktion aufgelöst:
`ModuleAccessService.getAccessibleModuleIds(tenantId, userId, role)`
(`apps/api/src/module-registry/module-access.service.ts`). ADMIN und SUPER_ADMIN umgehen die
Grant-Prüfung und bekommen automatisch alle mandantenweit aktiven Module. Für die Rolle USER ist es
die Vereinigungsmenge aus Direkt-Grants und Grants über Gruppenmitgliedschaft, geschnitten mit den
aktiven Modulen des Mandanten. Diese eine Funktion versorgt drei Stellen — den `ModuleGuard` im
Beide Stufen werden ausschließlich an einer Stelle aufgelöst:
`ModuleAccessService.getModuleAccessLevels(tenantId, userId, role)`
(`apps/api/src/module-registry/module-access.service.ts`). Die Funktion liefert je zugänglichem Modul die
Stufe. ADMIN und SUPER_ADMIN umgehen die Grant-Prüfung und bekommen automatisch alle
mandantenweit aktiven Module mit der Stufe `MANAGE`. Für die Rolle USER ist es die
Vereinigungsmenge aus Direkt-Grants und Grants über Gruppenmitgliedschaft, geschnitten mit den
aktiven Modulen des Mandanten; besteht der Zugriff über mehrere Wege, gewinnt die höhere Stufe.
`getAccessibleModuleIds(tenantId, userId, role)` ist nur die Schlüsselmenge davon — es liefert
also die Modul-IDs ohne Stufe. Diese eine Auflösung versorgt drei Stellen — den `ModuleGuard` im
Backend, `GET /modules/active` (Sidebar) und `GET /modules/catalog` (Marktplatz) — damit keine
dieser Stellen unabhängig voneinander driften kann.
dieser Stellen unabhängig voneinander driften kann. Der Dekorator `@UseModule(slug)` verlangt
Zugriff auf das Modul; `@ModuleManage(slug)` (ebenfalls in `module.guard.ts`) verlangt zusätzlich
die Stufe `MANAGE` und ersetzt `@Roles(ADMIN, SUPER_ADMIN)` bei Routen, die nur dieses eine Modul
konfigurieren. Beide nie zusammen mit `@Roles` am selben Handler einsetzen.
### Vom Backend-Endpunkt zur Seite im Portal
@@ -400,9 +445,13 @@ Im Frontend gibt es zwei Wege, wie eine Modulseite unter `/modules/...` erreichb
- **Generische Route** `apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx` — für
jedes Modul über `[category]`/`[moduleSlug]` erreichbar.
- **Vier fest verdrahtete Modulverzeichnisse**: `modules/cert-manager`, `modules/dkv-fleet`,
`modules/domaincheck`, `modules/tender-radar` — mit eigenen Unterrouten (z. B.
`dkv-fleet/vehicles`, `tender-radar/my-sources`, `*/settings`).
- **Zehn fest verdrahtete Modulverzeichnisse**: `modules/cert-manager`, `modules/dkv-fleet`,
`modules/domaincheck`, `modules/domains`, `modules/handelsware-datev`, `modules/kantine-datev`,
`modules/nextcloud-files`, `modules/nextcloud-status`, `modules/proxmox` und
`modules/tender-radar` — mit eigenen Unterrouten (z. B. `dkv-fleet/vehicles`,
`tender-radar/my-sources`, `*/settings`). Jedes davon hat zugleich einen Eintrag in
`MODULE_REGISTRY`. Daneben gibt es `modules/custom` — die bewusste Ausnahme ohne Gate-Layout
für die [Eigenen Module](#eigene-module).
Beide Wege rendern denselben Baustein: die Server-Komponente `ModuleAccessGate`
(`apps/web/src/components/modules/module-access-gate.tsx`). Sie ruft `checkModuleAccess(moduleSlug)`
@@ -412,11 +461,12 @@ auf, was `GET /modules/active` mit dem Session-Cookie anfragt — dieselbe
einen Fehler) zeigt eine gemeinsame 403-Ansicht.
**Bekannter Fallstrick (behoben, aber lehrreich):** Ursprünglich saß dieser Zugriffs-Check nur in
der generischen `[category]/[moduleSlug]`-Route. Die vier fest verdrahteten Modulverzeichnisse
hatten **keinen eigenen** `ModuleAccessGate` und liefen an der Prüfung vorbei — ein direkter Aufruf
der generischen `[category]/[moduleSlug]`-Route. Die damals vier fest verdrahteten Modulverzeichnisse
(`cert-manager`, `dkv-fleet`, `domaincheck`, `tender-radar`) hatten **keinen eigenen** `ModuleAccessGate` und liefen an der Prüfung vorbei — ein direkter Aufruf
von z. B. `/modules/dkv-fleet` umging die Freigabeprüfung vollständig, obwohl die generische Route
korrekt geschützt war. Der Fix (Commit `74a30fb`/`5504931`) gibt jedem der vier Modulverzeichnisse
ein eigenes `layout.tsx`, das denselben `ModuleAccessGate` einbindet:
korrekt geschützt war. Der Fix (Commit `74a30fb`/`5504931`) gab jedem dieser Verzeichnisse
ein eigenes `layout.tsx`, das denselben `ModuleAccessGate` einbindet; heute tragen es alle zehn
fest verdrahteten Modulverzeichnisse:
```ts
// apps/web/src/app/(portal)/modules/dkv-fleet/layout.tsx
@@ -429,7 +479,13 @@ export default function DkvFleetLayout({ children }: { children: ReactNode }) {
braucht **immer** ein eigenes `layout.tsx` mit `ModuleAccessGate`, genau wie sein Backend-Controller
`@UseModule('<slug>')` braucht. Beide Prüfungen sind unabhängig voneinander — die eine ersetzt nicht
die andere; das Frontend-Gate ist Komfort/UX (keine leere Seite ohne Erklärung), das Backend-Gate ist
die tatsächliche Zugriffskontrolle.
die tatsächliche Zugriffskontrolle. Den Wächter dafür stellt
`apps/web/src/app/(portal)/modules/module-layouts.test.tsx`: Er prüft für die dort aufgeführten Layouts, dass sie das
`ModuleAccessGate` mit genau dem Slug ihres Ordnernamens einbinden (`proxmox` fehlt in dieser Liste
bisher), und dass **jedes**
Modulverzeichnis (außer den dynamischen `[...]`-Ordnern und dem bewusst ausgenommenen `custom`)
ein `layout.tsx` besitzt. Wer ein neues Modulverzeichnis ohne Layout anlegt, bekommt einen roten
Test; wer ein Layout anlegt, trägt es dort zusätzlich in die Slug-Tabelle ein.
Zusätzlich läuft im Frontend eine dritte, unabhängige Absicherung: `ModuleShell`
(`apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-shell.tsx`) lädt die
@@ -439,7 +495,7 @@ ein beliebiger Slug aus der URL löst sonst keinen Import aus.
### So entsteht ein neues Modul — Walkthrough am Beispiel Domaincheck
Domaincheck ist das kleinste vorhandene Modul und eignet sich als Vorlage. Die realen Dateien:
Domaincheck ist ein kleines, überschaubares Modul und eignet sich als Vorlage. Die realen Dateien:
**Backend** (`apps/api/src/domaincheck/`):
@@ -448,7 +504,8 @@ Domaincheck ist das kleinste vorhandene Modul und eignet sich als Vorlage. Die r
3. `domaincheck.controller.ts` — `@Controller('modules/domaincheck')` mit `@UseModule('domaincheck')`
auf Klassenebene, ein `@Post('check')`-Handler.
4. `domaincheck.seed.ts` — die `seedDomaincheckModule()`-Funktion mit dem Manifest (`slug`, `name`,
`version`, `category`, `description`, `isSystem`).
`version`, `category`, `description`, `isSystem`). Die Version kommt aus dem Modul-Changelog, siehe
[Modulversion und Modul-Changelog pflegen](#modulversion-und-modul-changelog-pflegen).
5. `domaincheck.module.ts` — bindet Controller/Service zusammen, importiert
`ModuleRegistryModule`, ruft in `onModuleInit()` den Seed auf.
6. Eintrag des neuen Moduls in `apps/api/src/app.module.ts` unter `imports`.
@@ -488,10 +545,36 @@ diesem globalen `fetch` ignoriert. Gemessen und dokumentiert in
`apps/api/src/proxmox/proxmox-client.service.ts` (zweites, unabhängig davon konstruiertes
Auftreten mit demselben Befund).
### Eigene Module
Neben den eingebauten Modulen gibt es die „Eigenen Module“: Seitenleisten-Einträge, die eine
externe `https`-Seite im Rahmen des Portals anzeigen. Sie sind keine Zeilen der Tabelle `Module`,
hängen deshalb an keiner Mandanten-Aktivierung und keinem Grant und sind für alle angemeldeten
Benutzer sichtbar. Die Teile:
- **Backend** `apps/api/src/custom-modules/` (`@Controller('custom-modules')`): Es gibt gemeinsame
Einträge (nur Administratoren legen sie an, ändern und löschen sie) und persönliche Einträge
(nur der Besitzer sieht und ändert sie; für fremde Kennungen antwortet die API immer mit 404). Die
Rollenentscheidung trifft der Dienst, weil sie vom Eintrag abhängt, nicht von der Route — deshalb
tragen die Routen weder `@Roles` noch `@UseModule`. Die Kategorien kommen aus
`apps/api/src/module-categories/`.
- **Frontend**: die Rahmenseite `modules/custom/[id]` (bewusst ohne `ModuleAccessGate`; der
statische Ordner `custom` hat im App Router Vorrang vor `[category]/[moduleSlug]`), die
Bausteine `apps/web/src/components/modules/custom-module-*.tsx`, die Pflege der
gemeinsamen Einträge für Administratoren unter `/admin/custom-modules` und die persönlichen
Einträge unter `/settings/custom-modules`.
- **Wächter**: `apps/web/src/messages/module-categories.spec.ts` prüft, dass jede Modulkategorie
einen Anzeigenamen in beiden Sprachen hat; `module-layouts.test.tsx` führt `custom` als einzige
begründete Ausnahme von der Layout-Pflicht.
### Eine Kachel zum Modul
Ein Modul kann zusätzlich als Kachel auf dem Dashboard erscheinen. Seit
`quick-260922-m1h` sind dafür **drei** Stellen nötig (vorher waren es sieben):
Ein Modul kann zusätzlich als Kachel auf dem Dashboard erscheinen. `WIDGET_TYPES` kennt zwölf
Kachel-Typen: `clock`, `search`, `calendar`, `note`, `calculator`, `favorites`, `stopwatch`,
`picture-frame`, `xframe`, `proxmox`, `reminder` und `nextcloud-status`. Davon sind nur `proxmox` und
`nextcloud-status` an ein Modul gebunden; alle übrigen (auch `reminder`) sind Plattform-Kacheln, die
immer sichtbar sind. Seit `quick-260922-m1h` sind für eine neue Kachel **drei** Stellen nötig
(vorher waren es sieben):
1. **Die Kachel-Komponente schreiben** — `apps/web/src/components/dashboard/widgets/<name>-widget.tsx`,
nimmt die `WidgetProps` aus `widget-registry.tsx` (`instanceId`, `config`, `isEditMode`) entgegen.
@@ -538,6 +621,14 @@ den Proxmox-Hosts. Und Zeilen, die im Ansichtsmodus Links sind, werden im Bearbe
schlichten Elementen ohne Ziel: Links stehen im Abbruch-Selektor von `dashboard-grid.tsx`, eine Kachel
aus Links ließe sich sonst kaum noch ziehen.
**Zweites Beispiel: Nextcloud-Status (`quick-261002-k67`).** Die zweite modulgebundene Kachel
folgt demselben Muster: `WIDGET_MODULE_SLUGS` trägt `'nextcloud-status': 'nextcloud-status'`, die
Komponente ist `apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx`, und die
Daten kommen über die Endpunkte des Moduls (`/modules/nextcloud-status/*`, im Frontend gebündelt in
`apps/web/src/lib/nextcloud-status-api.ts`, das sich an `proxmox-api.ts` orientiert). Der Katalog-
Test `widget-catalog-modal.test.tsx` prüft, dass beide Modul-Kacheln nur bei Zugriff auf das jeweilige
Modul erscheinen.
## Mandantentrennung
Der tatsächliche Mechanismus ist `TenantGuard` (`apps/api/src/tenant/tenant.guard.ts`), global als
@@ -547,8 +638,10 @@ SUPER_ADMIN einen Wechsel per `x-tenant-id`-Header, und setzt anschließend AUSS
`req.tenantId` (260911-e2s). Die Bindung an den Mandanten geschieht dienst-intern, je
Service-Methode neu, über das Bindungshilfsmittel `forTenant()`
(`apps/api/src/prisma/prisma-tenant.extension.ts`), das vor **jeder** Query in einer Transaktion
`SELECT set_config('app.current_tenant', $1, true)` ausführt — der Guard selbst erzeugt keinen
Prisma-Client mehr und veröffentlicht keinen auf dem Anfrageobjekt.
eine einzige `SELECT set_config(...)`-Anweisung ausführt. Sie setzt drei Werte zugleich:
`app.current_tenant` (der Mandant), `app.current_user` (der optionale dritte Parameter `userId`,
leer, wenn keiner übergeben wird) und `app.system_context` (wird dabei auf leer gesetzt). Der Guard
selbst erzeugt keinen Prisma-Client mehr und veröffentlicht keinen auf dem Anfrageobjekt.
> Ein früherer Entwurf veröffentlichte zusätzlich einen gebundenen Prisma-Client auf dem
> Anfrageobjekt, dupliziert in einer gleichnamigen, nie in `app.module.ts` registrierten
@@ -589,6 +682,34 @@ Bei den RLS-geschützten Tabellen greift die DB-seitige Absicherung zusätzlich,
läuft tatsächlich über einen dienst-intern per `forTenant()` gebundenen Client und nicht über
den globalen, ungebundenen `PrismaService`.
**Systemkontext für Hintergrunddienste:** Ein Dienst, der ohne Anfrage einmal über **alle** Mandanten
lesen muss und danach je Mandant gebunden handelt (zum Beispiel die Hintergrunddienste von DKV, Proxmox,
Nextcloud-Status, Erinnerungs-Mails und Ausschreibungs-Zusammenfassung, der LDAP-Abgleich und das
Ausschreibungs-Matching), nutzt `forSystem()` aus derselben Datei. Es setzt `app.system_context`
auf `'true'` und leert dabei `app.current_tenant` und `app.current_user`. Geöffnet ist nur das
**Lesen** (eine zusätzliche `FOR SELECT`-Regel); jedes Schreiben scheitert weiterhin an der
Mandantenregel. Ein Anfrageweg darf `forSystem()` nie aufrufen — es würde an jeder Mandantenregel
vorbeilesen. Welche Dateien es rufen dürfen, steht mit der genauen Zahl der Aufrufe je Datei in
`FORSYSTEM_ALLOWED_CALL_SITES` in `rls-access-inventory.spec.ts`; jeder weitere Aufruf macht
diesen Test rot.
**Wächter-Tests für die Mandantentrennung** (`apps/api/src/prisma/`): Sie lesen Schema,
Migrationen und Quelltext und werden rot, sobald etwas auseinanderläuft:
- `rls-coverage.spec.ts` — misst aus `schema.prisma` und den Migrationen, welche Modelle eine
`tenantId` tragen und ob für sie eine RLS-Regel existiert; bleibt auch für künftige Modelle gültig.
- `rls-access-inventory.spec.ts` — ermittelt alle Zugriffe auf Modelle im Quelltext (gebunden,
ungebunden, gemischt, systemgebunden) und vergleicht sie mit der Bestandsaufnahme in
`docs/mandantentrennung-zugriffsklassifikation.md`. Eine neue Fundstelle ohne Eintrag oder ein
Eintrag ohne Fundstelle lässt den Test fehlschlagen — deshalb gehört die Doku-Zeile in dieselbe
Änderung wie der neue Zugriff.
- `rls-app-role.spec.ts` — prüft die Migration der Datenbankrolle `tessera_app` (ohne Superuser- und
BYPASSRLS-Recht) rein textuell.
- `rls-preflight.spec.ts` — prüft `apps/api/scripts/rls-preflight.mjs` in der Betriebsart
`--print-plan`, also ohne Datenbankverbindung. Das Skript selbst misst gegen eine Datenbankrolle
(Verbindung aus `TESSERA_PREFLIGHT_DATABASE_URL`), ob die Mandantentrennung unter ihr tatsächlich
greift; es liest nur und schreibt nichts.
## Berechtigungen
Rollen kommen aus dem Prisma-`enum Role { SUPER_ADMIN, ADMIN, USER }` und stecken im JWT — nie aus
@@ -607,7 +728,7 @@ Dekorators auf Handler oder Controller.
Für den Modulzugriff kommt das oben beschriebene Grant-Modell hinzu: `Group` (mandantenintern,
optional an ein AD-Objekt über `ldapDn`/`ldapObjectGuid` gebunden), `GroupMembership`
(Benutzer-zu-Gruppe, `source: MANUAL | LDAP`) und `ModuleGrant` (Gruppe **oder** Benutzer, XOR,
kein Rechtestufen-Feld). Die Schreibseite dafür ist
mit Freigabestufe `level` = `USE` oder `MANAGE`). Die Schreibseite dafür ist
`apps/api/src/groups/module-grants.controller.ts`, ausschließlich für ADMIN/SUPER_ADMIN:
| Route | Zweck |
@@ -636,12 +757,22 @@ Der Workflow für eine Schemaänderung:
(`apps/api/Dockerfile`) setzt als `CMD`:
```
prisma migrate deploy --schema apps/api/prisma/schema.prisma && node apps/api/dist/main.js
sh apps/api/scripts/migrate-and-start.sh
```
Ein neu gestarteter `api`-Container wendet also jede noch ausstehende Migration selbst an, bevor
die Anwendung überhaupt hochfährt — es gibt keinen separaten manuellen Migrationsschritt beim
Deployment.
Das Skript führt zuerst `prisma migrate deploy --schema apps/api/prisma/schema.prisma` aus und
startet danach die API (`node apps/api/dist/main.js`, per `exec`, damit Signale wie `SIGTERM`
den Node-Prozess erreichen). Ein neu gestarteter `api`-Container wendet also jede noch ausstehende
Migration selbst an, bevor die Anwendung überhaupt hochfährt — es gibt keinen separaten manuellen
Migrationsschritt beim Deployment.
Das Skript trennt dabei die Verbindung für die Migration von der Verbindung für den Betrieb: Für
`prisma migrate deploy` gilt `TESSERA_MIGRATE_DATABASE_URL`, falls gesetzt (Rechte des
Tabelleneigentümers für DDL), sonst `DATABASE_URL`; die API selbst läuft immer mit `DATABASE_URL`.
Ohne gesetzte Migrationsverbindung nutzen beide Schritte `DATABASE_URL` — der bisherige Ablauf,
unverändert für lokale Entwicklung und CI. Die Hintergründe zur Rollentrennung stehen in
`docs/mandantentrennung-datenbankrolle.md`; `apps/api/src/prisma/start-script.spec.ts` prüft das
Skript in der Betriebsart `--print-plan` ohne Datenbank.
RLS-Policies werden nicht von Prisma selbst verwaltet, sondern als reines SQL innerhalb regulärer
Migrationsdateien mitgeliefert (`CREATE POLICY ...` in `migration.sql`) — siehe
@@ -669,6 +800,19 @@ gegen Services, Controller-Logik und React-Komponenten. Guard-artige Spezifikati
gegen bereits einmal aufgetretene Fehler — wiederkehrende Fallstricke werden in diesem Projekt
durch einen Test abgesichert, nicht nur durch einen Kommentar.
**Wächter-Tests (Regressionsschutz):** Mehrere Tests sind keine Funktionstests, sondern halten eine
Regel fest, die einmal verletzt wurde. Die wichtigsten:
| Test | Was er absichert |
|---|---|
| `apps/web/src/app/(portal)/modules/module-layouts.test.tsx` | Jedes Modulverzeichnis hat ein `layout.tsx` mit `ModuleAccessGate` (Ausnahme: `custom` und dynamische `[...]`-Ordner) |
| `apps/web/src/messages/umlaut-guard.spec.ts` | Keine Ersatzschreibung („fuer“, „loeschen“) in `de.json`/`en.json` |
| `apps/web/src/messages/tenderRadar-parity.spec.ts` | `de.json` und `en.json` haben im Namensraum `tenderRadar` dieselben Schlüssel |
| `apps/web/src/messages/module-categories.spec.ts` | Jede Modulkategorie hat einen Anzeigenamen in beiden Sprachen |
| `apps/web/src/components/dashboard/widget-registry.test.tsx` | `WIDGET_TYPES`, Registry und Größenvorgaben der Kacheln sind deckungsgleich |
| `apps/api/src/module-registry/module-changelog.spec.ts` | Jedes Seed-Modul hat einen Changelog, die Version stimmt mit dem obersten Eintrag |
| `apps/api/src/prisma/rls-*.spec.ts` | Mandantentrennung: Abdeckung, Zugriffsinventar, Datenbankrolle, Preflight (siehe [Mandantentrennung](#mandantentrennung)) |
**Desktop-Modul (`apps/api/src/desktop`):**
```bash