939c8121a1
- Kritikschrift: neuer Abschnitt "## Systemkontext (Etappe 3c, 260914-eym)" mit (y1) woertlicher Werkzeugausgabe und pg_policies der lebenden DB, (y2) Signaltabelle beider Fehlerrichtungen samt Rueckbau-Belegen (a)-(d), (y3) Leere-als-Abwesenheit je Pfad (kein Pfad loescht), (y4) bewusst nicht geloest, (y5) bewusst nicht angefasst; Nachtraege in (d4), (s4), (b4) und im Abschluss - Klassifikation: Uebersichtstabelle mit dritter Spalte System, Werte nachgerechnet (61/179/5), Stand-Absatz 260914-eym (72 Paare, Klassen unveraendert, sieben Staende geaendert), sechs Regelschluesse im Hintergrunddienst-Abschnitt, admin-seed-Zeile mit 3c-Befund, 3c-Punkt unter "NICHT entscheidet" erledigt - Auftrag: 3c als Erledigt vermerkt (3d64567/6e2a641), zwei neue Fallen unter "Werkzeuge und Fallen" - Datenbankrolle: dritte Sitzungsvariable, Nachtrag zum Systemkontext und zur weiterhin gueltigen Vorher-Pruefung ohne-kontext-leer - Ledger (ueber gsd-tools windows): #21 fixed, #30 fixed, #37 neu (prozessweiter Single-Flight-Riegel processInbox) — open 15 / waived 1 / fixed 21 / total 37, aus den Zeilen gezaehlt Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
255 lines
14 KiB
Markdown
255 lines
14 KiB
Markdown
<!-- generated-by: gsd-doc-writer -->
|
||
# Tessera – Mandantentrennung auf Datenbankebene (WINDOWS #18)
|
||
|
||
Diese Anleitung richtet sich an dieselben Kolleg:innen wie das
|
||
[Betriebshandbuch](./anleitung-betrieb.md) und behandelt nur einen einzigen
|
||
Punkt: die Datenbankrolle, mit der Tessera verbindet, und warum die
|
||
bestehenden Sicherheitsregeln (Row-Level Security) heute keine Wirkung
|
||
haben.
|
||
|
||
## Inhaltsverzeichnis
|
||
|
||
1. [Der Befund](#1-der-befund)
|
||
2. [Was bereits vorbereitet ist](#2-was-bereits-vorbereitet-ist)
|
||
3. [Der Sperrgrund — warum die Umstellung noch nicht erfolgt ist](#3-der-sperrgrund--warum-die-umstellung-noch-nicht-erfolgt-ist)
|
||
4. [Die Umstellung, Schritt fuer Schritt](#4-die-umstellung-schritt-fuer-schritt)
|
||
5. [Die Vorher-Pruefung](#5-die-vorher-pruefung)
|
||
6. [Der Rueckweg](#6-der-rueckweg)
|
||
|
||
---
|
||
|
||
## 1. Der Befund
|
||
|
||
Tessera trennt die Daten mehrerer Mandanten auf zwei Ebenen: im Anwendungscode
|
||
(`where: { tenantId }`) und zusaetzlich auf Datenbankebene ueber PostgreSQL
|
||
Row-Level Security (RLS) — ein „zweites Sicherheitsnetz" fuer den Fall, dass
|
||
im Code einmal ein `tenantId`-Filter vergessen wird.
|
||
|
||
Dieses zweite Netz existiert seit Juni 2026 auf sieben Tabellen und wurde am
|
||
2026-09-09 mit einer wichtigen Einschraenkung nachgemessen: **es greift
|
||
derzeit nicht.** Die API verbindet laut `docker-compose.yml` als Rolle
|
||
`tessera`. Diese Rolle entsteht aus der Umgebungsvariable `POSTGRES_USER` und
|
||
ist damit automatisch Superuser des PostgreSQL-Clusters. PostgreSQL wendet
|
||
Row-Level Security auf Superuser-Rollen grundsaetzlich nicht an — auch nicht
|
||
auf Rollen mit dem Recht `BYPASSRLS`, das Superuser automatisch mitbringen.
|
||
Der Zusatz `FORCE ROW LEVEL SECURITY` in den bestehenden Migrationen aendert
|
||
daran nichts: er erzwingt Policies nur gegenueber dem *Tabelleneigentuemer*,
|
||
nicht gegenueber Rollen mit Umgehungsrecht — und die Rolle `tessera` ist
|
||
beides zugleich.
|
||
|
||
Praktisch nachgemessen am 2026-09-09: ein `SELECT count(*) FROM "Group"` ohne
|
||
gesetzten Mandantenkontext liefert zwei Zeilen, obwohl die vorhandene Policy
|
||
`USING ("tenantId" = current_tenant_id())` bei fehlendem Kontext null Zeilen
|
||
liefern muesste. Die Isolation zwischen Mandanten haengt heute vollstaendig
|
||
am `where`-Filter im Anwendungscode — nicht an der Datenbank.
|
||
|
||
Dieser Befund ist als [WINDOWS #18](../.planning/WINDOWS.md) im
|
||
Fehler-Ledger festgehalten. Kein akutes Risiko, solange Tessera nur intern
|
||
und einmandantig laeuft — aber zwingend zu beheben, bevor Tessera an einen
|
||
zweiten, externen Mandanten geht.
|
||
|
||
## 2. Was bereits vorbereitet ist
|
||
|
||
Diese Aenderung liefert die vier Bausteine, die fuer eine wirksame Trennung
|
||
noetig sind:
|
||
|
||
- **Eine eigene Datenbankrolle** `tessera_app` ohne Superuser- und ohne
|
||
BYPASSRLS-Recht (Migration `20260909130000_rls_app_role`).
|
||
- **Getrennte Verbindungen fuer Migration und Laufzeit.** `prisma migrate
|
||
deploy` braucht die Rechte des Tabelleneigentuemers, die Anwendung soll
|
||
sie nicht haben — `apps/api/scripts/migrate-and-start.sh` trennt beide
|
||
Schritte ueber die neue Variable `TESSERA_MIGRATE_DATABASE_URL`.
|
||
- **Ein Pruefwerkzeug**, das den Nachweis fuehrt statt ihn zu behaupten:
|
||
`apps/api/scripts/rls-preflight.mjs` misst gegen eine angegebene
|
||
Verbindung Rollenrechte, Setzbarkeit des Mandantenkontexts, sichtbare
|
||
Zeilen mit und ohne Kontext sowie vorhandene Zugriffsrechte.
|
||
- **Vollstaendige Policy-Abdeckung.** Migration
|
||
`20260909140000_rls_remaining_tenant_tables` ergaenzt die bislang
|
||
fehlenden 16 Tabellen; alle 20 Tabellen mit `tenantId` tragen jetzt eine
|
||
Regel.
|
||
- **Eine zweite Sitzungsvariable fuer die Benutzerdimension.** Neben
|
||
`app.current_tenant` (Migration `20260618112133_rls_policies`,
|
||
`current_tenant_id()`) setzt die Rolle seit Migration
|
||
`20260911120000_rls_user_dimension_personal_tables` (Etappe 3b,
|
||
260911-nke) zusaetzlich `app.current_user`. Die zugehoerige Funktion
|
||
`current_user_id()` faltet den Leerstring per `NULLIF` auf `NULL` — der
|
||
Helfer `forTenant()` sendet "kein Benutzer" ausdruecklich als Leerstring,
|
||
nicht als weggelassene Variable, damit ein Aufruf ohne Benutzer nie einen
|
||
Benutzer aus einer fruaheren Transaktion derselben Verbindung erben kann.
|
||
Kein `GRANT EXECUTE` noetig — wie bei `current_tenant_id()` vergibt
|
||
PostgreSQL EXECUTE auf Funktionen standardmaessig an PUBLIC. Die Regeln
|
||
der zehn persoenlichen Tabellen (CalendarSource, DashboardLayout,
|
||
FavoriteLink, SearchProvider, TenderEmailConfig, TenderNotificationPref,
|
||
TenderRssFeedSource, TenderSavedSearch, TenderTriage, WidgetInstance)
|
||
pruefen `current_user_id() IS NULL OR "userId" = current_user_id()` —
|
||
ein Aufruf OHNE gesetzten Benutzer (Admin, Hintergrunddienst) sieht
|
||
weiterhin den ganzen Mandanten, das macht die Aenderung fuer heutige
|
||
Aufrufer wirkungslos.
|
||
- **Eine dritte Sitzungsvariable fuer den Systemkontext.** Migration
|
||
`20260914120000_rls_system_context_read` (Etappe 3c, 260914-eym) bringt
|
||
`app.system_context` und die Funktion `is_system_context()` —
|
||
`COALESCE(current_setting('app.system_context', true) = 'true', false)`,
|
||
damit die Regel ohne gesetzte Variable FALSE sieht, nicht NULL — sowie je
|
||
eine zusaetzliche PERMISSIVE Regel `system_read_policy ... FOR SELECT
|
||
USING (is_system_context())` auf genau den fuenf Tabellen, die die
|
||
Hintergrunddienste ueber alle Mandanten LESEN (DkvModuleConfig, LdapConfig,
|
||
LdapFieldMapping, TenderMatch, TenderSavedSearch). Permissive Regeln werden
|
||
ODER-verknuepft: fuer SELECT gilt (Mandantenregel ODER Systemregel), fuer
|
||
INSERT/UPDATE/DELETE weiter NUR die Mandantenregel — unter Systemkontext
|
||
ist `current_tenant_id()` der Leerstring, jedes Schreiben faellt durch
|
||
(gemessen: 42501 / count 0 / P2025). Der Helfer `forSystem()` setzt
|
||
`app.system_context = 'true'` und die beiden anderen Variablen
|
||
AUSDRUECKLICH leer; `forTenant()` und `withTenantTransaction()` setzen
|
||
umgekehrt `app.system_context = ''` — kein Kontext erbt vom anderen
|
||
(`local=true` als erstes Netz, der Reset als zweites, beides im Werkzeug
|
||
gemessen und durch Rueckbau belegt). Kein `GRANT EXECUTE` noetig, wie bei
|
||
den beiden anderen Funktionen.
|
||
|
||
## 3. Der Sperrgrund — warum die Umstellung noch nicht erfolgt ist
|
||
|
||
**Die Verbindung ist bewusst noch nicht umgestellt, und sie darf noch nicht
|
||
umgestellt werden.**
|
||
|
||
**Nachgemessen und korrigiert am 2026-09-09 (260909-eor, Aufgabe 3):** die
|
||
zuvor hier genannte Zahl 182 stammte aus einer groeberen Zaehlung vor dieser
|
||
Etappe und ist ueberholt. Die aktuelle, maschinell ermittelte und durch
|
||
`apps/api/src/prisma/rls-access-inventory.spec.ts` dauerhaft gepruefte
|
||
Bestandsaufnahme steht in
|
||
**`docs/mandantentrennung-zugriffsklassifikation.md`**: **227**
|
||
`this.prisma.*`-Fundstellen in `apps/api/src` (ohne Tests), zusammengefasst zu
|
||
**59** (Datei, Modell)-Paaren, davon **31** `muss-mandantengebunden` und **9**
|
||
`beides` (Hintergrunddienst mit sowohl uebergreifendem Lesen als auch
|
||
mandantengebundenem Schreiben je Zeile) — zusammen der eigentliche
|
||
Arbeitsvorrat fuer die Umstellung. **16** Paare betreffen keine
|
||
mandantengebundene Tabelle (plattformweite Daten wie der Ausschreibungs- und
|
||
Modulkatalog, D-03) und **3** sind bewusst uebergreifend mit ausgeschriebenem
|
||
Grund. Zusaetzlich zu beachten: das entdeckte, aber in dieser Etappe nicht
|
||
behobene `forTenant()`-Verbindungsproblem (WINDOWS #20, siehe unten). WINDOWS
|
||
#19 (nullbares `tenantId` bei `SearchProvider`/`TenderRssFeedSource`) ist
|
||
GESCHLOSSEN (260910-jab, Migration
|
||
`20260910120000_rls_widen_membership_grant_and_platform_read`) — siehe
|
||
`docs/mandantentrennung-zugriffsklassifikation.md`, Abschnitt "Zwei belegte
|
||
Befunde", und `.planning/WINDOWS.md`. Offen geblieben ist der Verwaltungsweg
|
||
fuer plattformweite Zeilen unter der Anwendungsrolle (WINDOWS #24).
|
||
|
||
Darunter war bis Aufgabe 2 dieser Etappe auch der Anmeldeweg selbst, der
|
||
strukturell nicht anders funktionieren konnte: `apps/api/src/auth/auth.service.ts`
|
||
suchte den Benutzer anhand des Benutzernamens, **bevor** der Mandant bekannt
|
||
war. Das ist inzwischen geloest — drei enge SECURITY-DEFINER-Funktionen
|
||
(`auth_lookup_user_by_username`, `auth_lookup_user_by_email`,
|
||
`auth_lookup_reset_token`, Migration `20260909160000_auth_lookup_functions`)
|
||
uebernehmen die drei pre-tenant Lesezugriffe, alle nachfolgenden
|
||
Schreibzugriffe laufen ueber `forTenant()`.
|
||
|
||
Weiterhin betroffen sind Hintergrunddienste, die von Natur aus ohne
|
||
Mandantenkontext laufen: der AD-Abgleich, der Ausschreibungs-Digest, die
|
||
Ausschreibungs-Sofortmeldung und die Erstanlage des Administrators beim
|
||
ersten Start — Details und Begruendung je Fundstelle in
|
||
`docs/mandantentrennung-zugriffsklassifikation.md`.
|
||
|
||
Diese Stellen brauchen einen ausdruecklichen, benannten Systemkontext (oder
|
||
eine gezielte Umstellung auf `forTenant()`), bevor der Schalter umgelegt
|
||
werden darf. Das ist **eigene Arbeit und nicht Teil dieser Aenderung**
|
||
(Etappe 2/3 der Mandantentrennung). Ein gruener Bericht des Pruefwerkzeugs aus
|
||
Abschnitt 5 belegt ausschliesslich, dass die Datenbankseite stimmt — er sagt
|
||
nichts ueber diese Zugriffe aus.
|
||
|
||
**Nachtrag (260914-eym, Etappe 3c):** der Systemkontext ist gebaut — siehe
|
||
den Punkt "Eine dritte Sitzungsvariable" in Abschnitt 2 und
|
||
`docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
|
||
"## Systemkontext (Etappe 3c, 260914-eym)". Vier Hintergrunddienst-Dateien
|
||
lesen ueber `forSystem()`, der Mail-Startpfad ist entfernt, die Erstanlage
|
||
des Administrators liest nur `Tenant` (keine Regel). Die Vorher-Pruefung
|
||
`ohne-kontext-leer` in `rls-preflight.mjs` (Abschnitt 5) bleibt GUELTIG und
|
||
wird durch die neue Regel NICHT gelockert: ohne gesetzte Variable ist
|
||
`is_system_context()` false — Werkzeugbeleg
|
||
`is-system-context-ungesetzt-false` (`rls-scratch-check.mjs`, Rohwert
|
||
`null` -> `false`). Etappe 4 ergaenzt die Vorher-Pruefung um
|
||
`mit-systemkontext-sichtbar` (mit `app.system_context = 'true'` sind die
|
||
fuenf Tabellen lesbar); `rls-preflight.mjs` ist in 3c bewusst nicht
|
||
angefasst.
|
||
|
||
**Zusaetzlicher Sperrgrund, ebenfalls am 2026-09-09 gemessen (WINDOWS #20):**
|
||
`forTenant()` selbst war bis Aufgabe 1 dieser Etappe defekt — `set_config()`
|
||
lief auf einer anderen Datenbankverbindung als die eigentliche Abfrage, sodass
|
||
selbst die 6 bisherigen `forTenant()`-Aufrufstellen (alle in
|
||
`apps/api/src/ldap/ldap.service.ts` sowie `tenant.middleware.ts`/
|
||
`tenant.guard.ts`) den Mandantenkontext nie tatsaechlich gesetzt haben. Das ist
|
||
inzwischen repariert (Array-Form von `$transaction`, `prisma-tenant.extension.ts`)
|
||
und durch `apps/api/scripts/rls-scratch-check.mjs` live nachgewiesen. Ohne
|
||
diese Reparatur waere jede Umstellung auf `forTenant()` in den folgenden
|
||
Etappen wirkungslos gewesen.
|
||
|
||
## 4. Die Umstellung, Schritt fuer Schritt
|
||
|
||
Erst durchfuehren, wenn Abschnitt 3 abgearbeitet ist. Reihenfolge einhalten
|
||
— die Migration muss weiterhin als Tabelleneigentuemer laufen.
|
||
|
||
1. **Kennwort der Rolle einmalig setzen**, als Datenbank-Superuser:
|
||
|
||
```sql
|
||
ALTER ROLE tessera_app WITH PASSWORD '<hier ein starkes, generiertes Kennwort einsetzen>';
|
||
```
|
||
|
||
Dieser Befehl gehoert in keine Datei und in kein Protokoll — er wird
|
||
direkt an `psql` uebergeben und danach aus der Shell-Historie entfernt.
|
||
|
||
2. **In der `.env` des Servers** `TESSERA_MIGRATE_DATABASE_URL` auf die
|
||
**bisherige** Verbindung (Rolle `tessera`, Tabelleneigentuemer) setzen und
|
||
`DATABASE_URL` auf die **neue** Rolle `tessera_app` umbiegen — in dieser
|
||
Reihenfolge, denn `prisma migrate deploy` muss weiterhin mit
|
||
Eigentuemerrechten laufen, waehrend die Anwendung selbst die eingeschraenkte
|
||
Rolle bekommt.
|
||
|
||
3. Vor dem Neustart die Vorher-Pruefung aus Abschnitt 5 gegen die neue Rolle
|
||
ausfuehren.
|
||
|
||
4. API-Dienst neu erstellen (`docker compose up -d --build --force-recreate
|
||
api`, siehe Betriebshandbuch Abschnitt 4).
|
||
|
||
## 5. Die Vorher-Pruefung
|
||
|
||
```bash
|
||
TESSERA_PREFLIGHT_DATABASE_URL="postgresql://tessera_app:<kennwort>@<host>:5432/tessera" \
|
||
node apps/api/scripts/rls-preflight.mjs
|
||
```
|
||
|
||
Das Werkzeug meldet fuenf Pruefungen: Rollenrechte, Setzbarkeit des
|
||
Mandantenkontexts, keine sichtbaren Zeilen ohne Kontext, sichtbare Zeilen mit
|
||
Kontext, vollstaendige Zugriffsrechte. Es veraendert nichts — es liest
|
||
ausschliesslich.
|
||
|
||
**Ein gruener Bericht ist die Freigabebedingung fuer Schritt 4 aus Abschnitt
|
||
4 — aber er ist keine Freigabe fuer die Umstellung insgesamt.** Er bestaetigt
|
||
nur, dass die Datenbankseite stimmt. Die unskalierten Zugriffe aus Abschnitt 3
|
||
(siehe `docs/mandantentrennung-zugriffsklassifikation.md` fuer den aktuellen
|
||
Stand) misst er nicht und kann sie nicht messen — dafuer muesste er den
|
||
Anwendungscode lesen, nicht die Datenbank.
|
||
|
||
## 6. Der Rueckweg
|
||
|
||
**Wenn die API nach einer Umstellung nicht mehr hochkommt:**
|
||
|
||
Das sichtbare Bild: der `api`-Container bleibt ungesund (Healthcheck
|
||
schlaegt fehl), und das Protokoll (`docker compose logs api`) zeigt einen
|
||
Authentifizierungs- oder Rechtefehler von PostgreSQL — etwa eine
|
||
fehlgeschlagene Anmeldung oder eine verweigerte Berechtigung.
|
||
|
||
Weg zurueck, zwei Schritte:
|
||
|
||
1. `DATABASE_URL` in der `.env` wieder auf die bisherige Verbindung (Rolle
|
||
`tessera`) setzen.
|
||
2. `docker compose up -d --build --force-recreate api` ausfuehren.
|
||
|
||
Die Rolle `tessera_app` darf dabei unangetastet bestehen bleiben — sie
|
||
schadet nicht, solange niemand mit ihr verbindet.
|
||
|
||
**Wichtiger Hinweis fuer den Server:** `/opt/tessera/docker-compose.yml` ist
|
||
keine Arbeitskopie dieses Repositorys. Aenderungen an `docker-compose.yml`
|
||
oder `docker-compose.prod.yml` in diesem Repository erreichen den Server
|
||
nicht automatisch. Wer `TESSERA_MIGRATE_DATABASE_URL` dort verfuegbar haben
|
||
will, traegt die Zeile selbst in `/opt/tessera/docker-compose.yml` ein
|
||
(vorher sichern) — genau wie es bereits fuer andere nachtraegliche
|
||
Variablen dokumentiert ist (Betriebshandbuch, Abschnitt zur
|
||
Server-Konfiguration).
|