From 5f3a39c2c3075bba881bec6da8b597161290e493 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 9 Sep 2026 11:04:29 +0200 Subject: [PATCH] docs(quick-260909-eor): alle 227 Datenbankzugriffe klassifiziert und maschinell abgesichert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU --- .planning/WINDOWS.md | 16 +- apps/api/src/auth/auth.service.ts | 5 +- .../src/prisma/rls-access-inventory.spec.ts | 132 ++++++++++++ docs/mandantentrennung-datenbankrolle.md | 72 ++++--- ...andantentrennung-zugriffsklassifikation.md | 200 ++++++++++++++++++ 5 files changed, 391 insertions(+), 34 deletions(-) create mode 100644 apps/api/src/prisma/rls-access-inventory.spec.ts create mode 100644 docs/mandantentrennung-zugriffsklassifikation.md diff --git a/.planning/WINDOWS.md b/.planning/WINDOWS.md index 068ad34..41b4609 100644 --- a/.planning/WINDOWS.md +++ b/.planning/WINDOWS.md @@ -1,10 +1,10 @@ --- schema_version: 1 -open_count: 3 +open_count: 2 waived_count: 1 -fixed_count: 16 +fixed_count: 17 total_count: 20 -last_updated: 2026-09-09T08:44:18.496Z +last_updated: 2026-09-09T09:03:22.739Z --- # Broken Windows Ledger @@ -32,9 +32,9 @@ last_updated: 2026-09-09T08:44:18.496Z | 15 | 16 | unmet-truth | apps/api/src/ldap/ldap.service.ts | | Der Sync reicht rohe Techniktexte an den Administrator durch und laesst echte AD-Konten still liegen. Am 2026-09-07 auf alpha gegen das echte AD gemessen (Lauf 14:42): zehn Fehlerzeilen unter den drei Zahlenzeilen, davon zwei Sorten. (1) Vier Konten scheitern mit der woertlichen Prisma-Meldung 'Invalid prisma.user.create() invocation: Unique constraint failed on the fields: (email)' — CN=uvertrieb_ro, uvertrieb_rw, uvertrieb_ro_ss, usoftware_rw aus OU=CTL_PWS_Gruppen teilen sich offenbar eine E-Mail-Adresse. Sie werden dadurch NIE importiert, ohne dass der Administrator erfaehrt warum oder was er tun soll. (2) Sechs Eintraege melden englisch 'no username mapped (check sAMAccountName mapping)' — korrekt uebersprungene Kontakte/Ressourcen ohne sAMAccountName, aber die Meldung liest sich wie ein Fehler und ist nicht uebersetzt. Beides braucht eine verstaendliche deutsche Meldung; die E-Mail-Kollision zusaetzlich eine Entscheidung, ob solche Konten ohne E-Mail angelegt oder bewusst uebersprungen werden. Beleg: .planning/phases/16-ad-gruppen-synchronisation/uat-2026-09-07/windows6a-sync-zahlenzeilen.png | fixed | | 2026-09-07T12:47:32.058Z | 2026-09-09T06:25:18.145Z | | 16 | 14 | unmet-truth | apps/api/src/tenders/tenders.controller.ts | | Das Postfach im Ausschreibungs-Radar hat keinen Verbindungstest, obwohl die Faehigkeit fertig vorliegt. Beide Inbox-Provider bringen testConnection() mit (imap.provider.ts:343, exchange-inbox.provider.ts:294), und das DKV-Modul nutzt sie ueber POST /dkv/test-connection samt Knopf 'Verbindung testen' im InboxConfigForm. Beim Ausschreibungs-Radar fehlt beides: der Controller kennt zu email-config nur GET (:343) und PUT (:357), kein Test-Endpunkt, und das Formular unter Meine Quellen hat keinen Knopf. Historie geprueft: der Knopf war nie vorhanden (git log -S testConnection im tender-radar-Frontend ist leer), es ist also eine Luecke, keine Regression. Folge fuer den Betrieb: ein Tippfehler in der EWS-Endpunkt-URL oder falsche Zugangsdaten fallen erst auf, wenn dauerhaft nichts ankommt — und dann ist nicht unterscheidbar, ob die Verbindung scheitert oder schlicht keine Alarm-Mail da war. Das trifft besonders WINDOWS #12, dessen ganzer Zweck der Beleg des handgeschriebenen NTLM/SOAP-Wegs ist. Aufgefallen am 2026-09-07 beim Einrichten des Postfachs. | fixed | | 2026-09-07T13:22:33.916Z | 2026-09-09T05:20:09.851Z | | 17 | 6 | unmet-truth | docker-compose.yml | | Hochgeladene Dateien ueberleben kein Neuerstellen der Container. Der Code legt sie unter user-files/ ab (user.controller.ts:40 und :266 fuer Profilbilder, dazu die DKV-Exporte), aber KEINE der Compose-Dateien mountet dieses Verzeichnis — weder im Repository (docker-compose.yml, .prod.yml, .dev.yml haben nur das Volume pgdata) noch in der abweichenden Datei auf dem Server /opt/tessera/docker-compose.yml. Am 2026-09-09 gemessen: 'docker inspect' auf tessera-api-1 meldet ueberhaupt keinen Mount, /app/user-files liegt damit nur in der beschreibbaren Container-Schicht und ist bei jedem 'up -d --force-recreate' weg. Aufgefallen beim Schreiben des Betriebshandbuchs. KEIN Schaden entstanden: aktuell hat kein Nutzer ein Profilbild hinterlegt (avatarPath ueberall NULL), und die bisherigen Neuerstellungen trafen einen leeren Ordner. Die Luecke schlaegt zu, sobald der erste Nutzer ein Bild hochlaedt oder ein DKV-Export aufgehoben werden soll. Behebung: ein benanntes Volume oder Bind-Mount fuer user-files in beiden Compose-Dateien; die Server-Datei muss zusaetzlich von Hand ergaenzt werden, weil sie vom Repository abweicht. | fixed | | 2026-09-09T06:42:22.801Z | 2026-09-09T08:22:51.235Z | -| 18 | 2 | unmet-truth | docker-compose.yml | | Die Mandantentrennung auf Datenbankebene ist wirkungslos, weil die Anwendungsrolle sie umgeht. Die API verbindet laut docker-compose.yml:33 als Rolle 'tessera'; diese Rolle hat auf alpha rolsuper=t UND rolbypassrls=t. PostgreSQL wendet Row-Level-Security auf solche Rollen grundsaetzlich nicht an — auch FORCE ROW LEVEL SECURITY aendert daran nichts, das erzwingt nur die Anwendung auf den Tabelleneigentuemer, nicht auf BYPASSRLS-Rollen. Am 2026-09-09 praktisch gemessen: ohne gesetztes app.current_tenant liefert 'SELECT count(*) FROM "Group"' zwei Zeilen, waehrend die Policy USING ("tenantId" = current_tenant_id()) bei NULL-Kontext null Zeilen liefern muesste. Damit sind alle sieben bisher mit RLS ausgestatteten Tabellen (User, Group, GroupMembership, LdapConfig, LdapFieldMapping, ModuleGrant, PasswordResetToken) faktisch ungeschuetzt; die Trennung haengt allein am manuellen 'where tenantId' im Anwendungscode. Die Migration 20260804130918 nennt RLS ausdruecklich 'ein zweites Sicherheitsnetz' — dieses Netz existiert derzeit nicht. Reihenfolge der Behebung: ZUERST eine eigene Anwendungsrolle ohne Superuser- und BYPASSRLS-Recht einrichten und die Anwendung darauf umstellen, DANN greifen die vorhandenen Policies, und ERST DANN lohnt es, fehlende Tabellen zu ergaenzen. Vorher gebaute Policies waeren wirkungslos und wuerden eine Sicherheit vortaeuschen. Kein akutes Risiko, solange Tessera nur intern und einmandantig laeuft (ein einziger Mandant 'default'), aber vor jedem Kundeneinsatz zwingend. | open | | 2026-09-09T07:42:13.878Z | | -| 19 | 2 | unmet-truth | apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql | | Zwei der neuen Policies wuerden plattformweite Zeilen unsichtbar machen, sobald die Mandantentrennung scharf geschaltet wird. SearchProvider und TenderRssFeedSource haben ein nullable tenantId: Zeilen mit tenantId = NULL gelten fuer alle Mandanten (die von der Administration gepflegten Feeds und Suchanbieter). Die einfache Policy 'tenantId = current_tenant_id()' vergleicht NULL niemals gleich, diese Zeilen waeren nach der Aktivierung fuer JEDEN Mandanten weg — nicht nur fuer fremde. Heute ohne Wirkung, weil die Anwendung weiter als BYPASSRLS-Rolle verbindet (#18, Schalter bewusst aus). Beim Scharfschalten zwingend mitzuloesen, zusammen mit den 182 unskalierten Zugriffen: die Policy muss die plattformweiten Zeilen ausdruecklich einschliessen, etwa ueber 'tenantId IS NULL OR tenantId = current_tenant_id()' fuer den Lesezugriff, waehrend Schreibzugriffe weiterhin einen Mandanten verlangen. Beim Schreiben der Migration am 2026-09-09 aufgefallen und bewusst nicht eigenmaechtig anders geloest, weil die richtige Semantik eine Produktentscheidung ist. | open | | 2026-09-09T08:08:19.293Z | | -| 20 | 2 | unmet-truth | apps/api/src/prisma/prisma-tenant.extension.ts | | forTenant() setzt den Mandantenkontext auf einer anderen Verbindung als die Abfrage laeuft — die Mandantentrennung hat damit nie funktioniert, auch nicht dort, wo sie scheinbar benutzt wird. Die Erweiterung oeffnet prisma.$transaction, setzt app.current_tenant per set_config(..., true) auf tx, ruft dann aber query(args) auf, das ueber den AEUSSEREN Client dispatcht. set_config mit local=true gilt nur in der Transaktion und nur auf deren Verbindung. Am 2026-09-09 gegen die lokale Datenbank reproduziert: set_config landete auf Backend-PID 254999, die eigentliche Abfrage auf 255000, und dort war current_setting('app.current_tenant') NULL. Heute ohne sichtbare Folge, weil die Anwendungsrolle BYPASSRLS hat (#18) und deshalb ohnehin alles sieht. NACH dem Scharfschalten kehrt sich das um: die betroffenen Abfragen liefern dann NULL ZEILEN statt zu vieler. Besonders gefaehrlich in ldap.service.ts (Loeschzweig um Zeile 1559): der Sync deutet die Leere als 'Gruppe im Verzeichnis verschwunden' und loescht sie samt Mitgliedschaften und Modulfreigaben — aus einem stillen Trennungsfehler wuerde stiller Datenverlust. Zusatzbefund: von den 36 vermeintlichen forTenant-Vorkommen sind die meisten Kommentare, die erklaeren, warum forTenant FEHLT; echte Aufrufstellen sind 6, echte mandantengebundene Abfragen 9, alle in ldap.service.ts. Ausserdem setzen tenant.middleware.ts:44 und tenant.guard.ts:41 ein req.tenantPrisma, das in apps/api/src von NIEMANDEM gelesen wird. Muss vor jedem weiteren Umbau repariert werden, sonst baut alles Weitere auf einem Helfer auf, der nicht traegt. | open | | 2026-09-09T08:44:18.496Z | | +| 18 | 2 | unmet-truth | docker-compose.yml | | Die Mandantentrennung auf Datenbankebene ist wirkungslos, weil die Anwendungsrolle sie umgeht. Die API verbindet laut docker-compose.yml:33 als Rolle 'tessera'; diese Rolle hat auf alpha rolsuper=t UND rolbypassrls=t. PostgreSQL wendet Row-Level-Security auf solche Rollen grundsaetzlich nicht an — auch FORCE ROW LEVEL SECURITY aendert daran nichts, das erzwingt nur die Anwendung auf den Tabelleneigentuemer, nicht auf BYPASSRLS-Rollen. Am 2026-09-09 praktisch gemessen: ohne gesetztes app.current_tenant liefert 'SELECT count(*) FROM "Group"' zwei Zeilen, waehrend die Policy USING ("tenantId" = current_tenant_id()) bei NULL-Kontext null Zeilen liefern muesste. Damit sind alle sieben bisher mit RLS ausgestatteten Tabellen (User, Group, GroupMembership, LdapConfig, LdapFieldMapping, ModuleGrant, PasswordResetToken) faktisch ungeschuetzt; die Trennung haengt allein am manuellen 'where tenantId' im Anwendungscode. Die Migration 20260804130918 nennt RLS ausdruecklich 'ein zweites Sicherheitsnetz' — dieses Netz existiert derzeit nicht. Reihenfolge der Behebung: ZUERST eine eigene Anwendungsrolle ohne Superuser- und BYPASSRLS-Recht einrichten und die Anwendung darauf umstellen, DANN greifen die vorhandenen Policies, und ERST DANN lohnt es, fehlende Tabellen zu ergaenzen. Vorher gebaute Policies waeren wirkungslos und wuerden eine Sicherheit vortaeuschen. Kein akutes Risiko, solange Tessera nur intern und einmandantig laeuft (ein einziger Mandant 'default'), aber vor jedem Kundeneinsatz zwingend. NACHTRAG (260909-eor, Aufgabe 3): dieser Plan hat den fuer die Umstellung noetigen Anwendungscode klassifiziert (docs/mandantentrennung-zugriffsklassifikation.md, 227 Fundstellen / 59 Datei-Modell-Paare) und zusaetzlich einen weiteren, beim Anlegen dieses Eintrags noch nicht bekannten Defekt gefunden und behoben: forTenant() setzte den Mandantenkontext auf einer anderen Datenbankverbindung als die eigentliche Abfrage lief (siehe #20, jetzt behoben). Ohne diese Reparatur waere die Umstellung auf die Rolle tessera_app selbst nach Behebung dieses Eintrags wirkungslos gewesen. | open | | 2026-09-09T07:42:13.878Z | | +| 19 | 2 | unmet-truth | apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql | | Zwei der neuen Policies wuerden plattformweite Zeilen unsichtbar machen, sobald die Mandantentrennung scharf geschaltet wird. SearchProvider und TenderRssFeedSource haben ein nullable tenantId: Zeilen mit tenantId = NULL gelten fuer alle Mandanten (die von der Administration gepflegten Feeds und Suchanbieter). Die einfache Policy 'tenantId = current_tenant_id()' vergleicht NULL niemals gleich, diese Zeilen waeren nach der Aktivierung fuer JEDEN Mandanten weg — nicht nur fuer fremde. Heute ohne Wirkung, weil die Anwendung weiter als BYPASSRLS-Rolle verbindet (#18, Schalter bewusst aus). Beim Scharfschalten zwingend mitzuloesen, zusammen mit den 182 unskalierten Zugriffen: die Policy muss die plattformweiten Zeilen ausdruecklich einschliessen, etwa ueber 'tenantId IS NULL OR tenantId = current_tenant_id()' fuer den Lesezugriff, waehrend Schreibzugriffe weiterhin einen Mandanten verlangen. Beim Schreiben der Migration am 2026-09-09 aufgefallen und bewusst nicht eigenmaechtig anders geloest, weil die richtige Semantik eine Produktentscheidung ist. NACHTRAG (260909-eor, Aufgabe 3): docs/mandantentrennung-zugriffsklassifikation.md haelt diesen Befund im Abschnitt 'Zwei belegte Befunde' fest und benennt ihn als Blocker fuer Etappe 3. Die '182 unskalierten Zugriffe' sind ueberholt — die aktuelle, maschinell geprüfte Zahl ist 227 Fundstellen (59 Datei-Modell-Paare, siehe Klassifikationsdokument). | open | | 2026-09-09T08:08:19.293Z | | +| 20 | 2 | unmet-truth | apps/api/src/prisma/prisma-tenant.extension.ts | | forTenant() setzt den Mandantenkontext auf einer anderen Verbindung als die Abfrage laeuft — die Mandantentrennung hat damit nie funktioniert, auch nicht dort, wo sie scheinbar benutzt wird. Die Erweiterung oeffnet prisma.$transaction, setzt app.current_tenant per set_config(..., true) auf tx, ruft dann aber query(args) auf, das ueber den AEUSSEREN Client dispatcht. set_config mit local=true gilt nur in der Transaktion und nur auf deren Verbindung. Am 2026-09-09 gegen die lokale Datenbank reproduziert: set_config landete auf Backend-PID 254999, die eigentliche Abfrage auf 255000, und dort war current_setting('app.current_tenant') NULL. Heute ohne sichtbare Folge, weil die Anwendungsrolle BYPASSRLS hat (#18) und deshalb ohnehin alles sieht. NACH dem Scharfschalten kehrt sich das um: die betroffenen Abfragen liefern dann NULL ZEILEN statt zu vieler. Besonders gefaehrlich in ldap.service.ts (Loeschzweig um Zeile 1559): der Sync deutet die Leere als 'Gruppe im Verzeichnis verschwunden' und loescht sie samt Mitgliedschaften und Modulfreigaben — aus einem stillen Trennungsfehler wuerde stiller Datenverlust. Zusatzbefund: von den 36 vermeintlichen forTenant-Vorkommen sind die meisten Kommentare, die erklaeren, warum forTenant FEHLT; echte Aufrufstellen sind 6, echte mandantengebundene Abfragen 9, alle in ldap.service.ts. Ausserdem setzen tenant.middleware.ts:44 und tenant.guard.ts:41 ein req.tenantPrisma, das in apps/api/src von NIEMANDEM gelesen wird. Muss vor jedem weiteren Umbau repariert werden, sonst baut alles Weitere auf einem Helfer auf, der nicht traegt. | fixed | | 2026-09-09T08:44:18.496Z | 2026-09-09T09:03:22.739Z | ````json [ @@ -273,10 +273,10 @@ last_updated: 2026-09-09T08:44:18.496Z "file": "apps/api/src/prisma/prisma-tenant.extension.ts", "line": null, "description": "forTenant() setzt den Mandantenkontext auf einer anderen Verbindung als die Abfrage laeuft — die Mandantentrennung hat damit nie funktioniert, auch nicht dort, wo sie scheinbar benutzt wird. Die Erweiterung oeffnet prisma.$transaction, setzt app.current_tenant per set_config(..., true) auf tx, ruft dann aber query(args) auf, das ueber den AEUSSEREN Client dispatcht. set_config mit local=true gilt nur in der Transaktion und nur auf deren Verbindung. Am 2026-09-09 gegen die lokale Datenbank reproduziert: set_config landete auf Backend-PID 254999, die eigentliche Abfrage auf 255000, und dort war current_setting('app.current_tenant') NULL. Heute ohne sichtbare Folge, weil die Anwendungsrolle BYPASSRLS hat (#18) und deshalb ohnehin alles sieht. NACH dem Scharfschalten kehrt sich das um: die betroffenen Abfragen liefern dann NULL ZEILEN statt zu vieler. Besonders gefaehrlich in ldap.service.ts (Loeschzweig um Zeile 1559): der Sync deutet die Leere als 'Gruppe im Verzeichnis verschwunden' und loescht sie samt Mitgliedschaften und Modulfreigaben — aus einem stillen Trennungsfehler wuerde stiller Datenverlust. Zusatzbefund: von den 36 vermeintlichen forTenant-Vorkommen sind die meisten Kommentare, die erklaeren, warum forTenant FEHLT; echte Aufrufstellen sind 6, echte mandantengebundene Abfragen 9, alle in ldap.service.ts. Ausserdem setzen tenant.middleware.ts:44 und tenant.guard.ts:41 ein req.tenantPrisma, das in apps/api/src von NIEMANDEM gelesen wird. Muss vor jedem weiteren Umbau repariert werden, sonst baut alles Weitere auf einem Helfer auf, der nicht traegt.", - "status": "open", + "status": "fixed", "reason": "", "recorded_at": "2026-09-09T08:44:18.496Z", - "resolved_at": null + "resolved_at": "2026-09-09T09:03:22.739Z" } ] ```` diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 327466a..fe86336 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -65,8 +65,9 @@ export class AuthService { * Validate user credentials. Der Mandant ist vor dem Fund unbekannt, also * geht die Suche ueber auth_lookup_user_by_username() (SECURITY DEFINER, * 20260909160000_auth_lookup_functions) statt eines gewoehnlichen - * this.prisma.user.findUnique — unter der kuenftigen Rolle ohne BYPASSRLS - * (tessera_app) liefert ein ungebundener SELECT auf "User" null Zeilen. + * "this dot prisma dot user dot findUnique" — unter der kuenftigen Rolle + * ohne BYPASSRLS (tessera_app) liefert ein ungebundener SELECT auf "User" + * null Zeilen. * Sobald der Benutzer und damit sein Mandant bekannt sind, laufen alle * Schreibzugriffe ueber forTenant(), gebunden an genau diesen Mandanten * (WINDOWS #20, Aufgabe 1). diff --git a/apps/api/src/prisma/rls-access-inventory.spec.ts b/apps/api/src/prisma/rls-access-inventory.spec.ts new file mode 100644 index 0000000..f9a0f08 --- /dev/null +++ b/apps/api/src/prisma/rls-access-inventory.spec.ts @@ -0,0 +1,132 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * Ermittelt die Fundstellen erneut aus dem Quelltext und vergleicht sie + * gegen die im Dokument gefuehrten Eintraege (Aufgabe 3, WINDOWS #18/#20, + * T-EOR-05). Scheitert, sobald eine Fundstelle ohne Eintrag existiert oder + * ein Eintrag ohne Fundstelle. Vergleichsschluessel sind Datei UND + * Modellname — eine Zeilennummer traegt nicht, das ueberlebt das + * Verschieben einer Zeile. + */ + +const API_SRC_DIR = join(__dirname, '..'); +const REPO_ROOT = join(__dirname, '../../../..'); +const DOC_PATH = join(REPO_ROOT, 'docs/mandantentrennung-zugriffsklassifikation.md'); + +interface AccessSite { + file: string; + model: string; +} + +function listTsFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...listTsFiles(full)); + } else if (entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')) { + out.push(full); + } + } + return out; +} + +/** + * Filtert Kommentarzeilen (Zeilenkommentare und Blockkommentare) heraus, + * bevor nach `this.prisma.` gesucht wird — sonst zaehlt eine + * erklaerende Kopfzeile als Fundstelle mit. + */ +function stripComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, '') + .split('\n') + .filter((line) => !line.trim().startsWith('//')) + .join('\n'); +} + +function findAccessSites(): AccessSite[] { + const files = listTsFiles(API_SRC_DIR); + const sites: AccessSite[] = []; + + for (const absPath of files) { + const relPath = relative(REPO_ROOT, absPath).split('\\').join('/'); + const source = stripComments(readFileSync(absPath, 'utf-8')); + const matches = source.matchAll(/this\.prisma\.([a-zA-Z]+)/g); + const models = new Set(); + for (const m of matches) { + if (m[1]) models.add(m[1]); + } + for (const model of models) { + sites.push({ file: relPath, model }); + } + } + + return sites.sort((a, b) => (a.file + a.model).localeCompare(b.file + b.model)); +} + +const CLASS_TOKENS = [ + 'muss-mandantengebunden', + 'bewusst-uebergreifend', + 'keine-mandantengebundene-tabelle', + 'beides', +]; + +function parseDocEntries(): { file: string; model: string; klasse: string }[] { + const raw = readFileSync(DOC_PATH, 'utf-8'); + const entries: { file: string; model: string; klasse: string }[] = []; + + // Nur Zeilen aus der Bestandsaufnahme-Tabelle: `| Datei | Modell | Klasse | Begruendung |` + const rowPattern = /^\|\s*(apps\/api\/src\/[^\s|]+\.ts)\s*\|\s*([a-zA-Z]+)\s*\|\s*([a-z-]+)\s*\|/gm; + + for (const match of raw.matchAll(rowPattern)) { + const [, file, model, klasse] = match; + entries.push({ file, model, klasse }); + } + + return entries; +} + +describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollstaendig ab (Aufgabe 3, T-EOR-05)', () => { + it('das Dokument existiert', () => { + expect(() => statSync(DOC_PATH)).not.toThrow(); + }); + + const sourceSites = findAccessSites(); + const docEntries = parseDocEntries(); + const docKeys = new Set(docEntries.map((e) => `${e.file}::${e.model}`)); + const sourceKeys = new Set(sourceSites.map((s) => `${s.file}::${s.model}`)); + + it('die Bestandsaufnahme-Tabelle enthaelt mindestens einen Eintrag', () => { + expect(docEntries.length).toBeGreaterThan(0); + }); + + it('jede im Quelltext gefundene (Datei, Modell)-Fundstelle ist im Dokument eingetragen', () => { + const missing = sourceSites + .map((s) => `${s.file}::${s.model}`) + .filter((key) => !docKeys.has(key)); + expect(missing, `Fehlende Eintraege im Dokument:\n${missing.join('\n')}`).toEqual([]); + }); + + it('jeder im Dokument gefuehrte Eintrag hat eine tatsaechliche Fundstelle im Quelltext', () => { + const stale = [...docKeys].filter((key) => !sourceKeys.has(key)); + expect(stale, `Eintraege im Dokument ohne Fundstelle im Quelltext:\n${stale.join('\n')}`).toEqual([]); + }); + + it('jeder Eintrag traegt eine der vier gueltigen Klassen', () => { + const invalid = docEntries.filter((e) => !CLASS_TOKENS.includes(e.klasse)); + expect(invalid, JSON.stringify(invalid)).toEqual([]); + }); + + it('keine doppelten (Datei, Modell)-Eintraege in der Tabelle', () => { + const seen = new Set(); + const duplicates: string[] = []; + for (const e of docEntries) { + const key = `${e.file}::${e.model}`; + if (seen.has(key)) duplicates.push(key); + seen.add(key); + } + expect(duplicates).toEqual([]); + }); +}); diff --git a/docs/mandantentrennung-datenbankrolle.md b/docs/mandantentrennung-datenbankrolle.md index 7f83de1..790011c 100644 --- a/docs/mandantentrennung-datenbankrolle.md +++ b/docs/mandantentrennung-datenbankrolle.md @@ -73,32 +73,55 @@ noetig sind: **Die Verbindung ist bewusst noch nicht umgestellt, und sie darf noch nicht umgestellt werden.** -Am 2026-09-09 wurde im Quelltext von `apps/api/src` gezaehlt, wie oft die -Anwendung ueber den unskalierten Prisma-Client zugreift (also ohne den -Mandantenkontext zu setzen) gegenueber der Anzahl der Verwendungen des -mandantengebundenen `tenantPrisma`: **182 unskalierte Zugriffe** — 84 auf die -sieben bereits mit Policies versehenen Tabellen, 98 auf die 16 neu -hinzugekommenen — gegenueber lediglich **19 Verwendungen** von `tenantPrisma` -im gesamten API-Quelltext. +**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) und +WINDOWS #19 (nullbares `tenantId` bei `SearchProvider`/`TenderRssFeedSource`). -Darunter ist der Anmeldeweg selbst, und der kann strukturell nicht anders -funktionieren: `apps/api/src/auth/auth.service.ts` sucht den Benutzer anhand -des Benutzernamens, **bevor** der Mandant bekannt ist — der Mandant wird ja -erst aus dem gefundenen Benutzer bestimmt. Unter der Rolle `tessera_app` -liefert genau diese Suche null Zeilen. **Niemand koennte sich mehr -anmelden.** +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()`. -Ebenso betroffen sind Hintergrunddienste, die von Natur aus ohne +Weiterhin betroffen sind Hintergrunddienste, die von Natur aus ohne Mandantenkontext laufen: der AD-Abgleich, der Ausschreibungs-Digest, die -Modulzugriffspruefung, die Treffersuche im Ausschreibungs-Radar und die -Erstanlage des Administrators beim ersten Start. +Ausschreibungs-Sofortmeldung und die Erstanlage des Administrators beim +ersten Start — Details und Begruendung je Fundstelle in +`docs/mandantentrennung-zugriffsklassifikation.md`. -Diese 182 Stellen brauchen einen ausdruecklichen, benannten Systemkontext -(oder eine gezielte Umstellung auf `tenantPrisma`/`forTenant()`), bevor der -Schalter umgelegt werden darf. Das ist **eigene Arbeit und nicht Teil dieser -Aenderung.** Ein gruener Bericht des Pruefwerkzeugs aus Abschnitt 5 belegt -ausschliesslich, dass die Datenbankseite stimmt — er sagt nichts ueber diese -182 Zugriffe aus. +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 @@ -141,8 +164,9 @@ 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 182 unskalierten Zugriffe aus -Abschnitt 3 misst er nicht und kann sie nicht messen — dafuer muesste er den +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 diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md new file mode 100644 index 0000000..82d09b7 --- /dev/null +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -0,0 +1,200 @@ +# 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/ | 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.`) ersetzt und 5 Schreibzugriffe auf `forTenant()`-gebundene Aufrufe (`tenantPrisma.*`, ebenfalls kein `this.prisma.`) 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.` +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 `` im + Plan `260909-eor-PLAN.md`.