Files
tessera-ctl/docs/mandantentrennung-zugriffsklassifikation.md
T
schalli 5f3a39c2c3 docs(quick-260909-eor): alle 227 Datenbankzugriffe klassifiziert und maschinell abgesichert
WINDOWS #18/#20, Aufgabe 3: docs/mandantentrennung-zugriffsklassifikation.md
haelt fuer jede der 227 this.prisma.*-Fundstellen (32 Dateien, zusammengefasst
zu 59 Datei-Modell-Paaren) eine Klasse fest — muss-mandantengebunden (31),
keine-mandantengebundene-tabelle (16), bewusst-uebergreifend (3, mit
ausgeschriebenem Grund) oder beides (9, der Hintergrunddienst-Sonderfall:
uebergreifend lesen, je Zeile mandantengebunden schreiben — betrifft
ldap.service.ts, tender-digest.scheduler.ts, tender-matching.service.ts).

rls-access-inventory.spec.ts ermittelt die Fundstellen bei jedem Testlauf neu
aus dem Quelltext und vergleicht sie gegen die Tabelle im Dokument — Datei und
Modellname als Schluessel, keine Zeilennummer. Scheitert nachweislich, sobald
eine Fundstelle fehlt oder ein Eintrag verwaist (per Testlauf geprueft, danach
zurueckgesetzt).

Zwei belegte Befunde im Dokument festgehalten: req.tenantPrisma wird gesetzt,
aber nirgends gelesen; WINDOWS #19 (nullbares tenantId bei SearchProvider/
TenderRssFeedSource) bleibt benannter Blocker fuer Etappe 3.

docs/mandantentrennung-datenbankrolle.md verweist jetzt auf das neue
Dokument und korrigiert die ueberholte Zahl 182 auf den nachgemessenen Stand
(227/59). WINDOWS.md #18/#19 um Nachtrag auf diesen Plan ergaenzt; #20 (der
in Aufgabe 1 gemessene und behobene forTenant()-Verbindungsdefekt) als
"fixed" markiert.

Deviation (Rule 3, blockierend fuer die Bestandsaufnahme-Pruefung):
auth.service.ts-Kommentar umformuliert, der zuvor woertlich
"this.prisma.user.findUnique" als erklaerenden Text enthielt und dadurch
einen Eigentreffer der grep-basierten Inventur-Pruefung erzeugte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 11:04:29 +02:00

18 KiB
Raw Blame History

Mandantentrennung — Zugriffsklassifikation

Dieses Dokument gehört zusammen mit docs/mandantentrennung-datenbankrolle.md zur Vorbereitung der Mandantentrennung auf Datenbankebene (WINDOWS #18/#20). Während die Datenbankrolle-Anleitung beschreibt, wie die Umstellung technisch abläuft, hält dieses Dokument fest, welche der 227 this.prisma.*-Fundstellen in apps/api/src beim Umbau in den folgenden Etappen angefasst werden müssen und welche bewusst unverändert bleiben.

Die Bestandsaufnahme unten wird maschinell aus dem Quelltext ermittelt (nicht von Hand zusammengeschrieben) und durch apps/api/src/prisma/rls-access-inventory.spec.ts bei jedem Testlauf gegen den tatsächlichen Stand geprüft. Verschiebt sich eine Zeile, bleibt die Prüfung grün — Vergleichsschlüssel sind Datei und Prisma-Modellname, keine Zeilennummer. Kommt eine neue Fundstelle hinzu oder verschwindet eine bestehende, schlägt die Prüfung fehl, bis dieses Dokument nachgezogen wird.

Die drei Klassen

  • muss-mandantengebunden — berührt auf Rechnung genau eines Mandanten eine Tabelle mit tenantId (oder eine Tabelle, die ihre Mandantenregel über einen Join auf eine solche Tabelle bezieht). Muss in Etappe 2 auf forTenant() umgestellt werden.
  • bewusst-uebergreifend — muss über Mandanten hinweg sehen, mit ausgeschriebenem Grund. Braucht in Etappe 3 eine sichtbare Kennzeichnung (Systemkontext), aber keinen forTenant()-Umbau.
  • keine-mandantengebundene-tabelle — betrifft eines der acht Modelle ohne tenantId bzw. eine bewusst plattformweite Tabelle (D-03). Kein Umbau nötig.
  • beides — ein vierter, im Plan ausdrücklich verlangter Sonderfall: ein Hintergrunddienst, der zurecht über alle Mandanten hinweg eine Liste aufbaut (bewusst übergreifend), aber innerhalb der Schleife je Mandant binden muss (muss mandantengebunden werden). Beide Anteile sind in derselben Datei vorhanden; die Fundstelle bekommt hier eine Sammelklassifikation, die Aufteilung auf Zeilenebene steht in der Begründung.

Zwei belegte Befunde

req.tenantPrisma wird gesetzt, aber nirgends gelesen. tenant.middleware.ts:44 und tenant.guard.ts:41 setzen req.tenantPrisma = forTenant(this.prisma, tenantId). Eine Volltextsuche über apps/api/src nach tenantPrisma außerhalb dieser beiden Dateien und ihrer Tests findet keine lesende Stelle — kein Controller greift darauf zu. Die Verdrahtung besteht, wird aber nicht genutzt. Für Etappe 2 ist zu entscheiden, ob die Controller künftig darüber gehen (dann bräuchte es keinen zweiten forTenant()-Aufruf je Service-Methode) oder ob der Weg ersatzlos entfällt. Dieser Plan entscheidet das nicht, hält den Befund nur fest.

WINDOWS #19 — nullbares tenantId bei SearchProvider und TenderRssFeedSource. Beide Modelle tragen ein nullbares tenantId (SearchProvider für admin-gepflegte Vorgabe-Suchmaschinen, wobei laut 05-02-Entscheidung die tatsächlichen Vorgaben als Konstanten und nicht als DB-Zeilen mit tenantId = NULL geführt werden — die Spalte ist nullbar, ob es heute tatsächlich NULL-Zeilen gibt, ist damit eine offene Frage für Etappe 3, nicht eine hier beantwortete; TenderRssFeedSource für plattformweite RSS-Quellen wie den geseedeten service.bund.de-Feed, D-06). Die aktuelle Policy "tenantId" = current_tenant_id() vergleicht NULL nie gleich — nach dem Scharfschalten wären plattformweite Zeilen für JEDEN Mandanten unsichtbar, nicht nur für fremde. Das ist heute ohne Wirkung (Schalter aus, #18), muss aber in Etappe 3 zusammen mit den restlichen Fundstellen gelöst werden: die Policy braucht für den Lesezugriff tenantId IS NULL OR tenantId = current_tenant_id(), während Schreibzugriffe weiterhin einen Mandanten verlangen.

Übersicht je Bereich (Zeilentreffer, this.prisma.* ohne Specs)

Gemessen mit grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src/<bereich> | grep -v spec | wc -l am 2026-09-09, nach den Änderungen aus Aufgabe 1/2 dieses Plans:

Bereich Treffer Hinweis
tenders 62 unverändert gegenüber measured_baseline
groups 37 unverändert
ldap 21 unverändert
dkv 21 unverändert
user 17 unverändert
module-registry 17 unverändert
dashboard 13 unverändert
auth 8 war 13 in measured_baseline — Aufgabe 2 hat 3 Lesezugriffe durch auth_lookup_*()-Funktionsaufrufe ($queryRaw, kein this.prisma.<Modell>) ersetzt und 5 Schreibzugriffe auf forTenant()-gebundene Aufrufe (tenantPrisma.*, ebenfalls kein this.prisma.<Modell>) umgestellt
calendar 12 unverändert
tenant 8 unverändert
favorites 7 unverändert
settings 4 unverändert
Summe 227 war 232 in measured_baseline, Delta = die 5 in Aufgabe 2 verschwundenen auth-Treffer minus ein bereits vorher fehlerhaft mitgezähltes Kommentarvorkommen in der neuen Kopfzeile von validateUser(), das bewusst umformuliert wurde, um einen Eigentreffer der Bestandsaufnahme-Prüfung zu vermeiden

Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 59 Paare)

Klasse Anzahl Paare
muss-mandantengebunden 31
keine-mandantengebundene-tabelle 16
beides 9
bewusst-uebergreifend 3
Summe 59

Der Hintergrunddienst als Falle — drei beides-Fälle

Ein Planer, der über alle Mandanten iteriert, liest zu Recht übergreifend — muss aber innerhalb der Schleife je Mandant binden. Drei Dateien sind betroffen:

  • ldap.service.ts (AD-Abgleich): iteriert nicht selbst über alle Mandanten (der Sync läuft je Aufruf für einen übergebenen Mandanten), aber innerhalb der Sync-Methoden bleiben Lesezugriffe auf group, ldapConfig und user teils ungebunden, obwohl der Mandant zu diesem Zeitpunkt bereits bekannt ist — die 4 echten forTenant()-Aufrufstellen (Zeilen 762, 905, 1179, 1342) decken nur einen Teil der Lese-/Schreibpfade ab. Das ist der im Plankontext benannte Kern von WINDOWS #20: genau dieser Löschzweig (~Zeile 1559) deutet Leere nach dem Scharfschalten als "Gruppe im Verzeichnis verschwunden".
  • tender-digest.scheduler.ts (Ausschreibungs-Digest): liest tenderMatch/tenderNotificationPref/user bewusst über ALLE Mandanten in einem findMany (ein einziger globaler Cron-Job, kein Mandant im Job selbst — so von Anfang an entworfen, Pitfall 1 in den Kommentaren der Datei). Innerhalb der Verteilung je Treffer ist der Mandant aus der Zeile bekannt und der Versand muss darauf gebunden laufen.
  • tender-matching.service.ts (Ausschreibungs-Sofortmeldung): dieselbe Form — tenderMatch/tenderSavedSearch/user werden für die Sofort-Benachrichtigung über alle betroffenen Mandanten hinweg gelesen, der Versand je Treffer ist mandantengebunden.

Bestandsaufnahme

Maschinell ermittelt, rls-access-inventory.spec.ts hält Vollständigkeit nach. Spalten: Datei, Modell (Prisma-Modellname wie in this.prisma.<Modell> verwendet), Klasse, Begründung.

Datei Modell Klasse Begründung
apps/api/src/auth/auth.service.ts user muss-mandantengebunden getMe, changePassword, adminResetPassword suchen über die Benutzerkennung aus dem Sitzungsnachweis — der Mandant ist dort bereits bekannt (Aufgabe 2 fasst sie bewusst nicht an, siehe SUMMARY).
apps/api/src/calendar/calendar.service.ts calendarSource muss-mandantengebunden Kalenderquellen eines Nutzers, tenantId-Spalte vorhanden.
apps/api/src/dashboard/dashboard.service.ts dashboardLayout muss-mandantengebunden Widget-Anordnung eines Nutzers, tenantId-Spalte vorhanden.
apps/api/src/dashboard/dashboard.service.ts module keine-mandantengebundene-tabelle Modulkatalog ist plattformweit, kein tenantId (Migration 20260909140000, Gruppe b).
apps/api/src/dashboard/dashboard.service.ts searchProvider muss-mandantengebunden tenantId nullbar (WINDOWS #19) — heutige, tatsächlich gespeicherte Zeilen sind nutzerangelegt und tragen einen Mandanten; Vorgabe-Anbieter kommen laut 05-02 aus Konstanten, nicht aus der DB.
apps/api/src/dashboard/dashboard.service.ts widgetInstance muss-mandantengebunden Platzierte Dashboard-Widgets eines Nutzers, tenantId-Spalte vorhanden.
apps/api/src/dkv/dkv.service.ts dkvInvoiceHistory muss-mandantengebunden DKV-Rechnungshistorie je Mandant, tenantId-Spalte vorhanden.
apps/api/src/dkv/dkv.service.ts dkvModuleConfig muss-mandantengebunden Postfach-/Zugangsdaten des DKV-Moduls je Mandant.
apps/api/src/dkv/dkv.service.ts dkvVehicleMaster muss-mandantengebunden Fahrzeugstammdaten des DKV-Moduls je Mandant.
apps/api/src/favorites/favorites.service.ts favoriteLink muss-mandantengebunden Favoriten-Links eines Nutzers, tenantId-Spalte vorhanden.
apps/api/src/groups/groups.service.ts group muss-mandantengebunden Gruppen sind je Mandant, tenantId-Spalte vorhanden.
apps/api/src/groups/groups.service.ts groupMembership muss-mandantengebunden Kein eigenes tenantId, RLS über Join auf Group (Migration 20260618112133-Nachfolger) — braucht trotzdem forTenant(), damit der Join-Kontext gesetzt ist.
apps/api/src/groups/groups.service.ts moduleGrant muss-mandantengebunden Modulfreigaben je Mandant, tenantId-Spalte vorhanden.
apps/api/src/groups/groups.service.ts user muss-mandantengebunden Nutzerverwaltung innerhalb eines Mandanten.
apps/api/src/groups/module-grants.service.ts group muss-mandantengebunden Wie groups.service.ts.
apps/api/src/groups/module-grants.service.ts groupMembership muss-mandantengebunden Kein eigenes tenantId, RLS über Join auf Group.
apps/api/src/groups/module-grants.service.ts moduleGrant muss-mandantengebunden Modulfreigaben je Mandant.
apps/api/src/groups/module-grants.service.ts tenantModuleActivation muss-mandantengebunden Welche Module ein Mandant aktiviert hat, tenantId-Spalte vorhanden.
apps/api/src/groups/module-grants.service.ts user muss-mandantengebunden Zielbenutzer eines Grants innerhalb des Mandanten.
apps/api/src/ldap/ldap-config.service.ts ldapConfig muss-mandantengebunden LDAP-Konfiguration je Mandant, tenantId-Spalte (unique) vorhanden.
apps/api/src/ldap/ldap-config.service.ts ldapFieldMapping muss-mandantengebunden Kein eigenes tenantId, RLS über Join auf LdapConfig.
apps/api/src/ldap/ldap.service.ts group beides AD-Abgleich: 4 echte forTenant()-Aufrufstellen decken einen Teil ab, weitere this.prisma.group-Zugriffe innerhalb der Sync-Methoden bleiben ungebunden, obwohl der Mandant zu diesem Zeitpunkt bekannt ist (siehe Abschnitt "Der Hintergrunddienst als Falle").
apps/api/src/ldap/ldap.service.ts ldapConfig beides Dieselbe Begründung wie group — Konfigurationszugriffe innerhalb der Sync-Methoden.
apps/api/src/ldap/ldap.service.ts user beides Dieselbe Begründung — der Löschzweig um Zeile 1559 (WINDOWS #20) ist der konkrete Risikofall.
apps/api/src/module-registry/module-access.service.ts module keine-mandantengebundene-tabelle Modulkatalog ist plattformweit, kein tenantId.
apps/api/src/module-registry/module-access.service.ts moduleGrant muss-mandantengebunden Modulfreigaben je Mandant.
apps/api/src/module-registry/module-access.service.ts tenantModuleActivation muss-mandantengebunden Aktivierung je Mandant, tenantId-Spalte vorhanden.
apps/api/src/module-registry/module-registry.service.ts module keine-mandantengebundene-tabelle Modulkatalog ist plattformweit.
apps/api/src/module-registry/module-registry.service.ts tenantModuleActivation muss-mandantengebunden Aktivierung je Mandant.
apps/api/src/settings/settings.service.ts smtpConfig muss-mandantengebunden SMTP-Zugangsdaten je Mandant, tenantId-Spalte vorhanden.
apps/api/src/tenant/tenant.controller.ts tenant keine-mandantengebundene-tabelle Tenant ist die Mandantentabelle selbst — hat keine eigene tenantId-Spalte, kann sie per Definition nicht haben (Migration 20260909140000, Gruppe b).
apps/api/src/tenant/tenant.service.ts tenant keine-mandantengebundene-tabelle Dieselbe Begründung.
apps/api/src/tenders/adapters/email-alert.adapter.ts tenderEmailConfig bewusst-uebergreifend fetchTenders() liest bewusst jede aktive TenderEmailConfig-Zeile über ALLE Mandanten in einer Abfrage (Plattform-Scheduler, ein Tick pro Postfach, D-13/D-01) — ausführlich im Dateikopf begründet, darf laut Kommentar niemals in forTenant() verpackt werden.
apps/api/src/tenders/adapters/rss.adapter.ts tenderRssFeedSource bewusst-uebergreifend Fan-out über jeden aktiven Feed, plattformweit UND persönlich, in einer Abfrage (Zeilen 55–83 im Dateikopf begründet) — dieselbe Scheduler-Ebene wie beim E-Mail-Adapter.
apps/api/src/tenders/tender-dedup.service.ts tender keine-mandantengebundene-tabelle Explizit im Dateikopf: "platform-global, RLS-exempt tables. Never wrap these queries in forTenant()." (D-03)
apps/api/src/tenders/tender-dedup.service.ts tenderSource keine-mandantengebundene-tabelle Dieselbe Begründung.
apps/api/src/tenders/tender-digest.scheduler.ts tenderMatch beides Ein einziger globaler Cron-Job liest über ALLE Mandanten (bewusst übergreifend, Pitfall-1-Kommentar im Dateikopf), der Versand je Treffer ist an dessen Mandanten gebunden.
apps/api/src/tenders/tender-digest.scheduler.ts tenderNotificationPref beides Dieselbe Begründung — Präferenzen werden über alle Mandanten gelesen, aber je Zeile mandantenbezogen ausgewertet.
apps/api/src/tenders/tender-digest.scheduler.ts user beides E-Mail-Adressen für den Versand werden über alle Mandanten gelesen, der eigentliche Versand ist je Treffer mandantengebunden.
apps/api/src/tenders/tender-email-config.service.ts tenderEmailConfig muss-mandantengebunden Nutzer-CRUD für die eigene Postfachanbindung (Phase 17, D-01) — anders als der Fan-out-Adapter oben, hier ist der Mandant aus der Anfrage bekannt.
apps/api/src/tenders/tender-fingerprint-backfill.service.ts tender keine-mandantengebundene-tabelle Einmaliges Backfill-Skript über den plattformweiten Tender-Katalog (D-03).
apps/api/src/tenders/tender-ingestion.service.ts tender keine-mandantengebundene-tabelle Explizit im Dateikopf: "Multi-tenant safety (D-03, T-10-09): uses the plain, non-tenant-scoped ... queries ... these are platform-global".
apps/api/src/tenders/tender-ingestion.service.ts tenderSourcePollConfig keine-mandantengebundene-tabelle Plattformweiter Poll-Status, kein tenantId (Migration 20260909140000, Gruppe b).
apps/api/src/tenders/tender-matching.service.ts tender keine-mandantengebundene-tabelle Liest den plattformweiten Katalog (D-03), um Treffer zu berechnen — kein tenantId.
apps/api/src/tenders/tender-matching.service.ts tenderMatch beides Sofortmeldung: Treffer über alle betroffenen Mandanten gelesen, Versand je Treffer mandantengebunden — siehe Abschnitt "Der Hintergrunddienst als Falle".
apps/api/src/tenders/tender-matching.service.ts tenderSavedSearch beides Dieselbe Begründung — gespeicherte Suchprofile aller Mandanten werden gegen neue Treffer geprüft, der Versand ist je Profil mandantengebunden.
apps/api/src/tenders/tender-matching.service.ts user beides Dieselbe Begründung — E-Mail-Adressen für die Sofortmeldung.
apps/api/src/tenders/tender-notification-pref.service.ts tenderNotificationPref muss-mandantengebunden Nutzer-CRUD für die eigenen Benachrichtigungseinstellungen — Mandant aus der Anfrage bekannt.
apps/api/src/tenders/tender-rss-feed.service.ts tenderRssFeedSource muss-mandantengebunden Nutzer-CRUD für die eigenen RSS-Quellen (anders als der Fan-out in adapters/rss.adapter.ts) — Mandant aus der Anfrage bekannt. WINDOWS #19 betrifft die nullbaren plattformweiten Zeilen, nicht diesen CRUD-Pfad.
apps/api/src/tenders/tender-saved-search.service.ts tenderSavedSearch muss-mandantengebunden Nutzer-CRUD für die eigenen gespeicherten Suchprofile — Mandant aus der Anfrage bekannt.
apps/api/src/tenders/tender-scheduler.service.ts tenderSourcePollConfig keine-mandantengebundene-tabelle Plattformweiter DÖE-Poll-Status, kein tenantId — im Dateikopf explizit als "genuine platform-wide singleton" begründet.
apps/api/src/tenders/tenders.controller.ts tender keine-mandantengebundene-tabelle Lesezugriff auf den plattformweiten Katalog (D-03).
apps/api/src/tenders/tenders.controller.ts tenderSourcePollConfig keine-mandantengebundene-tabelle Plattformweiter Poll-Status, admin-verwaltet, kein tenantId.
apps/api/src/tenders/tenders.module.ts tenderSourcePollConfig keine-mandantengebundene-tabelle Singleton-Bestückung beim Boot — im Dateikopf explizit als "global, RLS-exempt (D-03)" begründet.
apps/api/src/tenders/tender-triage.service.ts tenderTriage muss-mandantengebunden Favorisierungs-/Ablehnungsstatus eines Nutzers, tenantId-Spalte vorhanden.
apps/api/src/user/admin-seed.service.ts tenant keine-mandantengebundene-tabelle Legt beim ersten Start den Standard-Mandanten selbst an — Tenant hat keine tenantId-Spalte.
apps/api/src/user/admin-seed.service.ts user bewusst-uebergreifend Erstanlage des Administrators beim ersten Start: läuft einmalig beim Boot, BEVOR irgendein Mandantenkontext existiert, um den allerersten Mandanten samt Admin-Nutzer anzulegen — es gibt zu diesem Zeitpunkt strukturell keinen Mandanten, an den gebunden werden könnte.
apps/api/src/user/user.controller.ts user muss-mandantengebunden Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins.
apps/api/src/user/user.service.ts user muss-mandantengebunden Dieselbe Begründung.

Was diese Etappe NICHT entscheidet

  • Ob Controller künftig über req.tenantPrisma statt eines erneuten forTenant()-Aufrufs im Service gehen (offener Befund oben).
  • Wie die WINDOWS-#19-Policy für SearchProvider/TenderRssFeedSource am Ende genau lautet — nur, dass sie vor dem Scharfschalten gelöst sein muss.
  • Die Reihenfolge und Zuschnitt der Etappe-2-Pläne — dafür ist die Klassen-Verteilung oben der Arbeitsvorrat, siehe <next_stages> im Plan 260909-eor-PLAN.md.