diff --git a/apps/api/scripts/rls-scratch-check.mjs b/apps/api/scripts/rls-scratch-check.mjs index bb55d49..0ec2411 100644 --- a/apps/api/scripts/rls-scratch-check.mjs +++ b/apps/api/scripts/rls-scratch-check.mjs @@ -810,6 +810,315 @@ async function runGroupsAreaChecks(adminUrl, scratchRoleUrl, results) { } } +/** + * Aufgabe 1 (260909-laa) — misst die drei Sonderfaelle des Bereichs tenders + * unter der Rolle ohne BYPASSRLS, mit den fuenf Policies WORTGLEICH aus der + * ausgelieferten Migration `_rls_remaining_tenant_tables` (dieselbe Datei, + * die bereits die TenantModuleActivation-Policy fuer runGroupsAreaChecks + * liefert). Findet die Extraktion eine der fuenf nicht, meldet dieser + * Abschnitt eine FEHLGESCHLAGENE Pruefung und bricht ab, statt mit einer + * geratenen Policy weiterzumessen. + * + * Legt keine Tabelle an, auf der eine andere Pruefung dieses Werkzeugs + * aufsetzt — anders als runGroupsAreaChecks() (Vorbedingung fuer + * runTransactionShapeMeasurement()) ist dieser Abschnitt in der Aufrufkette + * ein Blatt: er muss NACH runGroupsAreaChecks() und VOR + * runTransactionShapeMeasurement() laufen, weil Letztere weiterhin auf der + * von runGroupsAreaChecks() angelegten Tabelle "Group" aufsetzt — dieser + * Abschnitt aendert daran nichts. + */ +async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) { + const remainingMigrationSql = readRemainingTenantTablesMigrationSql(); + + const emailConfigPolicy = remainingMigrationSql + ? extractPolicySql(remainingMigrationSql, 'TenderEmailConfig') + : null; + const notificationPrefPolicy = remainingMigrationSql + ? extractPolicySql(remainingMigrationSql, 'TenderNotificationPref') + : null; + const rssFeedPolicy = remainingMigrationSql + ? extractPolicySql(remainingMigrationSql, 'TenderRssFeedSource') + : null; + const savedSearchPolicy = remainingMigrationSql + ? extractPolicySql(remainingMigrationSql, 'TenderSavedSearch') + : null; + const triagePolicy = remainingMigrationSql + ? extractPolicySql(remainingMigrationSql, 'TenderTriage') + : null; + + if ( + !emailConfigPolicy || + !notificationPrefPolicy || + !rssFeedPolicy || + !savedSearchPolicy || + !triagePolicy + ) { + report( + results, + 'tenders-policies-aus-migration-gefunden', + false, + 'CREATE POLICY fuer "TenderEmailConfig", "TenderNotificationPref", "TenderRssFeedSource", "TenderSavedSearch" und/oder "TenderTriage" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden', + ); + return; + } + + await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => { + await db.$executeRawUnsafe(` + CREATE TABLE "TenderEmailConfig" ( + id text PRIMARY KEY, + "userId" text NOT NULL UNIQUE, + "tenantId" text NOT NULL + ); + `); + await db.$executeRawUnsafe(` + CREATE TABLE "TenderNotificationPref" ( + id text PRIMARY KEY, + "userId" text NOT NULL UNIQUE, + "tenantId" text NOT NULL + ); + `); + await db.$executeRawUnsafe(` + CREATE TABLE "TenderRssFeedSource" ( + id text PRIMARY KEY, + url text NOT NULL, + "userId" text, + "tenantId" text, + UNIQUE ("userId", url) + ); + `); + await db.$executeRawUnsafe(` + CREATE TABLE "TenderSavedSearch" ( + id text PRIMARY KEY, + "userId" text NOT NULL, + "tenantId" text NOT NULL, + name text NOT NULL, + UNIQUE ("userId", name) + ); + `); + await db.$executeRawUnsafe(` + CREATE TABLE "TenderTriage" ( + id text PRIMARY KEY, + "userId" text NOT NULL, + "tenantId" text NOT NULL, + "tenderId" text NOT NULL, + UNIQUE ("userId", "tenderId") + ); + `); + + for (const table of [ + 'TenderEmailConfig', + 'TenderNotificationPref', + 'TenderRssFeedSource', + 'TenderSavedSearch', + 'TenderTriage', + ]) { + await db.$executeRawUnsafe(`ALTER TABLE "${table}" ENABLE ROW LEVEL SECURITY;`); + await db.$executeRawUnsafe(`ALTER TABLE "${table}" FORCE ROW LEVEL SECURITY;`); + } + await db.$executeRawUnsafe(emailConfigPolicy); + await db.$executeRawUnsafe(notificationPrefPolicy); + await db.$executeRawUnsafe(rssFeedPolicy); + await db.$executeRawUnsafe(savedSearchPolicy); + await db.$executeRawUnsafe(triagePolicy); + + for (const table of [ + 'TenderEmailConfig', + 'TenderNotificationPref', + 'TenderRssFeedSource', + 'TenderSavedSearch', + 'TenderTriage', + ]) { + await db.$executeRawUnsafe( + `GRANT SELECT, INSERT, UPDATE, DELETE ON "${table}" TO ${SCRATCH_ROLE_NAME}`, + ); + } + + await db.$executeRawUnsafe(` + INSERT INTO "TenderEmailConfig" (id, "userId", "tenantId") VALUES + ('ec-a', 'user-a1', 'TENANT-A'), + ('ec-b', 'user-b', 'TENANT-B'); + `); + await db.$executeRawUnsafe(` + INSERT INTO "TenderNotificationPref" (id, "userId", "tenantId") VALUES + ('np-a', 'user-a1', 'TENANT-A'), + ('np-b', 'user-b', 'TENANT-B'); + `); + await db.$executeRawUnsafe(` + INSERT INTO "TenderRssFeedSource" (id, url, "userId", "tenantId") VALUES + ('rss-a', 'https://a.example-tenders.invalid/feed', 'user-a1', 'TENANT-A'), + ('rss-b', 'https://b.example-tenders.invalid/feed', 'user-b', 'TENANT-B'), + ('rss-platform', 'https://platform.example-tenders.invalid/feed', NULL, NULL); + `); + await db.$executeRawUnsafe(` + INSERT INTO "TenderSavedSearch" (id, "userId", "tenantId", name) VALUES + ('ss-a1', 'user-a1', 'TENANT-A', 'Profil A1'), + ('ss-a2', 'user-a2', 'TENANT-A', 'Profil A2'), + ('ss-b', 'user-b', 'TENANT-B', 'Profil B'); + `); + await db.$executeRawUnsafe(` + INSERT INTO "TenderTriage" (id, "userId", "tenantId", "tenderId") VALUES + ('tr-a', 'user-a1', 'TENANT-A', 'tender-x'), + ('tr-b', 'user-b', 'TENANT-B', 'tender-y'), + ('tr-shared', 'user-shared', 'TENANT-B', 'tender-shared'); + `); + }); + + const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); + try { + // tendersavedsearch-gebunden-nur-eigener-mandant + const savedSearchRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw`SELECT "tenantId", "userId" FROM "TenderSavedSearch" ORDER BY id`, + ); + report( + results, + 'tendersavedsearch-gebunden-nur-eigener-mandant', + savedSearchRowsForA.length === 2 && + savedSearchRowsForA.every((r) => r.tenantId === 'TENANT-A'), + `forTenant(TENANT-A) liefert ${savedSearchRowsForA.length} Zeile(n): ${JSON.stringify(savedSearchRowsForA.map((r) => r.tenantId))}`, + ); + + // tendersavedsearch-ungebunden-null-zeilen — die Belegzeile, die diesen + // Abschnitt der Kritikschrift traegt, am echten, ausgelieferten + // Policy-Text gemessen. + const unboundSavedSearchRows = await prisma.$queryRaw`SELECT "tenantId" FROM "TenderSavedSearch"`; + report( + results, + 'tendersavedsearch-ungebunden-null-zeilen', + unboundSavedSearchRows.length === 0, + `ungebundener SELECT auf "TenderSavedSearch" liefert ${unboundSavedSearchRows.length} Zeile(n)`, + ); + + // tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar (Befund + // E): das GELINGEN — beide Nutzer von TENANT-A sind sichtbar — ist das + // bestandene Ergebnis. Es belegt, dass die Policy keine + // Benutzerdimension hat; die anwendungsseitige userId-Filterung bleibt + // deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern + // desselben Mandanten und darf bei der Umstellung nicht entfallen. + const usersVisibleForA = new Set(savedSearchRowsForA.map((r) => r.userId)); + const bothUsersVisible = usersVisibleForA.has('user-a1') && usersVisibleForA.has('user-a2'); + report( + results, + 'tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar', + bothUsersVisible, + bothUsersVisible + ? `forTenant(TENANT-A) liefert AUCH die Zeile des zweiten Nutzers (user-a2) — die Policy auf TenderSavedSearch prueft nur die Mandantenkennung, nicht die Benutzerkennung; die anwendungsseitige userId-Filterung bleibt der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben Mandanten und darf nicht entfallen` + : `sichtbare Nutzer unter forTenant(TENANT-A): ${JSON.stringify([...usersVisibleForA])}`, + ); + + // tenderemailconfig-gebunden-nur-eigener-mandant + const emailConfigRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw`SELECT "tenantId" FROM "TenderEmailConfig" ORDER BY id`, + ); + report( + results, + 'tenderemailconfig-gebunden-nur-eigener-mandant', + emailConfigRowsForA.length === 1 && emailConfigRowsForA[0].tenantId === 'TENANT-A', + `forTenant(TENANT-A) liefert ${emailConfigRowsForA.length} Zeile(n): ${JSON.stringify(emailConfigRowsForA.map((r) => r.tenantId))}`, + ); + + // tendertriage-gebunden-nur-eigener-mandant + const triageRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw`SELECT "tenantId" FROM "TenderTriage" ORDER BY id`, + ); + report( + results, + 'tendertriage-gebunden-nur-eigener-mandant', + triageRowsForA.length === 1 && triageRowsForA[0].tenantId === 'TENANT-A', + `forTenant(TENANT-A) liefert ${triageRowsForA.length} Zeile(n): ${JSON.stringify(triageRowsForA.map((r) => r.tenantId))}`, + ); + + // tendernotificationpref-gebunden-nur-eigener-mandant + const notificationPrefRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw`SELECT "tenantId" FROM "TenderNotificationPref" ORDER BY id`, + ); + report( + results, + 'tendernotificationpref-gebunden-nur-eigener-mandant', + notificationPrefRowsForA.length === 1 && notificationPrefRowsForA[0].tenantId === 'TENANT-A', + `forTenant(TENANT-A) liefert ${notificationPrefRowsForA.length} Zeile(n): ${JSON.stringify(notificationPrefRowsForA.map((r) => r.tenantId))}`, + ); + + // tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar + // (WINDOWS #19): die plattformweite Zeile (userId/tenantId NULL) ist + // unter BEIDEN Mandantenkontexten unsichtbar, weil die ausgelieferte + // Policy "tenantId" = current_tenant_id() NULL nie gleich vergleicht. + // Bestanden, wenn sie unter beiden Kontexten fehlt — die Folge: die + // drei RSS-Pfade, die plattformweite Zeilen beruehren, duerfen nicht + // gebunden werden, solange die Policy-Semantik unveraendert ist. + const rssRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw`SELECT id FROM "TenderRssFeedSource" ORDER BY id`, + ); + const rssRowsForB = await forTenantQuery(prisma, 'TENANT-B', (tx) => + tx.$queryRaw`SELECT id FROM "TenderRssFeedSource" ORDER BY id`, + ); + const platformRowVisibleForA = rssRowsForA.some((r) => r.id === 'rss-platform'); + const platformRowVisibleForB = rssRowsForB.some((r) => r.id === 'rss-platform'); + report( + results, + 'tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar', + !platformRowVisibleForA && !platformRowVisibleForB, + `forTenant(TENANT-A) sieht die Platform-Zeile: ${platformRowVisibleForA}, forTenant(TENANT-B) sieht sie: ${platformRowVisibleForB} — WINDOWS #19: eine plattformweite RSS-Quelle ist unter JEDEM Mandantenkontext unsichtbar; listForUser/createPlatform/remove duerfen deshalb nicht gebunden werden, solange die Policy-Semantik unveraendert ist`, + ); + + // tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt: ein + // gebundenes INSERT unter TENANT-A mit tenantId=NULL wird abgewiesen — + // die Abweisung IST das bestandene Ergebnis. Folge: createPlatform darf + // nicht gebunden werden. + let platformInsertRejected = false; + let platformInsertDetail = ''; + try { + await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => + tx.$executeRaw`INSERT INTO "TenderRssFeedSource" (id, url, "userId", "tenantId") VALUES ('rss-rejected', 'https://rejected.example-tenders.invalid/feed', NULL, NULL)`, + ); + platformInsertDetail = 'gebundenes INSERT mit tenantId=NULL ist NICHT fehlgeschlagen'; + } catch (err) { + platformInsertRejected = true; + platformInsertDetail = `gebundenes INSERT mit tenantId=NULL abgewiesen: ${err.message} — createPlatform darf deshalb nicht gebunden werden`; + } + report( + results, + 'tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt', + platformInsertRejected, + platformInsertDetail, + ); + + // tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit + // (Befund F): unter TENANT-A ein INSERT fuer ein Paar (userId, + // tenderId), dessen Zeile existiert, aber zu TENANT-B gehoert und daher + // unsichtbar ist (tr-shared). Bestanden, wenn der Fehler eine + // Verletzung der Eindeutigkeitsbedingung ist (P2002-Familie), nicht + // eine RLS-Policy-Abweisung — Postgres prueft Unique-Indizes gegen die + // physischen Zeilen, unabhaengig von der RLS-Sichtbarkeit. Folge fuer + // Aufgabe 2: aus einem stillen Ueberschreiben wird ein harter Fehler, + // der als verstaendliche Meldung herauskommen muss. + let uniqueViolationOnInvisibleRow = false; + let uniqueViolationDetail = ''; + try { + await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => + tx.$executeRaw`INSERT INTO "TenderTriage" (id, "userId", "tenantId", "tenderId") VALUES ('tr-a-attempt', 'user-shared', 'TENANT-A', 'tender-shared')`, + ); + uniqueViolationDetail = 'gebundenes INSERT auf die unsichtbare (userId,tenderId)-Kombination ist NICHT fehlgeschlagen'; + } catch (err) { + uniqueViolationOnInvisibleRow = err.code === '23505' || /unique/i.test(err.message); + uniqueViolationDetail = `gebundenes INSERT auf die unsichtbare (userId,tenderId)-Kombination scheitert an der Eindeutigkeitsbedingung, nicht an der Policy: ${err.message} — Befund F: aus einem stillen Ueberschreiben wird bei Aufgabe 2 ein harter, verstaendlich uebersetzter Fehler`; + } + report( + results, + 'tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit', + uniqueViolationOnInvisibleRow, + uniqueViolationDetail, + ); + } finally { + await prisma.$disconnect(); + } +} + /** * Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen * den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte @@ -1040,6 +1349,7 @@ async function main() { await runAuthLookupChecks(adminUrl, scratchRoleUrlString, results); await runLdapAreaChecks(adminUrl, scratchRoleUrlString, results); await runGroupsAreaChecks(adminUrl, scratchRoleUrlString, results); + await runTendersAreaChecks(adminUrl, scratchRoleUrlString, results); await runTransactionShapeMeasurement(scratchRoleUrlString, results); await runConcurrencyProbe(scratchRoleUrlString, results); } finally { diff --git a/docs/mandantentrennung-etappe2-fehlerrichtung.md b/docs/mandantentrennung-etappe2-fehlerrichtung.md index bef5677..1cfe082 100644 --- a/docs/mandantentrennung-etappe2-fehlerrichtung.md +++ b/docs/mandantentrennung-etappe2-fehlerrichtung.md @@ -375,6 +375,223 @@ geschlossen. Der Vermerk selbst steht am Ende von Abschnitt (e), gesetzt in Aufgabe 3 dieses Plans, weil die Schließung erst zu diesem Zeitpunkt tatsächlich vorliegt. +## Bereich tenders + +Dieser Abschnitt erweitert die Kritikschrift um den Bereich `tenders` +(Quick-Task 260909-laa) und beschreibt ihn zum Zeitpunkt seiner Umstellung. +Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt +beantwortet sie erneut, aber für einen Bereich, der überwiegend NICHT +umgestellt wird: von 23 Paaren sind fünf umzustellen, zehn bleiben bewusst +der plattformweite Ausschreibungskatalog (D-03), zwei sind bewusste +Fan-outs, und sechs zerfallen in eine übergreifende Hälfte (Etappe 3) und +eine mandantengebundene Hälfte (hier). Dieser Bereich trägt außerdem eine +Fehlerform, die `ldap` und `groups` nicht hatten: zwei Benachrichtigungswege, +die bei zu kleinem Leseergebnis nicht falsch handeln, sondern GAR NICHT. + +### (t1) Die Messung + +Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen fünften +Abschnitt (`runTendersAreaChecks`) erweitert, mit den fünf Policies für +`TenderEmailConfig`, `TenderNotificationPref`, `TenderRssFeedSource`, +`TenderSavedSearch` und `TenderTriage` (alle aus der ausgelieferten +Migration `20260909140000_rls_remaining_tenant_tables`) WORTGLEICH +extrahiert, nicht im Werkzeug nachgetippt. Tatsächlich beobachtete Ausgabe +dieses Laufs (2026-09-09, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`): + +``` +tendersavedsearch-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["TENANT-A","TENANT-A"] +tendersavedsearch-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "TenderSavedSearch" liefert 0 Zeile(n) +tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar: bestanden — forTenant(TENANT-A) liefert AUCH die Zeile des zweiten Nutzers (user-a2) — die Policy auf TenderSavedSearch prueft nur die Mandantenkennung, nicht die Benutzerkennung; die anwendungsseitige userId-Filterung bleibt der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben Mandanten und darf nicht entfallen +tenderemailconfig-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"] +tendertriage-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"] +tendernotificationpref-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"] +tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar: bestanden — forTenant(TENANT-A) sieht die Platform-Zeile: false, forTenant(TENANT-B) sieht sie: false — WINDOWS #19: eine plattformweite RSS-Quelle ist unter JEDEM Mandantenkontext unsichtbar; listForUser/createPlatform/remove duerfen deshalb nicht gebunden werden, solange die Policy-Semantik unveraendert ist +tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt: bestanden — gebundenes INSERT mit tenantId=NULL abgewiesen: ERROR: new row violates row-level security policy for table "TenderRssFeedSource" — createPlatform darf deshalb nicht gebunden werden +tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit: bestanden — gebundenes INSERT auf die unsichtbare (userId,tenderId)-Kombination scheitert an der Eindeutigkeitsbedingung, nicht an der Policy: Code 23505, Unique constraint failed — Befund F: aus einem stillen Ueberschreiben wird bei Aufgabe 2 ein harter, verstaendlich uebersetzter Fehler +Alle 32 Pruefungen bestanden. +``` + +Die Belegzeile, die diesen Abschnitt trägt, ist +`tendersavedsearch-ungebunden-null-zeilen`: der IDENTISCHE +`SELECT "tenantId" FROM "TenderSavedSearch"` ohne vorheriges `set_config` +liefert **0 Zeilen**, nicht etwa die 3 tatsächlich vorhandenen — an der +echten, ausgelieferten Policy gemessen, nicht an einer im Werkzeug +nachgebauten Hilfstabelle. + +Zwei Messungen dieses Laufs tragen eine Entscheidung, die kein bisheriger +Bereich brauchte: + +- `tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar` — das + GELINGEN (beide Nutzer von TENANT-A sind sichtbar) IST das bestandene + Ergebnis. Alle fünf Policies dieses Bereichs lauten schlicht + `"tenantId" = current_tenant_id()`, ohne Benutzerdimension — zwei Nutzer + DESSELBEN Mandanten sind füreinander vollständig sichtbar. Die + anwendungsseitige `userId`-Filterung, die alle fünf umzustellenden + Dienste bereits führen, bleibt deshalb der einzige Schutz gegen + Quer-Lesen zwischen Nutzern und wird bei der Umstellung NICHT entfernt. +- `tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar` — die + plattformweite RSS-Zeile (`userId`/`tenantId` beide `NULL`, wie der + geseedete `service.bund.de`-Feed) ist unter TENANT-A UND TENANT-B + gebunden unsichtbar, weil `NULL = current_tenant_id()` in SQL nie wahr + ist. Das ist WINDOWS #19, hier nicht als ferne Sorge, sondern als der + Grund, warum `listForUser`, `createPlatform` und `remove` in + `tender-rss-feed.service.ts` NICHT gebunden werden — eine Bindung würde + die plattformweite Quelle für JEDEN Mandanten verschwinden lassen. + `tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt` bestätigt die + Kehrseite: ein gebundenes `INSERT` mit `tenantId = NULL` wird von der + ausgelieferten Policy abgewiesen — `createPlatform` würde in genau diese + Abweisung laufen, würde man es binden. + +Eine dritte Messung trägt die Fehlerbehandlung von Aufgabe 2: +`tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit` zeigt, +dass ein gebundenes `INSERT` auf ein `(userId, tenderId)`-Paar, dessen Zeile +existiert, aber einem anderen Mandanten gehört und deshalb unsichtbar ist, +an der Eindeutigkeitsbedingung scheitert (Postgres prüft Unique-Indizes +gegen die physischen Zeilen, unabhängig von der RLS-Sichtbarkeit) — NICHT +an einer Policy-Abweisung. Aus einem stillen Überschreiben wird dadurch ein +harter Fehler, der in Aufgabe 2 als verständliche deutsche Meldung +herauskommen muss, nicht als roher 500er. + +TEIL 2 dieser Aufgabe hat zusätzlich nachgemessen, dass dieser Bereich +keine mandantengebundene Transaktion enthält (Befund A): +`grep -rn '\$transaction(' apps/api/src/tenders --include=*.ts | grep -v spec` +liefert genau EINEN Treffer, `tender-fingerprint-backfill.service.ts:89`, +die Array-Form auf der plattformweiten Tabelle `Tender` (D-03), außerhalb +jeder Mandantenbindung. Der im Kopf von `prisma-tenant.extension.ts` +verlangte erneute Test vor jedem neuen `forTenant()`-Fall mit eigener +Transaktion ist damit für diesen Bereich beantwortet: es fällt kein neuer +Fall an, `withTenantTransaction()` wird hier nicht gebraucht und auch +nicht eingeführt. + +### (t2) Signaltabelle je umgestelltem Pfad + +| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal | +|---|---|---| +| `TenderSavedSearchService.list` | Liefert 0 gespeicherte Suchprofile statt der tatsächlich vorhandenen | Die Suchprofil-Leiste im Ausschreibungs-Radar ist leer für einen Nutzer, der tatsächlich Profile gespeichert hat | +| `TenderSavedSearchService.create/update/remove` | Die Besitzprüfung (`findUnique` gebunden) liefert 0 Zeilen statt der eigenen Zeile | `PATCH`/`DELETE /saved-searches/:searchId` scheitert mit 404, obwohl das Profil existiert; `POST` legt scheinbar erfolgreich ein neues Profil an (unkritisch, betrifft nur den Lesepfad danach) | +| `TenderTriageService.listForUser` | Die Markierungsabfrage liefert 0 Treffer statt der tatsächlich vorhandenen | Die Trefferliste zeigt für bereits gelesene/favorisierte Ausschreibungen keine Markierung mehr — ein bereits gelesener Treffer erscheint wieder als ungelesen | +| `TenderTriageService.favoriteIds` | Liefert 0 favorisierte tenderIds statt der tatsächlich vorhandenen | Der Merklisten-Filter (`favOnly`) zeigt eine leere Liste für einen Nutzer, der tatsächlich Favoriten hat | +| `TenderNotificationPrefService.getForUser` | **Sonderfall**: kein Treffer bedeutet hier nicht "leer", sondern der Vorgabewert `daily` (D-01) | Ein Nutzer, der `off` gewählt hat, sieht in der Oberfläche wieder `daily` — ein zu kleines Leseergebnis setzt die Einstellung stillschweigend auf täglich zurück, statt sie leer zu lassen | +| `TenderEmailConfigService.getConfigForApi` | Der gebundene `findUnique` liefert 0 Zeilen statt der eigenen Konfiguration | `GET /email-config` liefert `null`; die Oberfläche zeigt "kein Postfach verbunden" für einen Nutzer, der tatsächlich eines hat | +| `TenderEmailConfigService.testConnection` | Der gebundene Rückgriff auf gespeicherte Zugangsdaten liefert 0 Zeilen statt der eigenen | Ein Verbindungstest mit leer gelassenem Formular (Rückgriff auf gespeicherte Zugangsdaten) schlägt fehl, obwohl gespeicherte Zugangsdaten existieren | +| `TenderRssFeedSourceService.listForUser`/`createPlatform`/`remove` (bewusst UNGEBUNDEN, WINDOWS #19) | Betrifft nicht diese drei Pfade selbst — sie binden nicht und liefern deshalb weiterhin die plattformweite Zeile korrekt. Das Risiko liegt in einer KÜNFTIGEN Bindung (Etappe 3), nicht in dieser Umstellung | Würde man sie binden: leere Feed-Liste bzw. 404 beim Entfernen einer plattformweiten Quelle — hier ausdrücklich als Grenze festgehalten, nicht als heute beobachtbares Verhalten | +| `TenderRssFeedSourceService.createForUser` (gebunden) | Der Zähler (`count`, gebunden) liefert 0 statt der tatsächlichen Anzahl eigener Feeds | Ein Nutzer, der bereits am Limit von 20 eigenen Feeds ist, könnte scheinbar unbegrenzt neue anlegen (harmlose Richtung: die Kappung aus T-17-10 wirkt nicht mehr, kein Datenverlust) | + +### (t3) Welcher Code Leere als Abwesenheit deutet + +**Sichtbare Formen** (Anzeige bleibt leer oder fällt auf einen Vorgabewert +zurück, jemand merkt es beim nächsten Blick auf die Oberfläche): siehe +Tabelle (t2) oben — Suchprofilliste, Triage-Markierung, Favoriten-Filter, +der `daily`-Sonderfall der Benachrichtigungseinstellung, und die +Postfach-Anzeige. + +**Lautlose Formen — die zusätzliche Fehlerform dieses Bereichs.** Fünf +Stellen in den beiden Hintergrunddiensten deuten ein zu kleines +Leseergebnis nicht als Fehler, sondern als "nichts zu tun", und +protokollieren dabei NICHTS: + +1. **`tender-digest.scheduler.ts`, `if (!candidates.length) return;`** — + liefert die gebundene Kandidatenabfrage innerhalb eines Profildurchlaufs + zu wenig, bricht der GESAMTE Digest für diesen Lauf ab, für ALLE + Mandanten, ohne Protokolleintrag. +2. **`tender-digest.scheduler.ts`, `if (!matches.length) continue;`** — + liefert die gebundene Treffer-Abfrage für einen Kandidaten zu wenig, + bekommt dieser eine Nutzer keine Post, der Lauf macht mit dem nächsten + Kandidaten weiter, ohne Protokolleintrag. +3. **`tender-digest.scheduler.ts`, `if (!user || !user.email) continue;`** + — liefert die gebundene Benutzer-Abfrage zu wenig, bekommt dieser + Nutzer keine Post, ohne Protokolleintrag. +4. **`tender-matching.service.ts`, `if (!fresh.length) continue;`** — + liefert die gebundene Abfrage der noch nicht benachrichtigten Treffer + innerhalb eines Profildurchlaufs zu wenig, bekommt dieser Nutzer keinen + Sofort-Alarm, ohne Protokolleintrag. +5. **`tender-matching.service.ts`, `if (!user || !user.email) continue;`** + — dieselbe Form wie Stelle 3, für den Sofort-Alarm-Pfad. + +Keine dieser fünf Stellen protokolliert etwas — eine ausbleibende Warnung +erzeugt keine Fehlermeldung, keinen Protokolleintrag und keine Beschwerde, +außer der stillen Abwesenheit einer E-Mail, die niemand erwartet, weil +niemand wusste, dass sie hätte kommen sollen. + +Entlastung in dieselbe Richtung, ebenfalls nachgesehen statt geschlossen +behauptet: weil `notifiedAt` auf `TenderMatch` nur nach ERFOLGREICHEM +Versand gestempelt wird (D-06), bleiben die betroffenen Zeilen auf +`notifiedAt IS NULL` stehen und werden beim nächsten Lauf erneut versucht. +Es geht also nichts verloren — es kommt nur nichts an, solange die Ursache +(z. B. ein nach dem Scharfschalten ungebunden gebliebener Lesepfad) +fortbesteht. Daraus ergibt sich das einzige nachprüfbare Signal dieser +Fehlerform: eine wachsende Zahl von `TenderMatch`-Zeilen mit +`notifiedAt IS NULL` bei gleichzeitig fehlendem Versandprotokoll. Dieses +Signal gehört in die Vorabprüfung von Etappe 4 (`rls-preflight.mjs`), NICHT +in diesen Durchlauf — Aufgabe 3 hält es hier fest, löst es aber nicht. + +Eine Laufzeitwarnung an den fünf Stellen wurde erwogen und VERWORFEN, aus +demselben Grund wie bei `getAllActiveConfigs` im `ldap`-Durchlauf: beide +Hintergrunddienste laufen regelmäßig, und "kein passender Kandidat"/"kein +Konto mit Adresse" ist ein regulärer Zustand, kein Fehlerfall — eine +Warnung wäre Dauerlärm und verlöre ihr Signal. + +### (t4) Was dieser Durchlauf bewusst nicht löst + +- **WINDOWS #19 — nullbares `tenantId` bei `TenderRssFeedSource`.** + Gemessen in (t1): eine plattformweite Zeile ist unter JEDEM + Mandantenkontext unsichtbar, ein gebundenes Einfügen ohne Mandant wird + abgewiesen. `listForUser`, `createPlatform` und `remove` bleiben deshalb + bewusst UNGEBUNDEN, mit einem Codekommentar, der die Grenze benennt. Die + Policy-Semantik selbst (eine `tenantId IS NULL OR tenantId = + current_tenant_id()`-Lesevariante) gehört zu Etappe 3 und wird hier nicht + angefasst. +- **Die übergreifenden Hälften der beiden Hintergrunddienste.** Aufgabe 3 + bindet nur die Je-Treffer-Hälften von `tender-digest.scheduler.ts` und + `tender-matching.service.ts`; die Kandidatenabfrage + (`tenderMatch.findMany`/`tenderSavedSearch.findMany`) bleibt bewusst + über alle Mandanten hinweg ungebunden und ist als Etappe-3-Übergabe + kommentiert — siehe `docs/mandantentrennung-zugriffsklassifikation.md`, + Abschnitt "Der Hintergrunddienst als Falle". +- **Befund K — die Abhängigkeit vom noch nicht umgestellten Bereich + `settings`.** `tender-mail.service.ts` holt die SMTP-Angaben über + `SettingsService.getDecryptedSmtpConfig(tenantId)`; `settings.service.ts` + ist noch vollständig unbound (4 Rohtreffer, siehe Übersichtstabelle in + `docs/mandantentrennung-zugriffsklassifikation.md`). Nach dem + Scharfschalten fände diese ungebundene Abfrage keine SMTP-Zeile mehr — + Ergebnis: kein Versand für niemanden, mit Wiederholung bei jedem Lauf + (dieselbe Entlastung wie in (t3)). Das ist eine Reihenfolgebedingung für + Etappe 4, genau wie Befund D des `ldap`-Durchlaufs es für `groups` war — + hier festgehalten, nicht gelöst. +- **Die offene Architekturfrage `req.tenantPrisma`.** Auch der Bereich + `tenders` entscheidet sie nicht — er bindet dienst-intern, wie `ldap` + und `groups` es vormachen. +- **Befund G — ein Administrator eines beliebigen Mandanten kann eine + plattformweite RSS-Quelle entfernen.** `TenderRssFeedSourceService.remove` + ist bereits heute ein einziger bedingter `deleteMany` mit der + Besitzbedingung in der Datenbank — das ist das gewünschte Muster, keine + Wiederholung des `ldap`-Fundes (Auflösung über die Kennung allein). Die + eine Beobachtung, die trotzdem festgehalten gehört: ein Administrator + EINES beliebigen Mandanten kann über diesen Pfad eine plattformweite + Quelle entfernen, die ALLE Mandanten speist — eine Produkt-/ + Zuständigkeitsfrage im Umfeld von WINDOWS #19, kein Auftrag dieser + Aufgabe. + +### (t5) Was dieser Durchlauf bewusst nicht anfasst + +Die zwölf Paare des plattformweiten Ausschreibungskatalogs (D-03, +`tender-dedup.service.ts`, `tender-fingerprint-backfill.service.ts`, +`tender-ingestion.service.ts`, `tender-matching.service.ts`/`tender`, +`tender-scheduler.service.ts`, `tenders.controller.ts`, `tenders.module.ts`) +und der beiden bewussten Fan-out-Adapter +(`adapters/email-alert.adapter.ts`, `adapters/rss.adapter.ts`) sind +geprüft und deliberat ungebunden — nicht übersehen. Jede der zwölf trägt +ihre Begründung im eigenen Dateikopf bzw. in D-03. + +Befund I gehört ausdrücklich hierher: `tender-ingestion.service.spec.ts` +enthält eine Schutzprüfung, die den QUELLTEXT von +`tender-ingestion.service.ts` liest und gegen ein Vorkommen des +Bezeichners `forTenant` prüft ("never calls forTenant()"). In dieser einen +Datei darf deshalb auch kein ERKLÄRENDER Kommentar diesen Bezeichner +nennen — die Datei selbst steht ohnehin auf der Nicht-Anfassen-Liste, hier +nur festgehalten, damit niemand sie beim Nachziehen der Begründungen +"freundlich kommentiert" und den Lauf rot macht. + ## Verweis Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang