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:
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user