feat(quick-260911-nke): Benutzer an 34 Aufrufstellen gesetzt, zehn Tabellen gemessen, sechs Pruefungen umgedreht

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-11 17:39:17 +02:00
parent f0b531b712
commit 07fc653f52
16 changed files with 785 additions and 108 deletions
+666 -62
View File
@@ -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' },
},
});
}
/**
@@ -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 () => {
+18 -12
View File
@@ -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<CalendarEvent[]> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const sources = await tenantPrisma.calendarSource.findMany({
where: { userId, isVisible: true },
});
@@ -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 () => {
+26 -11
View File
@@ -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 },
@@ -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 () => {
+14 -10
View File
@@ -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) {
@@ -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({
@@ -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 () => {
@@ -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 {
@@ -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 () => {
@@ -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 },
@@ -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');
});
@@ -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;
@@ -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 () => {
@@ -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<string[]> {
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 },