03fb3bf9c7
- WINDOWS.md: #19 auf fixed gesetzt (Tabelle + JSON-Block), mit Beleg (Migrationsname + benannte Pruefungen) und ausdruecklicher Feststellung, dass die SearchProvider-Haelfte als widerlegte Praemisse schliesst, nicht als geloestes Problem. #18/#20/#21/#22/#23 bleiben unveraendert offen. Ein neuer Eintrag #24 haelt den fehlenden Verwaltungsweg fuer plattformweite Zeilen unter der Anwendungsrolle offen (verschwindet nicht mit #19). Die vier Kopfzahlen sind aus dem JSON-Block abgeleitet (6 offen, 17 behoben, 1 zurueckgestellt, 24 gesamt). - Klassifikation: #19-Block von offener Frage zu beantwortet, die Uebersichtszeile tenders (35/27) und Summenzeile (107/135) aus dem Quelltext neu abgeleitet, vier Bestandsaufnahme-Zeilen nachgezogen (searchProvider, groups.service.ts/user, module-grants.service.ts/ moduleGrant, tender-rss-feed.service.ts/tenderRssFeedSource), der Punkt in "Was diese Etappe NICHT entscheidet" aufgeloest. - Kritikschrift: neuer Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19" mit tatsaechlich beobachteter Ausgabe, Regelliste der lebenden Datenbank, Signaltabelle mit beiden Fehlerrichtungen je Regel, der neuen Stelle aus Befund F, der selbst ausgefuehrten Messung zur fehlenden Benutzerdimension (keine zweite Sitzungsvariable gefunden) und dem, was dieser Durchlauf nicht loest/nicht anfasst. Fuenf ueberholte Bestandsstellen mit Nachtraegen versehen (g4, t4, die Signaltabellenzeile zu den RSS-Pfaden, drei aufgezeichnete Werkzeugausgaben, m4), die alten Messprotokolle bleiben woertlich stehen. - Betriebsanleitung: die eine Stelle, die #19 als offen fuehrte, nennt jetzt den Aufloesungsstand und WINDOWS #24. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
203 lines
10 KiB
Markdown
203 lines
10 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.
|
||
|
||
## 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.
|
||
|
||
**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).
|