From 07fc653f52d5be95b77cb30a34334e2d94726a41 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 11 Sep 2026 17:39:17 +0200 Subject: [PATCH] feat(quick-260911-nke): Benutzer an 34 Aufrufstellen gesetzt, zehn Tabellen gemessen, sechs Pruefungen umgedreht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 30 verbleibende forTenant()-Aufrufstellen in sieben Diensten (calendar 6, dashboard 9, favorites 5, tender-email-config 3, tender-notification-pref 2, tender-rss-feed 2, tender-triage 3) reichen userId als drittes Argument durch. tender-digest.scheduler.ts bleibt zweistellig (Hintergrunddienst, Etappe 3c), mit Begruendung im Kommentar. Keine Methodensignatur, kein Controller angefasst, keine anwendungsseitige userId-Filterung entfernt. - rls-scratch-check.mjs: zwoelf Extraktionsstellen auf die neue Migration umgeleitet (TenderEmailConfig/TenderNotificationPref/TenderSavedSearch/ TenderTriage/TenderRssFeedSource in runTendersAreaChecks, SearchProvider in runSearchProviderAreaChecks/runDashboardAreaChecks, DashboardLayout/ WidgetInstance, CalendarSource/FavoriteLink samt regelstand-eindeutig-Gates). SearchProvider/TenderRssFeedSource jetzt mit extractAllPolicySql (4 Regeln). runUserDimensionChecks() um die uebrigen neun Tabellen erweitert (neue Routine runCommandSeparatedPersonalTableCheck fuer die zwei NULL-faehigen Tabellen inkl. gemeinsame-Zeile-Pruefungen). - Sechs Loch-Pruefungen umgedreht (dashboardlayout, widgetinstance, searchprovider, calendarsource, favoritelink-Doppelaussage getrennt) — alte Messung ohne Benutzer bleibt unter neuem Namen, Umkehrung MIT Benutzer erwartet das Gegenteil; kein alter Name mehr als Kennung. - Baseline: 1020/62 Tests weiterhin gruen, Typpruefung sauber, Werkzeug 203/203 bestanden (vorher 146). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR --- apps/api/scripts/rls-scratch-check.mjs | 728 ++++++++++++++++-- .../api/src/calendar/calendar.service.spec.ts | 2 + apps/api/src/calendar/calendar.service.ts | 30 +- .../src/dashboard/dashboard.service.spec.ts | 2 + apps/api/src/dashboard/dashboard.service.ts | 37 +- .../src/favorites/favorites.service.spec.ts | 2 + apps/api/src/favorites/favorites.service.ts | 24 +- .../src/tenders/tender-digest.scheduler.ts | 8 + .../tender-email-config.service.spec.ts | 3 + .../tenders/tender-email-config.service.ts | 12 +- .../tender-notification-pref.service.spec.ts | 3 + .../tender-notification-pref.service.ts | 10 +- .../tenders/tender-rss-feed.service.spec.ts | 3 +- .../src/tenders/tender-rss-feed.service.ts | 14 +- .../src/tenders/tender-triage.service.spec.ts | 3 + apps/api/src/tenders/tender-triage.service.ts | 12 +- 16 files changed, 785 insertions(+), 108 deletions(-) diff --git a/apps/api/scripts/rls-scratch-check.mjs b/apps/api/scripts/rls-scratch-check.mjs index 3830c38..3c45e37 100644 --- a/apps/api/scripts/rls-scratch-check.mjs +++ b/apps/api/scripts/rls-scratch-check.mjs @@ -1056,28 +1056,28 @@ async function runGroupsAreaChecks(adminUrl, scratchRoleUrl, results) { * Abschnitt aendert daran nichts. */ async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) { - const remainingMigrationSql = readRemainingTenantTablesMigrationSql(); - const widenMigrationSql = readRlsWidenMigrationSql(); const userDimensionMigrationSql = readRlsUserDimensionMigrationSql(); - const emailConfigPolicy = remainingMigrationSql - ? extractPolicySql(remainingMigrationSql, 'TenderEmailConfig') + // Extraktionsstellen 975/978/981/987 (260911-nke, Aufgabe 2), 984 (Aufgabe + // 1): alle fuenf Tabellen dieses Bereichs werden ab der Benutzerdimension- + // Migration gelesen, nicht mehr aus *_rls_remaining_tenant_tables oder + // *_rls_widen_membership_grant_and_platform_read — sonst misst dieser + // Abschnitt die abgeloeste Regel ohne Benutzerdimension (Befund D, + // Praezedenz jab). + const emailConfigPolicy = userDimensionMigrationSql + ? extractPolicySql(userDimensionMigrationSql, 'TenderEmailConfig') : null; - const notificationPrefPolicy = remainingMigrationSql - ? extractPolicySql(remainingMigrationSql, 'TenderNotificationPref') + const notificationPrefPolicy = userDimensionMigrationSql + ? extractPolicySql(userDimensionMigrationSql, 'TenderNotificationPref') : null; - const rssFeedPolicies = widenMigrationSql - ? extractAllPolicySql(widenMigrationSql, 'TenderRssFeedSource') + const rssFeedPolicies = userDimensionMigrationSql + ? extractAllPolicySql(userDimensionMigrationSql, 'TenderRssFeedSource') : []; - // Extraktionsstelle 984 (260911-nke, Aufgabe 1): TenderSavedSearch wird - // ab der Benutzerdimension-Migration gelesen, nicht mehr aus - // *_rls_remaining_tenant_tables — sonst misst dieser Abschnitt die - // abgeloeste Regel ohne Benutzerdimension (Befund D, Praezedenz jab). const savedSearchPolicy = userDimensionMigrationSql ? extractPolicySql(userDimensionMigrationSql, 'TenderSavedSearch') : null; - const triagePolicy = remainingMigrationSql - ? extractPolicySql(remainingMigrationSql, 'TenderTriage') + const triagePolicy = userDimensionMigrationSql + ? extractPolicySql(userDimensionMigrationSql, 'TenderTriage') : null; if ( @@ -1091,7 +1091,7 @@ async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) { results, 'tenders-policies-aus-migration-gefunden', false, - `CREATE POLICY fuer "TenderEmailConfig", "TenderNotificationPref", "TenderSavedSearch" und/oder "TenderTriage" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden, oder nicht genau vier Policies fuer "TenderRssFeedSource" in *_rls_widen_membership_grant_and_platform_read (gefunden: ${rssFeedPolicies.length})`, + `CREATE POLICY fuer "TenderEmailConfig", "TenderNotificationPref", "TenderSavedSearch" und/oder "TenderTriage" nicht in der Benutzerdimension-Migration (20260911120000) gefunden, oder nicht genau vier Policies fuer "TenderRssFeedSource" (gefunden: ${rssFeedPolicies.length})`, ); return; } @@ -1489,17 +1489,23 @@ async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) { * Aufrufkette ein Blatt. */ async function runSearchProviderAreaChecks(adminUrl, scratchRoleUrl, results) { - const remainingMigrationSql = readRemainingTenantTablesMigrationSql(); - const searchProviderPolicy = remainingMigrationSql - ? extractPolicySql(remainingMigrationSql, 'SearchProvider') - : null; + // Extraktionsstelle 1386 (260911-nke, Aufgabe 2): die urspruengliche + // Ein-Regel-Fassung aus *_rls_remaining_tenant_tables ist seit + // 20260911120000 abgeloest — SearchProvider traegt jetzt vier + // befehlsgetrennte Regeln (Benutzerdimension). extractAllPolicySql mit + // erwarteter Laenge 4, sonst misst dieser Abschnitt die abgeloeste Regel + // (Befund D, Praezedenz jab). + const userDimensionMigrationSql = readRlsUserDimensionMigrationSql(); + const searchProviderPolicies = userDimensionMigrationSql + ? extractAllPolicySql(userDimensionMigrationSql, 'SearchProvider') + : []; - if (!searchProviderPolicy) { + if (searchProviderPolicies.length !== 4) { report( results, 'searchprovider-policy-aus-migration-gefunden', false, - 'CREATE POLICY fuer "SearchProvider" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden', + `nicht genau vier CREATE-POLICY-Anweisungen fuer "SearchProvider" in der Benutzerdimension-Migration (20260911120000) gefunden (gefunden: ${searchProviderPolicies.length})`, ); return; } @@ -1515,7 +1521,9 @@ async function runSearchProviderAreaChecks(adminUrl, scratchRoleUrl, results) { `); await db.$executeRawUnsafe(`ALTER TABLE "SearchProvider" ENABLE ROW LEVEL SECURITY;`); await db.$executeRawUnsafe(`ALTER TABLE "SearchProvider" FORCE ROW LEVEL SECURITY;`); - await db.$executeRawUnsafe(searchProviderPolicy); + for (const policySql of searchProviderPolicies) { + await db.$executeRawUnsafe(policySql); + } await db.$executeRawUnsafe( `GRANT SELECT, INSERT, UPDATE, DELETE ON "SearchProvider" TO ${SCRATCH_ROLE_NAME}`, ); @@ -2542,24 +2550,29 @@ async function runModuleRegistryAreaChecks(adminUrl, scratchRoleUrl, results) { * sie nicht stattfinden kann. */ async function runDashboardAreaChecks(adminUrl, scratchRoleUrl, results) { - const remainingMigrationSql = readRemainingTenantTablesMigrationSql(); + // Extraktionsstellen 2430/2433 (260911-nke, Aufgabe 2): DashboardLayout + // und WidgetInstance werden ab der Benutzerdimension-Migration gelesen. + // Extraktionsstelle 2436: die SearchProvider-Praesenzpruefung hier ist nur + // ein Wortlaut-Beleg (die Tabelle selbst legt runSearchProviderAreaChecks + // an) — seit 20260911120000 vier befehlsgetrennte Regeln statt einer. + const userDimensionMigrationSql = readRlsUserDimensionMigrationSql(); - const dashboardLayoutPolicy = remainingMigrationSql - ? extractPolicySql(remainingMigrationSql, 'DashboardLayout') + const dashboardLayoutPolicy = userDimensionMigrationSql + ? extractPolicySql(userDimensionMigrationSql, 'DashboardLayout') : null; - const widgetInstancePolicy = remainingMigrationSql - ? extractPolicySql(remainingMigrationSql, 'WidgetInstance') - : null; - const searchProviderPolicy = remainingMigrationSql - ? extractPolicySql(remainingMigrationSql, 'SearchProvider') + const widgetInstancePolicy = userDimensionMigrationSql + ? extractPolicySql(userDimensionMigrationSql, 'WidgetInstance') : null; + const searchProviderPolicies = userDimensionMigrationSql + ? extractAllPolicySql(userDimensionMigrationSql, 'SearchProvider') + : []; - if (!dashboardLayoutPolicy || !widgetInstancePolicy || !searchProviderPolicy) { + if (!dashboardLayoutPolicy || !widgetInstancePolicy || searchProviderPolicies.length !== 4) { report( results, 'dashboard-policies-aus-migration-gefunden', false, - 'CREATE POLICY fuer "DashboardLayout", "WidgetInstance" und/oder "SearchProvider" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden', + `CREATE POLICY fuer "DashboardLayout"/"WidgetInstance" und/oder die vier Regeln fuer "SearchProvider" nicht in der Benutzerdimension-Migration (20260911120000) gefunden (SearchProvider-Regeln gefunden: ${searchProviderPolicies.length})`, ); return; } @@ -2648,12 +2661,31 @@ async function runDashboardAreaChecks(adminUrl, scratchRoleUrl, results) { layoutRowsForA.length === 2 && layoutRowsForA.every((r) => r.tenantId === 'TENANT-A'), `forTenant(TENANT-A) liefert ${layoutRowsForA.length} Zeile(n): ${JSON.stringify(layoutRowsForA.map((r) => r.id))}`, ); + // Umgedreht (260911-nke, Aufgabe 2): die alte Pruefung + // "dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar" + // (Befund G) mass OHNE Benutzer — nach der Migration 20260911120000 ist + // das GELINGEN weiterhin der Fall, jetzt als GEWOLLTE Eigenschaft der + // IS-NULL-Form. Die alte Messung bleibt unter neuem Namen bestehen; die + // Umkehrung misst MIT Benutzer und erwartet das GEGENTEIL. const secondUserVisible = layoutRowsForA.some((r) => r.userId === 'user-a2'); report( results, - 'dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar', + 'dashboardlayout-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten', secondUserVisible, - `forTenant(TENANT-A) liefert die Anordnung von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: ${secondUserVisible} — die Regel auf "DashboardLayout" kennt keine Benutzerdimension, die anwendungsseitige Pruefung ueber die Benutzerkennung bleibt deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten`, + `bis 260911-nke als Befund G unter dem Namen dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar gefuehrt — jetzt die gewollte Eigenschaft der IS-NULL-Form fuer Admin und Hintergrunddienst. forTenant(TENANT-A) OHNE Benutzer liefert die Anordnung von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: ${secondUserVisible}`, + ); + const layoutRowsForA1 = await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => tx.$queryRaw`SELECT id, "userId", "tenantId" FROM "DashboardLayout" ORDER BY id`, + 'user-a1', + ); + const secondUserVisibleGebunden = layoutRowsForA1.some((r) => r.userId === 'user-a2'); + report( + results, + 'dashboardlayout-benutzer-a-sieht-kollegen-nicht-gebunden', + !secondUserVisibleGebunden, + `forTenant(TENANT-A, user-a1) liefert die Anordnung von 'user-a2' mit: ${secondUserVisibleGebunden}`, ); // 2: dashboardlayout-ungebunden-null-zeilen — die Belegzeile dieses @@ -2685,12 +2717,28 @@ async function runDashboardAreaChecks(adminUrl, scratchRoleUrl, results) { widgetRowsForA.length === 2 && widgetRowsForA.every((r) => r.tenantId === 'TENANT-A'), `forTenant(TENANT-A) liefert ${widgetRowsForA.length} Zeile(n): ${JSON.stringify(widgetRowsForA.map((r) => r.id))}`, ); + // Umgedreht (260911-nke, Aufgabe 2): alte Pruefung + // "widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar" + // (Befund G) mass OHNE Benutzer; jetzt gewollte Eigenschaft. const widgetSecondUserVisible = widgetRowsForA.some((r) => r.userId === 'user-a2'); report( results, - 'widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar', + 'widgetinstance-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten', widgetSecondUserVisible, - `forTenant(TENANT-A) liefert das Widget von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: ${widgetSecondUserVisible} — dieselbe fehlende Benutzerdimension wie bei "DashboardLayout" und "SearchProvider" (Befund G), der dritte der drei Faelle dieses Bereichs`, + `bis 260911-nke als Befund G unter dem Namen widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar gefuehrt — jetzt die gewollte Eigenschaft der IS-NULL-Form. forTenant(TENANT-A) OHNE Benutzer liefert das Widget von 'user-a2' mit: ${widgetSecondUserVisible}`, + ); + const widgetRowsForA1 = await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => tx.$queryRaw`SELECT id, "userId", "tenantId" FROM "WidgetInstance" ORDER BY id`, + 'user-a1', + ); + const widgetSecondUserVisibleGebunden = widgetRowsForA1.some((r) => r.userId === 'user-a2'); + report( + results, + 'widgetinstance-benutzer-a-sieht-kollegen-nicht-gebunden', + !widgetSecondUserVisibleGebunden, + `forTenant(TENANT-A, user-a1) liefert das Widget von 'user-a2' mit: ${widgetSecondUserVisibleGebunden}`, ); // 5: dashboardlayout-gebundener-konfliktschreibvorgang-auf-unsichtbare- @@ -2842,14 +2890,33 @@ async function runDashboardAreaChecks(adminUrl, scratchRoleUrl, results) { searchProviderRowsForA.every((r) => r.tenantId === 'TENANT-A'), `forTenant(TENANT-A) liefert ${searchProviderRowsForA.length} Zeile(n) aus den neu hinzugefuegten: ${JSON.stringify(searchProviderRowsForA.map((r) => r.id))}`, ); + // Umgedreht (260911-nke, Aufgabe 2): alte Pruefung + // "searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar" + // (Befund G) mass OHNE Benutzer; jetzt gewollte Eigenschaft. const searchProviderSecondUserVisible = searchProviderRowsForA.some( (r) => r.userId === 'user-a2', ); report( results, - 'searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar', + 'searchprovider-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten', searchProviderSecondUserVisible, - `forTenant(TENANT-A) liefert die Suchmaschine von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: ${searchProviderSecondUserVisible} — dieselbe fehlende Benutzerdimension wie bei "DashboardLayout" und "WidgetInstance" (Befund G)`, + `bis 260911-nke als Befund G unter dem Namen searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar gefuehrt — jetzt die gewollte Eigenschaft der IS-NULL-Form. forTenant(TENANT-A) OHNE Benutzer liefert die Suchmaschine von 'user-a2' mit: ${searchProviderSecondUserVisible}`, + ); + const searchProviderRowsForA1 = await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => + tx.$queryRaw`SELECT id, "userId", "tenantId" FROM "SearchProvider" WHERE id IN ('search-a1', 'search-a2', 'search-b1') ORDER BY id`, + 'user-a1', + ); + const searchProviderSecondUserVisibleGebunden = searchProviderRowsForA1.some( + (r) => r.userId === 'user-a2', + ); + report( + results, + 'searchprovider-benutzer-a-sieht-kollegen-nicht-gebunden', + !searchProviderSecondUserVisibleGebunden, + `forTenant(TENANT-A, user-a1) liefert die Suchmaschine von 'user-a2' mit: ${searchProviderSecondUserVisibleGebunden}`, ); // 12: searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt — die @@ -2975,21 +3042,29 @@ async function runCalendarAreaChecks(adminUrl, scratchRoleUrl, results) { const widenMigrationSql = readRlsWidenMigrationSql(); const widenHasOwnCalendarSourcePolicy = widenMigrationSql && Boolean(extractPolicySql(widenMigrationSql, 'CalendarSource')); + // 260911-nke, Aufgabe 2: die Benutzerdimension-Migration hat GENAU EINE + // eigene Regel fuer "CalendarSource" (DROP + CREATE unter demselben Namen) + // — widen hat weiterhin KEINE. Beide Bedingungen zusammen entscheiden, + // welche Migration den aktuellen Regelstand liefert. + const userDimensionMigrationSql = readRlsUserDimensionMigrationSql(); + const userDimensionHasOwnCalendarSourcePolicy = + userDimensionMigrationSql && Boolean(extractPolicySql(userDimensionMigrationSql, 'CalendarSource')); report( results, 'calendarsource-regelstand-eindeutig', - !widenHasOwnCalendarSourcePolicy, + !widenHasOwnCalendarSourcePolicy && userDimensionHasOwnCalendarSourcePolicy, widenHasOwnCalendarSourcePolicy - ? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt eine EIGENE Regel fuer "CalendarSource" — der Regelstand ist nicht mehr eindeutig auf 20260909140000_rls_remaining_tenant_tables zurueckzufuehren, Messung abgebrochen statt die abgeloeste Regel weiterzumessen' - : 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "CalendarSource" (Befund G) — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand', + ? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt eine EIGENE Regel fuer "CalendarSource" — der Regelstand ist nicht mehr eindeutig zurueckzufuehren, Messung abgebrochen statt die abgeloeste Regel weiterzumessen' + : userDimensionHasOwnCalendarSourcePolicy + ? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "CalendarSource", die *_rls_user_dimension_personal_tables-Migration (20260911120000, 260911-nke) enthaelt GENAU EINE — das ist der aktuelle Regelstand, aus dieser Migration gelesen (Wechsel von 20260909140000_rls_remaining_tenant_tables)' + : 'weder die widen- noch die Benutzerdimension-Migration enthaelt eine eigene Regel fuer "CalendarSource" — Messung abgebrochen statt eine aeltere Migration zu raten', ); - if (widenHasOwnCalendarSourcePolicy) { + if (widenHasOwnCalendarSourcePolicy || !userDimensionHasOwnCalendarSourcePolicy) { return; } - const remainingMigrationSql = readRemainingTenantTablesMigrationSql(); - const calendarSourcePolicy = remainingMigrationSql - ? extractPolicySql(remainingMigrationSql, 'CalendarSource') + const calendarSourcePolicy = userDimensionMigrationSql + ? extractPolicySql(userDimensionMigrationSql, 'CalendarSource') : null; if (!calendarSourcePolicy) { @@ -2997,7 +3072,7 @@ async function runCalendarAreaChecks(adminUrl, scratchRoleUrl, results) { results, 'calendarsource-policy-aus-migration-gefunden', false, - 'CREATE POLICY fuer "CalendarSource" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden', + 'CREATE POLICY fuer "CalendarSource" nicht in der Benutzerdimension-Migration (20260911120000) gefunden', ); return; } @@ -3090,13 +3165,33 @@ async function runCalendarAreaChecks(adminUrl, scratchRoleUrl, results) { rowsForA.length === 2 && rowsForA.every((r) => r.tenantId === 'TENANT-A'), `forTenant(TENANT-A) liefert ${rowsForA.length} Zeile(n): ${JSON.stringify(rowsForA.map((r) => r.id))}`, ); + // Umgedreht (260911-nke, Aufgabe 2): alte Pruefung + // "calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar" + // (Befund G) mass OHNE Benutzer; jetzt gewollte Eigenschaft. Der + // Meldetext nennt weiterhin, dass encryptedPassword eines Kollegen + // gebunden MIT Benutzer jetzt NICHT mehr lesbar ist (siehe Umkehrung + // unten). const a2Row = rowsForA.find((r) => r.userId === 'user-a2'); const a2CredentialsVisible = Boolean(a2Row && a2Row.encryptedPassword); report( results, - 'calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar', + 'calendarsource-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten', a2CredentialsVisible, - `forTenant(TENANT-A) liefert die Quelle von 'user-a2' (anderer Benutzer, gleicher Mandant) mit encryptedPassword=${JSON.stringify(a2Row?.encryptedPassword)} — die Regel auf "CalendarSource" kennt keine Benutzerdimension, die verschluesselten Zugangsdaten eines Kollegen DESSELBEN Mandanten sind auf Datenbankebene lesbar; die anwendungsseitige Filterung ueber die Benutzerkennung bleibt deshalb der einzige Schutz, bis die Etappe-3-Entscheidung (2) die Benutzerdimension nachzieht`, + `bis 260911-nke als Befund G unter dem Namen calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar gefuehrt — jetzt die gewollte Eigenschaft der IS-NULL-Form. forTenant(TENANT-A) OHNE Benutzer liefert die Quelle von 'user-a2' mit encryptedPassword=${JSON.stringify(a2Row?.encryptedPassword)}; MIT Benutzer (siehe calendarsource-benutzer-a-sieht-kollegen-nicht-gebunden) ist dieselbe Zeile NICHT mehr lesbar`, + ); + const rowsForA1 = await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => + tx.$queryRaw`SELECT id, "userId", "tenantId", "encryptedPassword" FROM "CalendarSource" ORDER BY id`, + 'user-a1', + ); + const a2RowGebunden = rowsForA1.find((r) => r.userId === 'user-a2'); + report( + results, + 'calendarsource-benutzer-a-sieht-kollegen-nicht-gebunden', + !a2RowGebunden, + `forTenant(TENANT-A, user-a1) liefert die Quelle von 'user-a2' mit: ${JSON.stringify(a2RowGebunden)} — encryptedPassword des Kollegen ist damit auf Datenbankebene nicht mehr lesbar`, ); // 2: calendarsource-ungebunden-null-zeilen — die tragende Belegzeile. @@ -3917,21 +4012,27 @@ async function runFavoritesAreaChecks(adminUrl, scratchRoleUrl, results) { const widenMigrationSql = readRlsWidenMigrationSql(); const widenHasOwnFavoriteLinkPolicy = widenMigrationSql && Boolean(extractPolicySql(widenMigrationSql, 'FavoriteLink')); + // 260911-nke, Aufgabe 2: die Benutzerdimension-Migration hat GENAU EINE + // eigene Regel fuer "FavoriteLink" — widen hat weiterhin KEINE. + const userDimensionMigrationSql = readRlsUserDimensionMigrationSql(); + const userDimensionHasOwnFavoriteLinkPolicy = + userDimensionMigrationSql && Boolean(extractPolicySql(userDimensionMigrationSql, 'FavoriteLink')); report( results, 'favoritelink-regelstand-eindeutig', - !widenHasOwnFavoriteLinkPolicy, + !widenHasOwnFavoriteLinkPolicy && userDimensionHasOwnFavoriteLinkPolicy, widenHasOwnFavoriteLinkPolicy - ? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt eine EIGENE Regel fuer "FavoriteLink" — der Regelstand ist nicht mehr eindeutig auf 20260909140000_rls_remaining_tenant_tables zurueckzufuehren, Messung abgebrochen statt die abgeloeste Regel weiterzumessen' - : 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "FavoriteLink" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand', + ? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt eine EIGENE Regel fuer "FavoriteLink" — der Regelstand ist nicht mehr eindeutig zurueckzufuehren, Messung abgebrochen statt die abgeloeste Regel weiterzumessen' + : userDimensionHasOwnFavoriteLinkPolicy + ? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "FavoriteLink", die *_rls_user_dimension_personal_tables-Migration (20260911120000, 260911-nke) enthaelt GENAU EINE — das ist der aktuelle Regelstand, aus dieser Migration gelesen (Wechsel von 20260909140000_rls_remaining_tenant_tables)' + : 'weder die widen- noch die Benutzerdimension-Migration enthaelt eine eigene Regel fuer "FavoriteLink" — Messung abgebrochen statt eine aeltere Migration zu raten', ); - if (widenHasOwnFavoriteLinkPolicy) { + if (widenHasOwnFavoriteLinkPolicy || !userDimensionHasOwnFavoriteLinkPolicy) { return; } - const remainingMigrationSql = readRemainingTenantTablesMigrationSql(); - const favoriteLinkPolicy = remainingMigrationSql - ? extractPolicySql(remainingMigrationSql, 'FavoriteLink') + const favoriteLinkPolicy = userDimensionMigrationSql + ? extractPolicySql(userDimensionMigrationSql, 'FavoriteLink') : null; if (!favoriteLinkPolicy) { @@ -3939,7 +4040,7 @@ async function runFavoritesAreaChecks(adminUrl, scratchRoleUrl, results) { results, 'favoritelink-policy-aus-migration-gefunden', false, - 'CREATE POLICY fuer "FavoriteLink" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden', + 'CREATE POLICY fuer "FavoriteLink" nicht in der Benutzerdimension-Migration (20260911120000) gefunden', ); return; } @@ -4040,9 +4141,11 @@ async function runFavoritesAreaChecks(adminUrl, scratchRoleUrl, results) { ); // 4: favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen - // — zwei Aussagen in einer Messung: die eigenen Zeilen kommen, UND die - // Regel kennt keine Benutzerdimension (die Zeile des Kollegen ist ueber - // ein gebundenes findMany auf DESSEN widgetId ebenfalls sichtbar). + // — 260911-nke, Aufgabe 2: die Doppelaussage aus 260911-gwh ist + // GETRENNT. Diese Pruefung behaelt NUR die erste Haelfte (die eigenen + // Zeilen kommen). Die zweite Haelfte (Kollege sichtbar) wird zu den + // zwei neuen Pruefungen unten: die alte Messung OHNE Benutzer bleibt + // unter neuem Namen bestehen, die Umkehrung misst MIT Benutzer. const bound = buildInlineExtendedClient(prisma, 'TENANT-A'); const boundOwnList = await bound.favoriteLink.findMany({ where: { userId: 'user-a1', widgetId: 'widget-a1' }, @@ -4051,14 +4154,32 @@ async function runFavoritesAreaChecks(adminUrl, scratchRoleUrl, results) { const ownListOk = boundOwnList.length === 2 && boundOwnList.every((r) => r.userId === 'user-a1' && r.widgetId === 'widget-a1'); + report( + results, + 'favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen', + ownListOk, + `bound.favoriteLink.findMany unter TENANT-A liefert fuer (userId='user-a1', widgetId='widget-a1') ${boundOwnList.length} Zeile(n): ${JSON.stringify(boundOwnList.map((r) => r.id))}`, + ); + const boundColleagueList = await bound.favoriteLink.findMany({ where: { widgetId: 'widget-a2' } }); const colleagueVisible = boundColleagueList.length === 1 && boundColleagueList[0].userId === 'user-a2'; report( results, - 'favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen', - ownListOk && colleagueVisible, - `bound.favoriteLink.findMany unter TENANT-A liefert fuer (userId='user-a1', widgetId='widget-a1') ${boundOwnList.length} Zeile(n): ${JSON.stringify(boundOwnList.map((r) => r.id))} — die Zeile von user-a2 fehlt (anwendungsseitige Benutzerfilterung); ein gebundenes findMany({ where: { widgetId: 'widget-a2' } }) unter DEMSELBEN Mandanten liefert dagegen ${boundColleagueList.length} Zeile(n) des Kollegen user-a2 (${JSON.stringify(boundColleagueList.map((r) => r.id))}) — die Regel auf "FavoriteLink" kennt keine Benutzerdimension (dieselbe Lehre wie calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar), die anwendungsseitige userId-Filterung bleibt deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten (Etappe-3-Entscheidung (2))`, + 'favoritelink-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten', + colleagueVisible, + `bis 260911-nke Teil der Doppelaussage in favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen (260911-gwh, Pruefung 4) — jetzt die gewollte Eigenschaft der IS-NULL-Form. bound(TENANT-A, ohne Benutzer).favoriteLink.findMany({ where: { widgetId: 'widget-a2' } }) liefert ${boundColleagueList.length} Zeile(n) des Kollegen user-a2: ${JSON.stringify(boundColleagueList.map((r) => r.id))}`, + ); + + const boundA1 = buildInlineExtendedClient(prisma, 'TENANT-A', 'user-a1'); + const boundColleagueListGebunden = await boundA1.favoriteLink.findMany({ + where: { widgetId: 'widget-a2' }, + }); + report( + results, + 'favoritelink-benutzer-a-sieht-kollegen-nicht-gebunden', + boundColleagueListGebunden.length === 0, + `bound(TENANT-A, user-a1).favoriteLink.findMany({ where: { widgetId: 'widget-a2' } }) liefert ${boundColleagueListGebunden.length} Zeile(n): ${JSON.stringify(boundColleagueListGebunden.map((r) => r.id))}`, ); // 5: favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null @@ -4605,6 +4726,174 @@ async function runSingleRulePersonalTableCheck(config) { } } +/** + * Wie `runSingleRulePersonalTableCheck`, aber fuer die ZWEI Tabellen mit + * NULL-faehiger `userId` und vier befehlsgetrennten Regeln (SearchProvider, + * TenderRssFeedSource, Etappe 3b, 260911-nke). Zusaetzlich zu den vier + * Standard-Wahrheiten: eine gemeinsame Zeile (`userId IS NULL`) bleibt fuer + * einen benutzergebundenen Aufruf LESBAR, aber NICHT entfernbar — weil ein + * einzelner USING-Ausdruck, der die gemeinsame Zeile zum Lesen einschliesst, + * sie auch zum Aendern/Entfernen freigaebe (jab-Praezedenz). Eine + * Gegenmessung ohne Benutzer zeigt, ob die gemeinsame Zeile dann entfernbar + * ist — bei TenderRssFeedSource NICHT (die plattformweite Zeile hat KEINEN + * Mandanten, die Schreibregel verlangt aber einen — WINDOWS #24), bei + * SearchProvider SCHON (die gemeinsame Zeile ist mandantengebunden). + */ +async function runCommandSeparatedPersonalTableCheck(config) { + const { + adminUrl, + scratchRoleUrl, + results, + slug, + tableName, + modelName, + userDimensionMigrationSql, + createTableSql, + seedSql, + ownRowId, + colleagueRowId, + sharedRowId, + sharedRowRemainsRemovableWithoutUser = true, + createAttempt, + } = config; + + const policies = extractAllPolicySql(userDimensionMigrationSql, tableName); + if (policies.length !== 4) { + report( + results, + `${slug}-policy-aus-migration-gefunden`, + false, + `nicht genau vier CREATE-POLICY-Anweisungen fuer "${tableName}" in der Benutzerdimension-Migration (20260911120000) gefunden (gefunden: ${policies.length})`, + ); + return; + } + + await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => { + await db.$executeRawUnsafe(`DROP TABLE IF EXISTS "${tableName}" CASCADE;`); + await db.$executeRawUnsafe(createTableSql); + await db.$executeRawUnsafe(`ALTER TABLE "${tableName}" ENABLE ROW LEVEL SECURITY;`); + await db.$executeRawUnsafe(`ALTER TABLE "${tableName}" FORCE ROW LEVEL SECURITY;`); + for (const policySql of policies) { + await db.$executeRawUnsafe(policySql); + } + await db.$executeRawUnsafe( + `GRANT SELECT, INSERT, UPDATE, DELETE ON "${tableName}" TO ${SCRATCH_ROLE_NAME}`, + ); + await db.$executeRawUnsafe(seedSql); + }); + + const schemaFields = readSchemaModelScalarFieldNames(modelName); + const tableColumns = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => { + const rows = await db.$queryRawUnsafe( + `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = '${tableName}'`, + ); + return rows.map((r) => r.column_name).sort(); + }, + ); + const schemaFieldsSorted = [...schemaFields].sort(); + const columnsMatch = + schemaFieldsSorted.length > 0 && + schemaFieldsSorted.length === tableColumns.length && + schemaFieldsSorted.every((f, i) => f === tableColumns[i]); + report( + results, + `${slug}-wegwerftabelle-deckt-alle-spalten-des-generierten-clients`, + columnsMatch, + `Schema-Felder aus schema.prisma (model ${modelName}, skalare Felder ohne Relation, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}`, + ); + if (!columnsMatch) { + return; + } + + const modelAccessor = modelName.charAt(0).toLowerCase() + modelName.slice(1); + const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); + try { + const boundA1 = buildInlineExtendedClient(prisma, 'TENANT-A', 'user-a1'); + + const ownRow = await boundA1[modelAccessor].findUnique({ where: { id: ownRowId } }); + report( + results, + `${slug}-benutzer-a-sieht-eigene-zeile`, + Boolean(ownRow) && ownRow.id === ownRowId, + `bound(TENANT-A, user-a1).${modelAccessor}.findUnique({ id: '${ownRowId}' }) liefert ${JSON.stringify(ownRow)}`, + ); + + const colleagueRow = await boundA1[modelAccessor].findUnique({ where: { id: colleagueRowId } }); + report( + results, + `${slug}-benutzer-a-sieht-kollegen-nicht`, + colleagueRow === null, + `bound(TENANT-A, user-a1).${modelAccessor}.findUnique({ id: '${colleagueRowId}' }) (gehoert user-a2) liefert ${JSON.stringify(colleagueRow)}`, + ); + + const boundNoUser = buildInlineExtendedClient(prisma, 'TENANT-A'); + const bothRows = await boundNoUser[modelAccessor].findMany({ + where: { id: { in: [ownRowId, colleagueRowId] } }, + }); + report( + results, + `${slug}-ohne-benutzer-sieht-beide`, + bothRows.length === 2, + `bound(TENANT-A, ohne Benutzer).${modelAccessor}.findMany liefert ${bothRows.length} Zeile(n): ${JSON.stringify(bothRows.map((r) => r.id))} — gewollte Eigenschaft der IS-NULL-Form fuer Admin/Hintergrunddienst`, + ); + + let writeRejected = false; + let writeDetail = ''; + try { + await boundA1[modelAccessor].create({ + data: { + id: createAttempt.id, + userId: 'user-a2', + tenantId: 'TENANT-A', + ...createAttempt.extraData, + }, + }); + writeDetail = `bound(TENANT-A, user-a1).${modelAccessor}.create mit userId='user-a2' ist NICHT fehlgeschlagen`; + } catch (err) { + const sqlState = sqlStateOf(err); + const ctor = err?.constructor?.name ?? 'unbekannt'; + writeRejected = sqlState === '42501'; + writeDetail = `bound(TENANT-A, user-a1).${modelAccessor}.create mit userId='user-a2' wirft ${ctor}, SQLSTATE ${sqlState ?? 'unbekannt'}: ${(err.message ?? '').toString().trim()}`; + } + report(results, `${slug}-schreiben-als-a-mit-kennung-b-abgelehnt`, writeRejected, writeDetail); + + // Gemeinsame Zeile (userId IS NULL): lesbar fuer einen benutzergebundenen + // Aufruf, aber nicht entfernbar (vier befehlsgetrennte Regeln). + const sharedRowVisible = await boundA1[modelAccessor].findUnique({ where: { id: sharedRowId } }); + report( + results, + `${slug}-gemeinsame-zeile-fuer-benutzer-a-lesbar`, + Boolean(sharedRowVisible) && sharedRowVisible.id === sharedRowId, + `bound(TENANT-A, user-a1).${modelAccessor}.findUnique({ id: '${sharedRowId}' }) (gemeinsame Zeile, userId IS NULL) liefert ${JSON.stringify(sharedRowVisible)}`, + ); + + const deleteAsUserResult = await boundA1[modelAccessor].deleteMany({ where: { id: sharedRowId } }); + report( + results, + `${slug}-gemeinsame-zeile-als-benutzer-a-nicht-entfernbar`, + deleteAsUserResult.count === 0, + `bound(TENANT-A, user-a1).${modelAccessor}.deleteMany({ id: '${sharedRowId}' }) liefert count=${deleteAsUserResult.count} — eine Regel ohne Befehlstrennung wuerde hier 1 liefern`, + ); + + const deleteWithoutUserResult = await boundNoUser[modelAccessor].deleteMany({ + where: { id: sharedRowId }, + }); + const expectedCount = sharedRowRemainsRemovableWithoutUser ? 1 : 0; + report( + results, + `${slug}-gemeinsame-zeile-ohne-benutzer-weiterhin-entfernbar`, + deleteWithoutUserResult.count === expectedCount, + sharedRowRemainsRemovableWithoutUser + ? `bound(TENANT-A, ohne Benutzer).${modelAccessor}.deleteMany({ id: '${sharedRowId}' }) liefert count=${deleteWithoutUserResult.count} — die gemeinsame Zeile bleibt fuer Admin/Hintergrunddienst entfernbar` + : `bound(TENANT-A, ohne Benutzer).${modelAccessor}.deleteMany({ id: '${sharedRowId}' }) liefert count=${deleteWithoutUserResult.count} — WINDOWS #24: die plattformweite Zeile hat keinen Mandanten, die Schreibregel verlangt aber einen; das gilt VOR wie NACH dieser Migration unveraendert und ist kein neu entdecktes Loch`, + ); + } finally { + await prisma.$disconnect(); + } +} + /** * Etappe 3b (260911-nke) — misst die Benutzerdimension der zehn * persoenlichen Tabellen ueber den GENERIERTEN Client. Aufgabe 1: nur @@ -4654,6 +4943,321 @@ async function runUserDimensionChecks(adminUrl, scratchRoleUrl, results) { colleagueRowId: 'ss-a2', createAttempt: { id: 'ss-a-schreibversuch', extraData: { name: 'Schreibversuch', filters: {} } }, }); + + // Aufgabe 2 (260911-nke): die uebrigen sieben Ein-Regel-Tabellen ueber + // dieselbe innere Routine. + await runSingleRulePersonalTableCheck({ + adminUrl, + scratchRoleUrl, + results, + slug: 'calendarsource', + tableName: 'CalendarSource', + modelName: 'CalendarSource', + userDimensionMigrationSql, + createTableSql: ` + CREATE TABLE "CalendarSource" ( + id text PRIMARY KEY, + "userId" text NOT NULL, + "tenantId" text NOT NULL, + name text NOT NULL, + type text NOT NULL, + "exchangeMode" text, + domain text, + url text NOT NULL, + username text, + "encryptedPassword" text, + color text DEFAULT '#3B82F6', + "isVisible" boolean NOT NULL DEFAULT true, + "syncIntervalMin" integer NOT NULL DEFAULT 15, + "lastSyncAt" timestamp(3), + "lastSyncError" text, + "createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `, + seedSql: ` + INSERT INTO "CalendarSource" (id, "userId", "tenantId", name, type, url) VALUES + ('cal-a1', 'user-a1', 'TENANT-A', 'Kalender A1', 'ics', 'https://a1.example.invalid/cal.ics'), + ('cal-a2', 'user-a2', 'TENANT-A', 'Kalender A2', 'ics', 'https://a2.example.invalid/cal.ics'), + ('cal-b', 'user-b', 'TENANT-B', 'Kalender B', 'ics', 'https://b.example.invalid/cal.ics'); + `, + ownRowId: 'cal-a1', + colleagueRowId: 'cal-a2', + createAttempt: { + id: 'cal-a-schreibversuch', + extraData: { name: 'Schreibversuch', type: 'ics', url: 'https://x.example.invalid/cal.ics' }, + }, + }); + + await runSingleRulePersonalTableCheck({ + adminUrl, + scratchRoleUrl, + results, + slug: 'dashboardlayout', + tableName: 'DashboardLayout', + modelName: 'DashboardLayout', + userDimensionMigrationSql, + createTableSql: ` + CREATE TABLE "DashboardLayout" ( + id text PRIMARY KEY, + "userId" text NOT NULL UNIQUE, + "tenantId" text NOT NULL, + layouts jsonb NOT NULL DEFAULT '{}', + "createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `, + seedSql: ` + INSERT INTO "DashboardLayout" (id, "userId", "tenantId", layouts) VALUES + ('layout-a1', 'user-a1', 'TENANT-A', '{}'), + ('layout-a2', 'user-a2', 'TENANT-A', '{}'), + ('layout-b', 'user-b', 'TENANT-B', '{}'); + `, + ownRowId: 'layout-a1', + colleagueRowId: 'layout-a2', + createAttempt: { id: 'layout-a-schreibversuch', extraData: { layouts: {} } }, + }); + + await runSingleRulePersonalTableCheck({ + adminUrl, + scratchRoleUrl, + results, + slug: 'widgetinstance', + tableName: 'WidgetInstance', + modelName: 'WidgetInstance', + userDimensionMigrationSql, + createTableSql: ` + CREATE TABLE "WidgetInstance" ( + id text PRIMARY KEY, + "userId" text NOT NULL, + "tenantId" text NOT NULL, + "widgetType" text NOT NULL, + config jsonb NOT NULL DEFAULT '{}', + "createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `, + seedSql: ` + INSERT INTO "WidgetInstance" (id, "userId", "tenantId", "widgetType") VALUES + ('widget-a1', 'user-a1', 'TENANT-A', 'clock'), + ('widget-a2', 'user-a2', 'TENANT-A', 'clock'), + ('widget-b', 'user-b', 'TENANT-B', 'clock'); + `, + ownRowId: 'widget-a1', + colleagueRowId: 'widget-a2', + createAttempt: { id: 'widget-a-schreibversuch', extraData: { widgetType: 'clock' } }, + }); + + await runSingleRulePersonalTableCheck({ + adminUrl, + scratchRoleUrl, + results, + slug: 'favoritelink', + tableName: 'FavoriteLink', + modelName: 'FavoriteLink', + userDimensionMigrationSql, + createTableSql: ` + CREATE TABLE "FavoriteLink" ( + id text PRIMARY KEY, + "userId" text NOT NULL, + "tenantId" text NOT NULL, + "widgetId" text NOT NULL, + title text NOT NULL, + url text NOT NULL, + "iconUrl" text, + position integer NOT NULL DEFAULT 0, + "createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `, + seedSql: ` + INSERT INTO "FavoriteLink" (id, "userId", "tenantId", "widgetId", title, url) VALUES + ('fav-a1', 'user-a1', 'TENANT-A', 'widget-a1', 'Favorit A1', 'https://a1.example.invalid'), + ('fav-a2', 'user-a2', 'TENANT-A', 'widget-a2', 'Favorit A2', 'https://a2.example.invalid'), + ('fav-b', 'user-b', 'TENANT-B', 'widget-b', 'Favorit B', 'https://b.example.invalid'); + `, + ownRowId: 'fav-a1', + colleagueRowId: 'fav-a2', + createAttempt: { + id: 'fav-a-schreibversuch', + extraData: { widgetId: 'widget-a1', title: 'Schreibversuch', url: 'https://x.example.invalid' }, + }, + }); + + await runSingleRulePersonalTableCheck({ + adminUrl, + scratchRoleUrl, + results, + slug: 'tenderemailconfig', + tableName: 'TenderEmailConfig', + modelName: 'TenderEmailConfig', + userDimensionMigrationSql, + createTableSql: ` + CREATE TABLE "TenderEmailConfig" ( + id text PRIMARY KEY, + "userId" text NOT NULL UNIQUE, + "tenantId" text NOT NULL, + protocol text NOT NULL DEFAULT 'imap', + host text, + port integer, + encryption text NOT NULL DEFAULT 'ssl-tls', + folder text NOT NULL DEFAULT 'INBOX', + "senderFilter" text, + domain text, + "isActive" boolean NOT NULL DEFAULT false, + "encryptedInboxCreds" text, + "createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `, + seedSql: ` + INSERT INTO "TenderEmailConfig" (id, "userId", "tenantId") VALUES + ('ec-a1', 'user-a1', 'TENANT-A'), + ('ec-a2', 'user-a2', 'TENANT-A'), + ('ec-b', 'user-b', 'TENANT-B'); + `, + ownRowId: 'ec-a1', + colleagueRowId: 'ec-a2', + createAttempt: { id: 'ec-a-schreibversuch', extraData: {} }, + }); + + await runSingleRulePersonalTableCheck({ + adminUrl, + scratchRoleUrl, + results, + slug: 'tendernotificationpref', + tableName: 'TenderNotificationPref', + modelName: 'TenderNotificationPref', + userDimensionMigrationSql, + createTableSql: ` + CREATE TABLE "TenderNotificationPref" ( + id text PRIMARY KEY, + "userId" text NOT NULL UNIQUE, + "tenantId" text NOT NULL, + "digestInterval" text NOT NULL DEFAULT 'daily', + "createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `, + seedSql: ` + INSERT INTO "TenderNotificationPref" (id, "userId", "tenantId") VALUES + ('np-a1', 'user-a1', 'TENANT-A'), + ('np-a2', 'user-a2', 'TENANT-A'), + ('np-b', 'user-b', 'TENANT-B'); + `, + ownRowId: 'np-a1', + colleagueRowId: 'np-a2', + createAttempt: { id: 'np-a-schreibversuch', extraData: {} }, + }); + + await runSingleRulePersonalTableCheck({ + adminUrl, + scratchRoleUrl, + results, + slug: 'tendertriage', + tableName: 'TenderTriage', + modelName: 'TenderTriage', + userDimensionMigrationSql, + createTableSql: ` + CREATE TABLE "TenderTriage" ( + id text PRIMARY KEY, + "userId" text NOT NULL, + "tenantId" text NOT NULL, + "tenderId" text NOT NULL, + "isRead" boolean NOT NULL DEFAULT false, + "isFavorite" boolean NOT NULL DEFAULT false, + "readAt" timestamp(3), + "favoritedAt" timestamp(3), + "createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE ("userId", "tenderId") + ); + `, + seedSql: ` + INSERT INTO "TenderTriage" (id, "userId", "tenantId", "tenderId") VALUES + ('tr-a1', 'user-a1', 'TENANT-A', 'tender-a1'), + ('tr-a2', 'user-a2', 'TENANT-A', 'tender-a2'), + ('tr-b', 'user-b', 'TENANT-B', 'tender-b'); + `, + ownRowId: 'tr-a1', + colleagueRowId: 'tr-a2', + createAttempt: { id: 'tr-a-schreibversuch', extraData: { tenderId: 'tender-a-schreibversuch' } }, + }); + + // Aufgabe 2 (260911-nke): die zwei befehlsgetrennten Tabellen — vier + // Regeln je Tabelle, zusaetzlich die gemeinsame-Zeile-Pruefungen. + await runCommandSeparatedPersonalTableCheck({ + adminUrl, + scratchRoleUrl, + results, + slug: 'searchprovider', + tableName: 'SearchProvider', + modelName: 'SearchProvider', + userDimensionMigrationSql, + createTableSql: ` + CREATE TABLE "SearchProvider" ( + id text PRIMARY KEY, + "userId" text, + "tenantId" text, + name text NOT NULL, + "urlTemplate" text NOT NULL, + "isDefault" boolean NOT NULL DEFAULT false, + "createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `, + seedSql: ` + INSERT INTO "SearchProvider" (id, "userId", "tenantId", name, "urlTemplate") VALUES + ('search-a1', 'user-a1', 'TENANT-A', 'Suche A1', 'https://a1.example.invalid/?q={query}'), + ('search-a2', 'user-a2', 'TENANT-A', 'Suche A2', 'https://a2.example.invalid/?q={query}'), + ('search-b', 'user-b', 'TENANT-B', 'Suche B', 'https://b.example.invalid/?q={query}'), + ('search-shared-a', NULL, 'TENANT-A', 'Gemeinsame Suche A', 'https://shared-a.example.invalid/?q={query}'); + `, + ownRowId: 'search-a1', + colleagueRowId: 'search-a2', + sharedRowId: 'search-shared-a', + createAttempt: { + id: 'search-a-schreibversuch', + extraData: { name: 'Schreibversuch', urlTemplate: 'https://x.example.invalid/?q={query}' }, + }, + }); + + await runCommandSeparatedPersonalTableCheck({ + adminUrl, + scratchRoleUrl, + results, + slug: 'tenderrssfeed', + tableName: 'TenderRssFeedSource', + modelName: 'TenderRssFeedSource', + userDimensionMigrationSql, + createTableSql: ` + CREATE TABLE "TenderRssFeedSource" ( + id text PRIMARY KEY, + url text NOT NULL, + label text NOT NULL, + "isActive" boolean NOT NULL DEFAULT true, + "userId" text, + "tenantId" text, + "createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE ("userId", url) + ); + `, + seedSql: ` + INSERT INTO "TenderRssFeedSource" (id, url, label, "userId", "tenantId") VALUES + ('rss-a1', 'https://a1.example-tenders.invalid/feed', 'Feed A1', 'user-a1', 'TENANT-A'), + ('rss-a2', 'https://a2.example-tenders.invalid/feed', 'Feed A2', 'user-a2', 'TENANT-A'), + ('rss-b', 'https://b.example-tenders.invalid/feed', 'Feed B', 'user-b', 'TENANT-B'), + ('rss-platform', 'https://platform.example-tenders.invalid/feed', 'Plattformweit', NULL, NULL); + `, + ownRowId: 'rss-a1', + colleagueRowId: 'rss-a2', + sharedRowId: 'rss-platform', + sharedRowRemainsRemovableWithoutUser: false, + createAttempt: { + id: 'rss-a-schreibversuch', + extraData: { url: 'https://x.example-tenders.invalid/feed', label: 'Schreibversuch' }, + }, + }); } /** diff --git a/apps/api/src/calendar/calendar.service.spec.ts b/apps/api/src/calendar/calendar.service.spec.ts index 059aa65..ea8af18 100644 --- a/apps/api/src/calendar/calendar.service.spec.ts +++ b/apps/api/src/calendar/calendar.service.spec.ts @@ -238,6 +238,8 @@ describe('CalendarService — Bindung an forTenant() (260911-cwh)', () => { expect(result).toHaveLength(1); expect(result[0].hasCredentials).toBe(true); expect((result[0] as any).encryptedPassword).toBeUndefined(); + // Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument. + expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'user-a1'); }); it('getSources von Nutzer A liefert NICHT die Quellen von Nutzer B desselben Mandanten — der userId-Filter bleibt, die Bindung ergaenzt ihn', async () => { diff --git a/apps/api/src/calendar/calendar.service.ts b/apps/api/src/calendar/calendar.service.ts index 9895514..43ff6c9 100644 --- a/apps/api/src/calendar/calendar.service.ts +++ b/apps/api/src/calendar/calendar.service.ts @@ -108,13 +108,19 @@ const CACHE_TTL_MS = 5 * 60 * 1000; * The three ownership checks (`updateSource`/`deleteSource`/ * `testConnection`, comparing `existing.userId` against the calling user) * are kept UNCHANGED alongside the binding, not replaced by it: the RLS - * policy on `CalendarSource` carries no user dimension (measured - * 260911-cwh, Aufgabe 1 — `calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`), + * policy on `CalendarSource` carried no user dimension when measured + * 260911-cwh, Aufgabe 1 (`calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`), * so a colleague of the SAME tenant would otherwise see and modify a - * fellow user's encrypted Exchange/CalDAV credentials. Until the RLS - * policy itself gains a user dimension (Etappe-3-Entscheidung (2)), these - * application-level checks remain the only protection between users of the - * same tenant. + * fellow user's encrypted Exchange/CalDAV credentials. + * + * Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 traegt die + * `tenant_isolation_policy` auf `CalendarSource` die Benutzerdimension + * (`current_user_id() IS NULL OR "userId" = current_user_id()`) — jeder + * `forTenant()`-Aufruf oben reicht `userId` als drittes Argument durch. Die + * drei anwendungsseitigen Besitzpruefungen bleiben trotzdem UNVERAENDERT + * bestehen: die Datenbankregel ist ein ZWEITES Netz, kein Ersatz dafuer, und + * ein Aufrufer, der `userId` vergisst, saehe ohne sie den ganzen Mandanten + * (siehe .planning/WINDOWS.md). * * Credentials encrypted at rest via CryptoService (T-05-10). */ @@ -155,7 +161,7 @@ export class CalendarService { * Adds a `hasCredentials` boolean so the UI knows if credentials are set. */ async getSources(userId: string, tenantId: string) { - const tenantPrisma = forTenant(this.prisma, tenantId); + const tenantPrisma = forTenant(this.prisma, tenantId, userId); const sources = await tenantPrisma.calendarSource.findMany({ where: { userId }, select: { @@ -195,7 +201,7 @@ export class CalendarService { data.encryptedPassword = this.crypto.encrypt(dto.password); } - const tenantPrisma = forTenant(this.prisma, tenantId); + const tenantPrisma = forTenant(this.prisma, tenantId, userId); const created = await tenantPrisma.calendarSource.create({ data: data as any, select: SOURCE_SAFE_SELECT, @@ -209,7 +215,7 @@ export class CalendarService { * Re-encrypts password if provided; T-05-12 ownership enforcement. */ async updateSource(id: string, userId: string, tenantId: string, dto: UpdateCalendarSourceDto) { - const tenantPrisma = forTenant(this.prisma, tenantId); + const tenantPrisma = forTenant(this.prisma, tenantId, userId); const existing = await tenantPrisma.calendarSource.findUnique({ where: { id }, select: { userId: true, type: true }, @@ -261,7 +267,7 @@ export class CalendarService { * Deletes a calendar source. Ownership check enforced (T-05-12). */ async deleteSource(id: string, userId: string, tenantId: string) { - const tenantPrisma = forTenant(this.prisma, tenantId); + const tenantPrisma = forTenant(this.prisma, tenantId, userId); const existing = await tenantPrisma.calendarSource.findUnique({ where: { id }, select: { userId: true }, @@ -283,7 +289,7 @@ export class CalendarService { * Updates lastSyncAt/lastSyncError on the source record. */ async testConnection(id: string, userId: string, tenantId: string): Promise<{ success: boolean; error?: string }> { - const tenantPrisma = forTenant(this.prisma, tenantId); + const tenantPrisma = forTenant(this.prisma, tenantId, userId); const source = await tenantPrisma.calendarSource.findUnique({ where: { id } }); if (!source) throw new NotFoundException('Calendar source not found'); if (source.userId !== userId) throw new ForbiddenException('Not your calendar source'); @@ -399,7 +405,7 @@ export class CalendarService { to: Date, cacheKey: string, ): Promise { - const tenantPrisma = forTenant(this.prisma, tenantId); + const tenantPrisma = forTenant(this.prisma, tenantId, userId); const sources = await tenantPrisma.calendarSource.findMany({ where: { userId, isVisible: true }, }); diff --git a/apps/api/src/dashboard/dashboard.service.spec.ts b/apps/api/src/dashboard/dashboard.service.spec.ts index 333dece..d391f94 100644 --- a/apps/api/src/dashboard/dashboard.service.spec.ts +++ b/apps/api/src/dashboard/dashboard.service.spec.ts @@ -388,6 +388,8 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26 expect(result).toEqual({ lg: [{ i: 'w1' }] }); expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique'); + // Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument. + expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-1', 'user-1'); }); it('getLayout: kein Widget/keine Anordnung vorhanden liefert die Vorgabeanordnung, keinen Fehler — heutiges Verhalten, damit eine spätere Änderung sichtbar wird', async () => { diff --git a/apps/api/src/dashboard/dashboard.service.ts b/apps/api/src/dashboard/dashboard.service.ts index 97e088e..9e813a6 100644 --- a/apps/api/src/dashboard/dashboard.service.ts +++ b/apps/api/src/dashboard/dashboard.service.ts @@ -58,12 +58,27 @@ const DEFAULT_SEARCH_PROVIDERS = [ * three ownership checks in this file (`updateWidgetConfig`, `removeWidget`, * `removeSearchProvider`) compare against the user id from the session proof * and are NOT decorative: the RLS rules on `DashboardLayout`, `WidgetInstance` - * and `SearchProvider` know only the tenant dimension, not the user dimension - * (measured 260910-krx, Aufgabe 1, Befund G) — until the switch is flipped + * and `SearchProvider` knew only the tenant dimension, not the user dimension, + * when measured 260910-krx, Aufgabe 1, Befund G — until the switch is flipped * (WINDOWS #18) they remain the only actually effective protection against * cross-reading/cross-deleting between two users of the SAME tenant, and the * `forTenant()` binding below ADDS a tenant boundary on top of them, it never * replaces them. + * + * Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 tragen die + * Regeln auf `DashboardLayout`, `WidgetInstance` und `SearchProvider` die + * Benutzerdimension (`current_user_id() IS NULL OR "userId" = current_user_id()`, + * fuer `SearchProvider` zusaetzlich als vier befehlsgetrennte Regeln) — jeder + * `forTenant()`-Aufruf unten reicht `userId` als drittes Argument durch. Die + * drei anwendungsseitigen Besitzpruefungen bleiben UNVERAENDERT: zweites Netz, + * kein Ersatz. Ein Aufrufer, der `userId` vergisst, saehe ohne sie den ganzen + * Mandanten (siehe .planning/WINDOWS.md). Beobachtung fuer die Kritikschrift: + * `removeWidget`/`updateWidgetConfig`/`removeSearchProvider` holen die Zeile + * per `findUnique({ where: { id } })` und vergleichen danach `userId` — nach + * dem Scharfschalten liefert `findUnique` fuer die Zeile eines Kollegen + * bereits `null` (die Regel blendet sie aus), die Anwendung meldet dann + * NotFoundException statt der heutigen Forbidden-Form — beides eine + * Abweisung, nur die Fehlerart aendert sich. */ @Injectable() export class DashboardService { @@ -77,7 +92,7 @@ export class DashboardService { * with all breakpoint arrays initialized. */ async getLayout(userId: string, tenantId: string) { - const tenantPrisma = forTenant(this.prisma, tenantId); + const tenantPrisma = forTenant(this.prisma, tenantId, userId); const record = await tenantPrisma.dashboardLayout.findUnique({ where: { userId }, }); @@ -108,7 +123,7 @@ export class DashboardService { * deferred as a product decision to Etappe 3, same as WINDOWS #22. */ async saveLayout(userId: string, tenantId: string, dto: SaveLayoutDto) { - const tenantPrisma = forTenant(this.prisma, tenantId); + const tenantPrisma = forTenant(this.prisma, tenantId, userId); try { return await tenantPrisma.dashboardLayout.upsert({ where: { userId }, @@ -144,7 +159,7 @@ export class DashboardService { * betroffene Widget entfernt (Fail-Closed). */ async getWidgets(userId: string, tenantId: string, role: Role) { - const tenantPrisma = forTenant(this.prisma, tenantId); + const tenantPrisma = forTenant(this.prisma, tenantId, userId); const widgets = await tenantPrisma.widgetInstance.findMany({ where: { userId }, orderBy: { createdAt: 'asc' }, @@ -195,7 +210,7 @@ export class DashboardService { * Creates a new widget instance for the user. */ async addWidget(userId: string, tenantId: string, dto: CreateWidgetDto) { - const tenantPrisma = forTenant(this.prisma, tenantId); + const tenantPrisma = forTenant(this.prisma, tenantId, userId); return tenantPrisma.widgetInstance.create({ data: { userId, @@ -223,7 +238,7 @@ export class DashboardService { tenantId: string, dto: UpdateWidgetConfigDto, ) { - const tenantPrisma = forTenant(this.prisma, tenantId); + const tenantPrisma = forTenant(this.prisma, tenantId, userId); const widget = await tenantPrisma.widgetInstance.findUnique({ where: { id }, }); @@ -253,7 +268,7 @@ export class DashboardService { * queries run over the SAME bound client and tenant id. */ async removeWidget(id: string, userId: string, tenantId: string) { - const tenantPrisma = forTenant(this.prisma, tenantId); + const tenantPrisma = forTenant(this.prisma, tenantId, userId); const widget = await tenantPrisma.widgetInstance.findUnique({ where: { id }, }); @@ -279,7 +294,7 @@ export class DashboardService { * below and are always prepended unchanged. */ async getSearchProviders(userId: string, tenantId: string) { - const tenantPrisma = forTenant(this.prisma, tenantId); + const tenantPrisma = forTenant(this.prisma, tenantId, userId); const custom = await tenantPrisma.searchProvider.findMany({ where: { userId }, orderBy: { createdAt: 'asc' }, @@ -300,7 +315,7 @@ export class DashboardService { tenantId: string, dto: CreateSearchProviderDto, ) { - const tenantPrisma = forTenant(this.prisma, tenantId); + const tenantPrisma = forTenant(this.prisma, tenantId, userId); return tenantPrisma.searchProvider.create({ data: { userId, @@ -319,7 +334,7 @@ export class DashboardService { * above: both queries run over the SAME bound client and tenant id. */ async removeSearchProvider(id: string, userId: string, tenantId: string) { - const tenantPrisma = forTenant(this.prisma, tenantId); + const tenantPrisma = forTenant(this.prisma, tenantId, userId); // Default providers have hardcoded IDs that won't exist in DB const provider = await tenantPrisma.searchProvider.findUnique({ where: { id }, diff --git a/apps/api/src/favorites/favorites.service.spec.ts b/apps/api/src/favorites/favorites.service.spec.ts index 86b37ef..8feaf6d 100644 --- a/apps/api/src/favorites/favorites.service.spec.ts +++ b/apps/api/src/favorites/favorites.service.spec.ts @@ -203,6 +203,8 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => { expect(result.map((r: any) => r.id)).toEqual(['f2', 'f1']); expectBoundCall(prisma, 't1', 'favoriteLink', 'findMany'); + // Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument. + expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'user-a1'); }); it('liefert unter einem FREMDEN Mandanten eine leere Liste, kein Fehler (der Wert, aus dem das Widget "Noch keine Favoriten." macht)', async () => { diff --git a/apps/api/src/favorites/favorites.service.ts b/apps/api/src/favorites/favorites.service.ts index 4d27fed..413b9fd 100644 --- a/apps/api/src/favorites/favorites.service.ts +++ b/apps/api/src/favorites/favorites.service.ts @@ -23,11 +23,15 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service'; * wuerde Widget und Link unter einem `x-tenant-id`-Wechsel eines * SUPER_ADMIN in verschiedenen Mandanten auseinanderreissen. * - * Die Regel auf `FavoriteLink` kennt KEINE Benutzerdimension (260911-gwh, - * Aufgabe 1, Pruefung 4 — dieselbe Lehre wie `CalendarSource`/ - * `DashboardLayout`/`WidgetInstance`) — die `userId`-Filter unten bleiben - * deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN - * Mandanten (Etappe-3-Entscheidung (2) traegt das nach). + * Die Regel auf `FavoriteLink` trug bei der Messung 260911-gwh (Aufgabe 1, + * Pruefung 4) KEINE Benutzerdimension — dieselbe Lehre wie `CalendarSource`/ + * `DashboardLayout`/`WidgetInstance`. Nachtrag (260911-nke, Etappe 3b): seit + * Migration 20260911120000 traegt die Regel auf `FavoriteLink` die + * Benutzerdimension (`current_user_id() IS NULL OR "userId" = current_user_id()`) + * — jeder `forTenant()`-Aufruf unten reicht `userId` als drittes Argument + * durch. Die `userId`-Filter unten bleiben trotzdem UNVERAENDERT bestehen: + * zweites Netz, kein Ersatz — ein Aufrufer, der `userId` vergisst, saehe + * ohne sie den ganzen Mandanten (siehe .planning/WINDOWS.md). * * Access control (T-08-06 / Pitfall 3): * - Every query is scoped by userId (prevents cross-user access). @@ -58,7 +62,7 @@ export class FavoritesService { async list(tenantId: string, userId: string, widgetId: string) { if (!widgetId) throw new BadRequestException('widgetId is required'); - const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any; return tenantPrisma.favoriteLink.findMany({ where: { userId, widgetId }, orderBy: [{ position: 'asc' }, { title: 'asc' }], @@ -72,7 +76,7 @@ export class FavoritesService { * If iconUrl is not provided, triggers server-side icon discovery with SSRF protection. */ async create(tenantId: string, userId: string, dto: CreateFavoriteDto) { - const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any; // T-GWH-05: der Fremdschluessel prueft an der Zeilenschutz-Regel von // WidgetInstance vorbei (Aufgabe 1, Pruefung 7) — ohne diesen Riegel @@ -116,7 +120,7 @@ export class FavoritesService { * Accepts null as an explicit value for iconUrl (clears stored icon). */ async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) { - const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any; const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } }); if (!link || link.userId !== userId) { @@ -157,7 +161,7 @@ export class FavoritesService { * Verifies userId ownership before deleting (T-08-06). */ async remove(tenantId: string, id: string, userId: string) { - const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any; const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } }); if (!link || link.userId !== userId) { @@ -183,7 +187,7 @@ export class FavoritesService { id: string, userId: string, ): Promise<{ contentType: string; body: Buffer }> { - const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any; const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } }); if (!link || link.userId !== userId || !link.iconUrl) { diff --git a/apps/api/src/tenders/tender-digest.scheduler.ts b/apps/api/src/tenders/tender-digest.scheduler.ts index 52c6e94..07f6778 100644 --- a/apps/api/src/tenders/tender-digest.scheduler.ts +++ b/apps/api/src/tenders/tender-digest.scheduler.ts @@ -132,6 +132,14 @@ export class TenderDigestScheduler implements OnModuleInit { // Je-Treffer-Haelfte, gebunden an den Mandanten DIESER // Kandidatenzeile (260909-laa, Aufgabe 3) — ein einziger gebundener // Client fuer alle Zugriffe dieses Schleifendurchlaufs. + // + // Bewusst OHNE Benutzer (260911-nke, Etappe 3b): dieser Scheduler ist + // ein Hintergrunddienst, kein Nutzer-CRUD-Aufrufer — er liest UND + // schreibt fuer den Nutzer, nicht ALS ihn eingeloggt. Die `IS NULL + // OR`-Form der Regeln macht das zur bewussten Eigenschaft: ohne + // `userId` sieht dieser Zugriff den ganzen Mandanten, exakt wie vor + // der Migration. Ein Systemkontext fuer Hintergrunddienste ist + // Etappe 3c, nicht Teil dieser Aenderung. const tenantPrisma = forTenant(this.prisma, tenantId) as any; const pref = await tenantPrisma.tenderNotificationPref.findUnique({ diff --git a/apps/api/src/tenders/tender-email-config.service.spec.ts b/apps/api/src/tenders/tender-email-config.service.spec.ts index f9a59a8..50326f2 100644 --- a/apps/api/src/tenders/tender-email-config.service.spec.ts +++ b/apps/api/src/tenders/tender-email-config.service.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { TenderEmailConfigService } from './tender-email-config.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; /** * TenderEmailConfigService.spec — Phase 14, Plan 03 (CONFIG-02, D-06/D-07). @@ -375,6 +376,8 @@ describe('TenderEmailConfigService', () => { (c: any) => c.tenantId === 't1' && c.model === 'tenderEmailConfig' && c.method === 'findUnique', ); expect(findUniqueCalls.length).toBe(2); + // Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument. + expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'user-k'); }); it('saveConfig() bindet den credChanged-Lesezugriff UND das upsert an den uebergebenen Mandanten', async () => { diff --git a/apps/api/src/tenders/tender-email-config.service.ts b/apps/api/src/tenders/tender-email-config.service.ts index 26a1c81..578a2e8 100644 --- a/apps/api/src/tenders/tender-email-config.service.ts +++ b/apps/api/src/tenders/tender-email-config.service.ts @@ -54,6 +54,12 @@ const EMAIL_CONFIG_SAFE_SELECT = { * uniqueness constraint on `userId` and surfaces as a translated * ConflictException, not a raw 500 (T-LAA-07, Befund F, Aufgabe 1). * + * Benutzerdimension seit 20260911120000 (Etappe 3b, 260911-nke): every + * `forTenant()` call above also passes `userId` as the third argument, so + * the `tenant_isolation_policy` on TenderEmailConfig ALSO enforces + * `userId = current_user_id()` — a second net, not a replacement for the + * `userId @unique` ownership model above. + * * Security: * - T-07-12: encryptedInboxCreds is excluded from every read-path select; * getConfigForApi returns `hasPassword: boolean` instead of the password. @@ -96,7 +102,7 @@ export class TenderEmailConfigService { * by userId (T-17-01) — a user only ever reads their own mailbox. */ async getConfigForApi(userId: string, tenantId: string) { - const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any; const safe = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId }, select: EMAIL_CONFIG_SAFE_SELECT, @@ -146,7 +152,7 @@ export class TenderEmailConfigService { */ async saveConfig(ctx: { userId: string; tenantId: string }, dto: TenderEmailConfigDto) { const { userId, tenantId } = ctx; - const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any; let encryptedInboxCreds: string | undefined; const credChanged = @@ -234,7 +240,7 @@ export class TenderEmailConfigService { if (!username || !password) { try { - const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any; const existing = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId } }); if (existing?.encryptedInboxCreds) { const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as { diff --git a/apps/api/src/tenders/tender-notification-pref.service.spec.ts b/apps/api/src/tenders/tender-notification-pref.service.spec.ts index f3a4cd0..5f556f8 100644 --- a/apps/api/src/tenders/tender-notification-pref.service.spec.ts +++ b/apps/api/src/tenders/tender-notification-pref.service.spec.ts @@ -1,6 +1,7 @@ import { ConflictException } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; import { TenderNotificationPrefService } from './tender-notification-pref.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; /** * TenderNotificationPrefService.spec — RED-first (TDD) proof for NOTIFY-01 @@ -144,6 +145,8 @@ describe('TenderNotificationPrefService', () => { await service.getForUser('u1', 't1'); expectBoundCall(prisma, 't1', 'findUnique'); + // Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument. + expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1'); }); it('setForUser() bindet tenderNotificationPref.upsert an den uebergebenen Mandanten', async () => { diff --git a/apps/api/src/tenders/tender-notification-pref.service.ts b/apps/api/src/tenders/tender-notification-pref.service.ts index 89123c4..7a89de4 100644 --- a/apps/api/src/tenders/tender-notification-pref.service.ts +++ b/apps/api/src/tenders/tender-notification-pref.service.ts @@ -26,6 +26,12 @@ import { forTenant } from '../prisma/prisma-tenant.extension'; * the failure is a P2002 unique-constraint violation, not an RLS * rejection. Translated below into a German message, same pattern as * `tender-saved-search.service.ts`, instead of surfacing as a raw 500. + * + * Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 traegt + * die Regel auf TenderNotificationPref die Benutzerdimension + * (`current_user_id() IS NULL OR "userId" = current_user_id()`) — beide + * `forTenant()`-Aufrufe unten reichen `userId` als drittes Argument durch. + * Die anwendungsseitige userId-Filterung bleibt zweites Netz, kein Ersatz. */ @Injectable() export class TenderNotificationPrefService { @@ -39,7 +45,7 @@ export class TenderNotificationPrefService { * autowrite needed to represent "using the default". */ async getForUser(userId: string, tenantId: string): Promise<{ digestInterval: string }> { - const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any; const existing = await tenantPrisma.tenderNotificationPref.findUnique({ where: { userId }, }); @@ -57,7 +63,7 @@ export class TenderNotificationPrefService { * than creating a new one. */ async setForUser(userId: string, tenantId: string, digestInterval: string) { - const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any; try { return await tenantPrisma.tenderNotificationPref.upsert({ where: { userId }, diff --git a/apps/api/src/tenders/tender-rss-feed.service.spec.ts b/apps/api/src/tenders/tender-rss-feed.service.spec.ts index 2f1c331..6250e95 100644 --- a/apps/api/src/tenders/tender-rss-feed.service.spec.ts +++ b/apps/api/src/tenders/tender-rss-feed.service.spec.ts @@ -509,6 +509,7 @@ describe('TenderRssFeedSourceService', () => { expectBoundCall(prisma, 'tenant-a', 'count'); expectBoundCall(prisma, 'tenant-a', 'create'); + expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-a', 'user-a'); }); // Umkehr von 'listForUser() bindet NICHT' (260910-jab, Aufgabe 2): seit @@ -530,7 +531,7 @@ describe('TenderRssFeedSourceService', () => { await service.listForUser('u-anyone', 'tenant-a'); - expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-a'); + expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-a', 'u-anyone'); expectBoundCall(prisma, 'tenant-a', 'findMany'); }); diff --git a/apps/api/src/tenders/tender-rss-feed.service.ts b/apps/api/src/tenders/tender-rss-feed.service.ts index 7a2c068..364e81a 100644 --- a/apps/api/src/tenders/tender-rss-feed.service.ts +++ b/apps/api/src/tenders/tender-rss-feed.service.ts @@ -69,7 +69,7 @@ export class TenderRssFeedSourceService { * Bindung nicht überflüssig, sondern das zweite Netz. */ async listForUser(userId: string, tenantId: string) { - const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any; return tenantPrisma.tenderRssFeedSource.findMany({ where: { OR: [{ userId: null }, { userId }] }, orderBy: { createdAt: 'asc' }, @@ -93,7 +93,7 @@ export class TenderRssFeedSourceService { ) { this.assertUrlAllowed(dto.url); - const tenantPrisma = forTenant(this.prisma, ctx.tenantId) as any; + const tenantPrisma = forTenant(this.prisma, ctx.tenantId, ctx.userId) as any; const existingCount = await tenantPrisma.tenderRssFeedSource.count({ where: { userId: ctx.userId }, }); @@ -130,7 +130,10 @@ export class TenderRssFeedSourceService { * Zeile laesst sich unter der Anwendungsrolle grundsaetzlich nicht * anlegen, weil jede Schreibregel einen Mandanten verlangt. Kein * Verwaltungsweg dafuer existiert heute; WINDOWS #24 haelt das als eigenen - * offenen Punkt fest, der NICHT mit #19 verschwindet. + * offenen Punkt fest, der NICHT mit #19 verschwindet. Nachtrag (260911-nke, + * Etappe 3b): dieselbe Begruendung gilt fuer die neue Benutzerdimension + * (20260911120000) — `createPlatform` bleibt bewusst ungebunden, WINDOWS #24 + * unveraendert offen. */ async createPlatform(dto: TenderRssFeedDto) { this.assertUrlAllowed(dto.url); @@ -173,7 +176,10 @@ export class TenderRssFeedSourceService { * Anweisungen zu zerlegen, um nur die persoenliche Haelfte zu binden, * wuerde ausserdem das Pruef-/Nutzungsfenster wieder oeffnen, das dieser * Kommentar oben (T-17-07) vermeidet — deshalb bleibt die gesamte Methode - * ungebunden, nicht nur ihre plattformweite Haelfte. + * ungebunden, nicht nur ihre plattformweite Haelfte. Nachtrag (260911-nke, + * Etappe 3b): dieselbe Begruendung gilt fuer die neue Benutzerdimension + * (20260911120000) — `remove` bleibt bewusst ungebunden, WINDOWS #24 + * unveraendert offen. */ async remove(id: string, ctx: { userId: string; isAdmin: boolean }) { const { userId, isAdmin } = ctx; diff --git a/apps/api/src/tenders/tender-triage.service.spec.ts b/apps/api/src/tenders/tender-triage.service.spec.ts index 243e963..589b754 100644 --- a/apps/api/src/tenders/tender-triage.service.spec.ts +++ b/apps/api/src/tenders/tender-triage.service.spec.ts @@ -1,6 +1,7 @@ import { ConflictException } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; import { TenderTriageService } from './tender-triage.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; /** * TenderTriageService.spec — RED-first (TDD) proof for UI-03/04 (D-09/D-10/ @@ -188,6 +189,8 @@ describe('TenderTriageService', () => { await service.setTriage('u1', 't1', 'tender-x', { isRead: true }); expectBoundCall(prisma, 't1', 'upsert'); + // Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument. + expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1'); }); it('listForUser() bindet tenderTriage.findMany an den uebergebenen Mandanten', async () => { diff --git a/apps/api/src/tenders/tender-triage.service.ts b/apps/api/src/tenders/tender-triage.service.ts index c1b109a..f1e56ef 100644 --- a/apps/api/src/tenders/tender-triage.service.ts +++ b/apps/api/src/tenders/tender-triage.service.ts @@ -25,6 +25,12 @@ export interface SetTriageInput { * TenderSavedSearch policy in Aufgabe 1; all five policies of this area * share the identical `"tenantId" = current_tenant_id()` text). * + * Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 traegt + * die Regel auf TenderTriage die Benutzerdimension (`current_user_id() IS + * NULL OR "userId" = current_user_id()`) — alle drei `forTenant()`-Aufrufe + * unten reichen `userId` als drittes Argument durch. Die anwendungsseitige + * userId-Filterung bleibt zweites Netz, kein Ersatz. + * * Cascade (Pitfall 6): the schema's `Tender @relation(..., onDelete: * Cascade)` removes a tender's triage rows automatically when Phase 10's * retention job deletes the tender — no manual cleanup needed here. @@ -69,7 +75,7 @@ export class TenderTriageService { update.favoritedAt = dto.isFavorite ? now : null; } - const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any; try { return await tenantPrisma.tenderTriage.upsert({ where: { userId_tenderId: { userId, tenderId } }, @@ -105,7 +111,7 @@ export class TenderTriageService { */ async listForUser(userId: string, tenantId: string, tenderIds: string[]) { if (!tenderIds.length) return []; - const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any; return tenantPrisma.tenderTriage.findMany({ where: { userId, tenderId: { in: tenderIds } }, }); @@ -117,7 +123,7 @@ export class TenderTriageService { * tender-query.builder.ts's buildTenderWhere. */ async favoriteIds(userId: string, tenantId: string): Promise { - const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any; const rows = await tenantPrisma.tenderTriage.findMany({ where: { userId, isFavorite: true }, select: { tenderId: true },