diff --git a/apps/api/scripts/rls-scratch-check.mjs b/apps/api/scripts/rls-scratch-check.mjs index a0965ca..6484894 100644 --- a/apps/api/scripts/rls-scratch-check.mjs +++ b/apps/api/scripts/rls-scratch-check.mjs @@ -3776,6 +3776,581 @@ async function runAuthAreaChecks(adminUrl, scratchRoleUrl, results) { } } +/** + * Aufgabe 1 (260911-gwh) — misst die acht im Plan genannten Verhaltensweisen + * des Bereichs `favorites` unter der Rolle ohne BYPASSRLS, an der Regel + * WORTGLEICH aus der ausgelieferten Migration + * `20260909140000_rls_remaining_tenant_tables` geschnitten — NICHT dem + * Werkzeug nachgetippt (vgl. runCalendarAreaChecks/runDashboardAreaChecks). + * + * Legt die Wegwerf-Tabelle "FavoriteLink" mit SAEMTLICHEN skalaren Spalten + * des Modells an (readSchemaModelScalarFieldNames('FavoriteLink') filtert + * das Relationsfeld `widgetInstance` heraus, sonst misst Pruefung 2 die + * falsche Menge) und traegt DEN Fremdschluessel auf die von + * runDashboardAreaChecks() bereits angelegte Tabelle "WidgetInstance" + * (Befund C, WINDOWS #27: eine Relation ist fuer die Bestandsaufnahme + * unsichtbar — hier deshalb ausdruecklich mitgebaut und gemessen, nicht nur + * behauptet). Muss deshalb NACH runDashboardAreaChecks() laufen. Setzt auf + * keiner Tabelle eines spaeteren Abschnitts auf: er ist ein Blatt in der + * Aufrufkette, muss NACH runAuthAreaChecks() und VOR + * runTransactionShapeMeasurement() laufen (siehe Aufrufkette in main()). + */ +async function runFavoritesAreaChecks(adminUrl, scratchRoleUrl, results) { + const widenMigrationSql = readRlsWidenMigrationSql(); + const widenHasOwnFavoriteLinkPolicy = + widenMigrationSql && Boolean(extractPolicySql(widenMigrationSql, 'FavoriteLink')); + report( + results, + 'favoritelink-regelstand-eindeutig', + !widenHasOwnFavoriteLinkPolicy, + 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', + ); + if (widenHasOwnFavoriteLinkPolicy) { + return; + } + + const remainingMigrationSql = readRemainingTenantTablesMigrationSql(); + const favoriteLinkPolicy = remainingMigrationSql + ? extractPolicySql(remainingMigrationSql, 'FavoriteLink') + : null; + + if (!favoriteLinkPolicy) { + report( + results, + 'favoritelink-policy-aus-migration-gefunden', + false, + 'CREATE POLICY fuer "FavoriteLink" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden', + ); + return; + } + + // Befund C: vorher ueber die Wartungsrolle MESSEN, welche "WidgetInstance"- + // Zeilen stehen — nicht annehmen. runDashboardAreaChecks() legt diese + // Tabelle bereits mit drei Zeilen an (widget-a1/user-a1/TENANT-A, + // widget-a2/user-a2/TENANT-A, widget-b1/user-b1/TENANT-B). + const widgetInstanceRows = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => { + const rows = await db.$queryRaw`SELECT id, "userId", "tenantId" FROM "WidgetInstance" ORDER BY id`; + return rows; + }, + ); + + await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => { + // updatedAt bekommt DEFAULT CURRENT_TIMESTAMP als vermerkte Abweichung + // (Prisma setzt den Wert clientseitig, das schmale INSERT unten braucht + // trotzdem einen Wert) — Form von 260911-e2s/fh9. + await db.$executeRawUnsafe(` + 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, + CONSTRAINT "FavoriteLink_widgetId_fkey" FOREIGN KEY ("widgetId") REFERENCES "WidgetInstance"("id") ON DELETE CASCADE + ); + `); + await db.$executeRawUnsafe(`ALTER TABLE "FavoriteLink" ENABLE ROW LEVEL SECURITY;`); + await db.$executeRawUnsafe(`ALTER TABLE "FavoriteLink" FORCE ROW LEVEL SECURITY;`); + await db.$executeRawUnsafe(favoriteLinkPolicy); + await db.$executeRawUnsafe( + `GRANT SELECT, INSERT, UPDATE, DELETE ON "FavoriteLink" TO ${SCRATCH_ROLE_NAME}`, + ); + + // fav-a1-1/fav-a1-2 gehoeren user-a1 (TENANT-A, widget-a1); fav-a2-1 + // gehoert dem KOLLEGEN user-a2 (TENANT-A, widget-a2, Befund G-Form); + // fav-b1-1 liegt unter TENANT-B (widget-b1). fav-a1-1 traegt eine + // iconUrl, fav-a1-2 nicht (Pitfall 3 des Bereichs). + await db.$executeRawUnsafe(` + INSERT INTO "FavoriteLink" (id, "userId", "tenantId", "widgetId", title, url, "iconUrl", position) VALUES + ('fav-a1-1', 'user-a1', 'TENANT-A', 'widget-a1', 'Favorit A1-1', 'https://example.invalid/a1-1', 'https://icons.invalid/a1-1.png', 0), + ('fav-a1-2', 'user-a1', 'TENANT-A', 'widget-a1', 'Favorit A1-2', 'https://example.invalid/a1-2', NULL, 1), + ('fav-a2-1', 'user-a2', 'TENANT-A', 'widget-a2', 'Favorit A2-1', 'https://example.invalid/a2-1', NULL, 0), + ('fav-b1-1', 'user-b1', 'TENANT-B', 'widget-b1', 'Favorit B1-1', 'https://example.invalid/b1-1', NULL, 0); + `); + }); + + // Pruefung 2 zuerst — faellt sie durch, sind die Client-Messungen (3-8) + // wertlos, deshalb steht sie vor ihnen und die Funktion bricht ab, wenn + // sie fehlschlaegt (Lehre aus Pruefung 8 im Bereich `calendar`). + const schemaFields = readSchemaModelScalarFieldNames('FavoriteLink'); + const tableColumns = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => { + const rows = await db.$queryRaw` + SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'FavoriteLink' + `; + 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, + 'favoritelink-wegwerftabelle-deckt-alle-spalten-des-generierten-clients', + columnsMatch, + `Schema-Felder aus schema.prisma (model FavoriteLink, skalare Felder ohne Relation, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}; gemessene "WidgetInstance"-Zeilen (Befund C, von runDashboardAreaChecks angelegt): ${JSON.stringify(widgetInstanceRows)}`, + ); + if (!columnsMatch) { + return; + } + + const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); + try { + // 3: favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste + // — die tragende Belegzeile dieses Abschnitts. + const unboundList = await prisma.favoriteLink.findMany({ + where: { userId: 'user-a1', widgetId: 'widget-a1' }, + orderBy: [{ position: 'asc' }, { title: 'asc' }], + }); + report( + results, + 'favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste', + unboundList.length === 0, + `ungebundenes prisma.favoriteLink.findMany({ where: { userId: 'user-a1', widgetId: 'widget-a1' }, orderBy: [{ position: 'asc' }, { title: 'asc' }] }) (die Form von list) liefert ${unboundList.length} Zeile(n), obwohl 2 tatsaechlich vorhanden sind — das ist der Wert, aus dem favorites-widget.tsx "Noch keine Favoriten." macht`, + ); + + // 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). + const bound = buildInlineExtendedClient(prisma, 'TENANT-A'); + const boundOwnList = await bound.favoriteLink.findMany({ + where: { userId: 'user-a1', widgetId: 'widget-a1' }, + orderBy: [{ position: 'asc' }, { title: 'asc' }], + }); + const ownListOk = + boundOwnList.length === 2 && + boundOwnList.every((r) => r.userId === 'user-a1' && r.widgetId === 'widget-a1'); + 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))`, + ); + + // 5: favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null + // — die Datenbankseite der Vorpruefung in update/remove/getIconBytes (T-GWH-02). + const boundB = buildInlineExtendedClient(prisma, 'TENANT-B'); + const foreignFind = await boundB.favoriteLink.findUnique({ where: { id: 'fav-a1-1' } }); + report( + results, + 'favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null', + foreignFind === null, + `bound.favoriteLink.findUnique({ where: { id: 'fav-a1-1' } }) unter TENANT-B (die Zeile gehoert TENANT-A) liefert ${JSON.stringify(foreignFind)} — das ist die Datenbankseite der Vorpruefung in update/remove/getIconBytes (T-GWH-02): fremde Zeile -> findUnique liefert null -> NotFoundException`, + ); + + // 6: favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut + // — der generierte Client meldet null getroffene Zeilen bei delete anders + // als Roh-SQL (dkv Befund G in der Client-Form): Konstruktorname/code + // woertlich, das Ergebnis wird nicht vorweggenommen. + let foreignDeleteThrew = false; + let foreignDeleteDetail = ''; + try { + await boundB.favoriteLink.delete({ where: { id: 'fav-a1-1' } }); + foreignDeleteDetail = + 'bound.favoriteLink.delete unter TENANT-B auf die unter TENANT-A liegende Zeile fav-a1-1 ist NICHT fehlgeschlagen'; + } catch (err) { + foreignDeleteThrew = true; + const ctor = err?.constructor?.name ?? 'unbekannt'; + foreignDeleteDetail = `bound.favoriteLink.delete unter TENANT-B auf die unter TENANT-A liegende, fuer TENANT-B unsichtbare Zeile fav-a1-1 wirft ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()}`; + } + const stillThereAfterForeignDelete = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => { + const rows = await db.$queryRaw`SELECT id FROM "FavoriteLink" WHERE id = 'fav-a1-1'`; + return rows.length === 1; + }, + ); + report( + results, + 'favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut', + foreignDeleteThrew && stillThereAfterForeignDelete, + `${foreignDeleteDetail} — die Wartungsrolle liest die Zeile danach noch: ${stillThereAfterForeignDelete}`, + ); + + // 7: favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei — MISST, + // ob der Fremdschluessel auf "WidgetInstance" die Zeilenschutz-Regel + // dieser Tabelle umgeht (dokumentiertes PostgreSQL-Verhalten: + // referentielle Integritaet prueft AN der Regel vorbei). Das Ergebnis + // steuert Aufgabe 2 (Befund F), es wird NICHT vorweggenommen. Zweite + // Haelfte derselben Pruefung: ein gebundenes create mit einer + // WIRKLICH fehlenden widgetId MUSS an der FK-Verletzung scheitern — der + // Unterschied zwischen beiden Antworten ist das Existenzorakel (T-GWH-05). + const widgetB1UnderA = await bound.widgetInstance.findUnique({ + where: { id: 'widget-b1' }, + select: { userId: true }, + }); + let foreignWidgetCreateSucceeded = false; + let foreignWidgetCreateDetail = ''; + try { + const created = await bound.favoriteLink.create({ + data: { + id: 'fav-a1-fremdes-widget', + userId: 'user-a1', + tenantId: 'TENANT-A', + widgetId: 'widget-b1', + title: 'Fremdes Widget', + url: 'https://example.invalid/fremd', + position: 0, + }, + }); + foreignWidgetCreateSucceeded = Boolean(created); + foreignWidgetCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-b1' (gehoert TENANT-B, unter TENANT-A per gebundenem widgetInstance.findUnique unsichtbar: ${JSON.stringify(widgetB1UnderA)}) GELINGT (id=${created?.id}) — der Fremdschluessel prueft am Zeilenschutz VORBEI (dokumentiertes PostgreSQL-Verhalten)`; + // Die Zeile ist ein Messartefakt, nicht Teil des Bestands fuer die + // folgenden Pruefungen — ueber die Wartungsrolle wieder entfernen. + await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), (db) => + db.$executeRawUnsafe(`DELETE FROM "FavoriteLink" WHERE id = 'fav-a1-fremdes-widget'`), + ); + } catch (err) { + const ctor = err?.constructor?.name ?? 'unbekannt'; + foreignWidgetCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-b1' (unter TENANT-A per gebundenem widgetInstance.findUnique unsichtbar: ${JSON.stringify(widgetB1UnderA)}) scheitert mit ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()}`; + } + let missingWidgetCreateRejected = false; + let missingWidgetCreateDetail = ''; + try { + await bound.favoriteLink.create({ + data: { + id: 'fav-a1-widget-fehlt', + userId: 'user-a1', + tenantId: 'TENANT-A', + widgetId: 'widget-gibt-es-nicht', + title: 'Widget fehlt', + url: 'https://example.invalid/fehlt', + position: 0, + }, + }); + missingWidgetCreateDetail = + 'bound.favoriteLink.create unter TENANT-A mit widgetId="widget-gibt-es-nicht" ist NICHT fehlgeschlagen'; + } catch (err) { + missingWidgetCreateRejected = true; + const ctor = err?.constructor?.name ?? 'unbekannt'; + missingWidgetCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId="widget-gibt-es-nicht" scheitert mit ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()} — die FK-Verletzung, das Gegenstueck zum Gelingen oben`; + } + report( + results, + 'favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei', + missingWidgetCreateRejected, + `${foreignWidgetCreateDetail}; ${missingWidgetCreateDetail} — der Unterschied zwischen beiden Antworten ("gibt es nicht" scheitert, "liegt bei fremdem Mandanten" gelingt${foreignWidgetCreateSucceeded ? '' : ' NICHT, gemessen statt angenommen'}) ist das Existenzorakel (T-GWH-05); das Ergebnis der ersten Haelfte (Gelingen: ${foreignWidgetCreateSucceeded}) steuert, ob Aufgabe 2 einen Besitzriegel in create() baut`, + ); + + // 8: favoritelink-gebundenes-anlegen-eigener-mandant-gelingt + let ownCreateSucceeded = false; + let ownCreateDetail = ''; + try { + const created = await bound.favoriteLink.create({ + data: { + id: 'fav-a1-neu', + userId: 'user-a1', + tenantId: 'TENANT-A', + widgetId: 'widget-a1', + title: 'Neu angelegter Favorit', + url: 'https://example.invalid/neu', + position: 2, + }, + }); + const readBack = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => + db.$queryRaw`SELECT "tenantId", "createdAt", "updatedAt" FROM "FavoriteLink" WHERE id = 'fav-a1-neu'`, + ); + ownCreateSucceeded = + readBack.length === 1 && + readBack[0].tenantId === 'TENANT-A' && + readBack[0].createdAt != null && + readBack[0].updatedAt != null; + ownCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-a1' gelingt (id=${created.id}); die Wartungsrolle liest danach tenantId=${JSON.stringify(readBack[0]?.tenantId)}, createdAt=${JSON.stringify(readBack[0]?.createdAt)}, updatedAt=${JSON.stringify(readBack[0]?.updatedAt)} — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig erzeugten Werte annimmt`; + } catch (err) { + ownCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-a1' ist fehlgeschlagen: ${err.message}`; + } + report( + results, + 'favoritelink-gebundenes-anlegen-eigener-mandant-gelingt', + ownCreateSucceeded, + ownCreateDetail, + ); + } finally { + await prisma.$disconnect(); + } +} + +/** + * Aufgabe 1 (260911-gwh) — misst die neun im Plan genannten Verhaltensweisen + * des Bereichs `settings` unter der Rolle ohne BYPASSRLS, an der Regel + * WORTGLEICH aus der ausgelieferten Migration + * `20260909140000_rls_remaining_tenant_tables` geschnitten. Legt die + * Wegwerf-Tabelle "SmtpConfig" mit SAEMTLICHEN skalaren Spalten des Modells + * an UND mit dem Eindeutigkeitsindex `SmtpConfig_tenantId_key` WORTGLEICH + * aus `20260629130000_add_missing_tables` — ohne diesen Index misst + * Pruefung 8 nichts (der Konfliktweg braucht den physischen Index, nicht + * nur die Regel). Ein Blatt wie `runFavoritesAreaChecks`: muss NACH + * runAuthAreaChecks() und VOR runTransactionShapeMeasurement() laufen. + */ +async function runSettingsAreaChecks(adminUrl, scratchRoleUrl, results) { + const widenMigrationSql = readRlsWidenMigrationSql(); + const widenHasOwnSmtpConfigPolicy = + widenMigrationSql && Boolean(extractPolicySql(widenMigrationSql, 'SmtpConfig')); + report( + results, + 'smtpconfig-regelstand-eindeutig', + !widenHasOwnSmtpConfigPolicy, + widenHasOwnSmtpConfigPolicy + ? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt eine EIGENE Regel fuer "SmtpConfig" — 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 "SmtpConfig" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand', + ); + if (widenHasOwnSmtpConfigPolicy) { + return; + } + + const remainingMigrationSql = readRemainingTenantTablesMigrationSql(); + const smtpConfigPolicy = remainingMigrationSql + ? extractPolicySql(remainingMigrationSql, 'SmtpConfig') + : null; + + if (!smtpConfigPolicy) { + report( + results, + 'smtpconfig-policy-aus-migration-gefunden', + false, + 'CREATE POLICY fuer "SmtpConfig" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden', + ); + return; + } + + await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => { + await db.$executeRawUnsafe(` + CREATE TABLE "SmtpConfig" ( + id text PRIMARY KEY, + "tenantId" text NOT NULL, + host text NOT NULL, + port integer NOT NULL DEFAULT 587, + encryption text NOT NULL DEFAULT 'starttls', + username text, + "encryptedPassword" text, + "fromAddress" text NOT NULL, + "createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `); + // WORTGLEICH aus 20260629130000_add_missing_tables — ohne diesen Index + // misst Pruefung 8 nichts. + await db.$executeRawUnsafe( + `CREATE UNIQUE INDEX "SmtpConfig_tenantId_key" ON "SmtpConfig"("tenantId");`, + ); + await db.$executeRawUnsafe(`ALTER TABLE "SmtpConfig" ENABLE ROW LEVEL SECURITY;`); + await db.$executeRawUnsafe(`ALTER TABLE "SmtpConfig" FORCE ROW LEVEL SECURITY;`); + await db.$executeRawUnsafe(smtpConfigPolicy); + await db.$executeRawUnsafe( + `GRANT SELECT, INSERT, UPDATE, DELETE ON "SmtpConfig" TO ${SCRATCH_ROLE_NAME}`, + ); + await db.$executeRawUnsafe(` + INSERT INTO "SmtpConfig" (id, "tenantId", host, "encryptedPassword", "fromAddress") VALUES + ('smtp-a', 'TENANT-A', 'smtp-a.example.invalid', 'enc(a-passwort-platzhalter)', 'a@example.invalid'), + ('smtp-b', 'TENANT-B', 'smtp-b.example.invalid', 'enc(b-passwort-platzhalter)', 'b@example.invalid'); + `); + }); + + // Pruefung 2 zuerst — dieselbe Reihenfolgeregel wie bei `favorites`. + const schemaFields = readSchemaModelScalarFieldNames('SmtpConfig'); + const tableColumns = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => { + const rows = await db.$queryRaw` + SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'SmtpConfig' + `; + 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]); + const indexRows = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => { + const rows = await db.$queryRaw` + SELECT indexdef FROM pg_indexes WHERE tablename = 'SmtpConfig' AND indexname = 'SmtpConfig_tenantId_key' + `; + return rows; + }, + ); + const indexOk = indexRows.length === 1; + report( + results, + 'smtpconfig-wegwerftabelle-deckt-alle-spalten-des-generierten-clients', + columnsMatch && indexOk, + `Schema-Felder aus schema.prisma (model SmtpConfig, skalare Felder ohne Relation, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}; Eindeutigkeitsindex "SmtpConfig_tenantId_key" ueber pg_indexes: ${JSON.stringify(indexRows.map((r) => r.indexdef))}`, + ); + if (!columnsMatch || !indexOk) { + return; + } + + const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); + try { + // 3: smtpconfig-startpfad-generierter-client-ungebunden-liefert-null — + // die Form von loadAnySmtpConfigForStartupTransport(), ohne jede Bedingung. + const unboundStartup = await prisma.smtpConfig.findFirst(); + report( + results, + 'smtpconfig-startpfad-generierter-client-ungebunden-liefert-null', + unboundStartup === null, + `ungebundenes prisma.smtpConfig.findFirst() (die Form von loadAnySmtpConfigForStartupTransport) liefert ${JSON.stringify(unboundStartup)}, obwohl 2 Zeilen existieren — das ist der Wert, mit dem mail.module.ts nach dem Scharfschalten auf Umgebungsvariablen und zuletzt localhost:1025 zurueckfaellt, ein falscher Transport statt einer Meldung`, + ); + + // 4: smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile — + // die HEUTIGE Lage: dieselbe Abfrage ueber die Wartungsrolle liefert + // eine beliebige, aber vorhandene Zeile. + const adminStartupRow = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => { + const rows = await db.$queryRaw`SELECT "tenantId" FROM "SmtpConfig" LIMIT 1`; + return rows[0]; + }, + ); + report( + results, + 'smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile', + Boolean(adminStartupRow), + `dieselbe Abfrage (SELECT ... LIMIT 1 ohne jede Bedingung) ueber die Wartungsrolle liefert genau EINE Zeile, tenantId=${JSON.stringify(adminStartupRow?.tenantId)} — nichts in der Abfrage bestimmt, WELCHER Mandant gezogen wird, und dessen Server und Absender tragen ab Start alle Kennwort-Zuruecksetzungs-Mails ALLER Mandanten (T-GWH-03)`, + ); + + // 5: smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null — + // Befund K, der Versandpfad. + const unboundSendPath = await prisma.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } }); + report( + results, + 'smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null', + unboundSendPath === null, + `ungebundenes prisma.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } }) (die Form von getDecryptedSmtpConfig) liefert ${JSON.stringify(unboundSendPath)}, waehrend die Wartungsrolle die Zeile liest — Befund K: tender-mail.service.ts protokolliert "No SMTP configuration" und ueberspringt, dkv-mail.service.ts wirft; kein Versand fuer niemanden`, + ); + + // 6: smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten + const boundA = buildInlineExtendedClient(prisma, 'TENANT-A'); + const boundOwn = await boundA.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } }); + report( + results, + 'smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten', + Boolean(boundOwn) && + boundOwn.encryptedPassword === 'enc(a-passwort-platzhalter)' && + boundOwn.host === 'smtp-a.example.invalid' && + boundOwn.fromAddress === 'a@example.invalid', + `gebunden unter TENANT-A liefert findUnique({ where: { tenantId: 'TENANT-A' } }): host=${JSON.stringify(boundOwn?.host)}, fromAddress=${JSON.stringify(boundOwn?.fromAddress)}, encryptedPassword=${JSON.stringify(boundOwn?.encryptedPassword)}`, + ); + + // 7: smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null + // — T-GWH-01. + const boundB = buildInlineExtendedClient(prisma, 'TENANT-B'); + const foreignSend = await boundB.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } }); + report( + results, + 'smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null', + foreignSend === null, + `gebunden unter TENANT-B liefert findUnique({ where: { tenantId: 'TENANT-A' } }) (gehoert TENANT-A): ${JSON.stringify(foreignSend)} — die verschluesselten Zugangsdaten von A sind fuer B unsichtbar (T-GWH-01)`, + ); + + // 8: smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut + // — die Form von saveSmtpConfig, UNGEBUNDEN. Konstruktorname und code + // woertlich, das Ergebnis wird NICHT vorweggenommen; die Belegausgabe + // haelt daneben, was 260910-krx fuer DashboardLayout gemessen hat. + let conflictThrew = false; + let conflictCtor = 'unbekannt'; + let conflictCode; + let conflictMessage = ''; + try { + await prisma.smtpConfig.upsert({ + where: { tenantId: 'TENANT-A' }, + create: { + id: 'smtp-a-neu', + tenantId: 'TENANT-A', + host: 'smtp-a-neu.example.invalid', + fromAddress: 'a@example.invalid', + }, + update: { host: 'smtp-a-neu.example.invalid' }, + }); + } catch (err) { + conflictThrew = true; + conflictCtor = err?.constructor?.name ?? 'unbekannt'; + conflictCode = err?.code; + conflictMessage = (err.message ?? '').toString().trim(); + } + const hostAfterConflict = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => { + const rows = await db.$queryRaw`SELECT host FROM "SmtpConfig" WHERE id = 'smtp-a'`; + return rows[0]?.host; + }, + ); + report( + results, + 'smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut', + conflictThrew && hostAfterConflict === 'smtp-a.example.invalid', + `ungebundenes prisma.smtpConfig.upsert({ where: { tenantId: 'TENANT-A' }, ... }) (die Form von saveSmtpConfig) wirft ${conflictCtor}${conflictCode ? ` (code ${conflictCode})` : ''}: ${conflictMessage} — zum Vergleich: 260910-krx mass fuer DashboardLayout unter dieser Form PrismaClientUnknownRequestError; die Wartungsrolle liest danach weiterhin host=${JSON.stringify(hostAfterConflict)}`, + ); + + // 9: smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert + let boundUpsertSucceeded = false; + let boundUpsertDetail = ''; + try { + await boundA.smtpConfig.upsert({ + where: { tenantId: 'TENANT-A' }, + create: { + id: 'smtp-a-neu-2', + tenantId: 'TENANT-A', + host: 'smtp-a-gebunden-neu.example.invalid', + fromAddress: 'a@example.invalid', + }, + update: { host: 'smtp-a-gebunden-neu.example.invalid' }, + }); + const afterBoundUpsert = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => { + const rows = await db.$queryRaw`SELECT id, host, "updatedAt" FROM "SmtpConfig" WHERE "tenantId" = 'TENANT-A'`; + return rows[0]; + }, + ); + const bUnchanged = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => { + const rows = await db.$queryRaw`SELECT host FROM "SmtpConfig" WHERE id = 'smtp-b'`; + return rows[0]?.host; + }, + ); + boundUpsertSucceeded = + afterBoundUpsert?.id === 'smtp-a' && + afterBoundUpsert?.host === 'smtp-a-gebunden-neu.example.invalid' && + afterBoundUpsert?.updatedAt != null && + bUnchanged === 'smtp-b.example.invalid'; + boundUpsertDetail = `gebundenes upsert unter TENANT-A trifft die eigene Zeile (id=${afterBoundUpsert?.id}), die Wartungsrolle liest danach host=${JSON.stringify(afterBoundUpsert?.host)}, updatedAt=${JSON.stringify(afterBoundUpsert?.updatedAt)}; smtp-b bleibt unveraendert: ${JSON.stringify(bUnchanged)}`; + } catch (err) { + boundUpsertDetail = `gebundenes upsert unter TENANT-A ist fehlgeschlagen: ${err.message}`; + } + report( + results, + 'smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert', + boundUpsertSucceeded, + boundUpsertDetail, + ); + } finally { + await prisma.$disconnect(); + } +} + /** * Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen * den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte @@ -4015,6 +4590,8 @@ async function main() { await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results); await runTenantAreaChecks(adminUrl, scratchRoleUrlString, results); await runAuthAreaChecks(adminUrl, scratchRoleUrlString, results); + await runFavoritesAreaChecks(adminUrl, scratchRoleUrlString, results); + await runSettingsAreaChecks(adminUrl, scratchRoleUrlString, results); await runTransactionShapeMeasurement(scratchRoleUrlString, results); await runConcurrencyProbe(scratchRoleUrlString, results); } finally { diff --git a/docs/mandantentrennung-etappe2-fehlerrichtung.md b/docs/mandantentrennung-etappe2-fehlerrichtung.md index d9628f1..ce45caf 100644 --- a/docs/mandantentrennung-etappe2-fehlerrichtung.md +++ b/docs/mandantentrennung-etappe2-fehlerrichtung.md @@ -632,6 +632,14 @@ Warnung wäre Dauerlärm und verlöre ihr Signal. (dieselbe Entlastung wie in (t3)). Das ist eine Reihenfolgebedingung für Etappe 4, genau wie Befund D des `ldap`-Durchlaufs es für `groups` war — hier festgehalten, nicht gelöst. + + **Nachtrag (260911-gwh):** `getDecryptedSmtpConfig(tenantId)` läuft seit + Aufgabe 2 dieses Laufs über `forTenant()` (GENAU EIN Klient `tenantPrisma` + je Aufruf). Die Reihenfolgebedingung ist damit ERFÜLLT — siehe + `docs/mandantentrennung-zugriffsklassifikation.md`, Bestandsaufnahme-Zeile + `settings.service.ts`/`smtpConfig`, und den Hintergrunddienst-Abschnitt + dort. Die Etappe-4-Vorabprüfung muss diese Bedingung ab jetzt NICHT mehr + führen. - **Die offene Architekturfrage `req.tenantPrisma`.** Auch der Bereich `tenders` entscheidet sie nicht — er bindet dienst-intern, wie `ldap` und `groups` es vormachen. @@ -943,6 +951,19 @@ keine Zeile mehr — Ergebnis: kein Versand für niemanden, mit Wiederholung bei jedem Lauf (die Datei bleibt lokal verfügbar, D-16). Reihenfolgebedingung für Etappe 4, hier festgehalten, nicht gelöst. +**Nachtrag (260911-gwh):** der `settings`-Teil dieser Übergabe ist erfüllt — +`getDecryptedSmtpConfig(tenantId)` läuft seit Aufgabe 2 dieses Laufs über +`forTenant()`, siehe die Bestandsaufnahme-Zeile +`settings.service.ts`/`smtpConfig` in +`docs/mandantentrennung-zugriffsklassifikation.md` und (s4)(b). Erneut +gemessen: `dkv.seed.ts` ruft weiterhin +`ModuleRegistryService.seedModule()` (`module-registry.service.ts:206`, +`this.prisma.module.upsert`) — UNGEBUNDEN, aber bewusst und unverändert seit +260910-exd, weil `Module` der plattformweite Modulkatalog ohne `tenantId`- +Spalte ist (Befund E, `keine-mandantengebundene-tabelle`); "gebunden seit +260910-exd" trifft auf diesen Zugriff NICHT zu, gemessen statt aus dem Plan +abgeschrieben. + **Die offene Architekturfrage `req.tenantPrisma`.** Auch der Bereich `dkv` entscheidet sie nicht — er bindet dienst-intern, wie `ldap`, `groups` und `tenders` es vormachen. @@ -2674,6 +2695,427 @@ Benutzers, unveraendert in diesem Plan. `ldap.service.spec.ts` verweist — wird in Aufgabe 2 ersetzt, hier nur als Befund F genannt. +## Bereich favorites + +Dieser Abschnitt erweitert die Kritikschrift um den Bereich `favorites` +(Quick-Task 260911-gwh, zusammen mit `settings` der LETZTE Lauf von Etappe 2) +und beschreibt ihn zum Zeitpunkt seiner Umstellung. Die Leitfrage aus +Abschnitt (a) gilt unverändert weiter. Anders als jeder Bereich davor hängt +`FavoriteLink` über einen Fremdschlüssel an einer ZWEITEN mandantengebundenen +Tabelle (`WidgetInstance`), deren Zeilenschutz-Regel der Fremdschlüssel auf +Datenbankebene umgeht — eine Ausprägung von WINDOWS #27, hier zum ersten Mal +ausdrücklich mitgebaut statt nur benannt. + +### (f1) Die Messung + +Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen weiteren +Abschnitt (`runFavoritesAreaChecks`) erweitert, mit der Policy für +`FavoriteLink` (aus der ausgelieferten Migration +`20260909140000_rls_remaining_tenant_tables`) WORTGLEICH extrahiert, nicht im +Werkzeug nachgetippt. Tatsächlich beobachtete Ausgabe dieses Laufs +(2026-09-11, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`): + +``` +favoritelink-regelstand-eindeutig: bestanden — 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 +favoritelink-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model FavoriteLink, skalare Felder ohne Relation, 10): ["createdAt","iconUrl","id","position","tenantId","title","updatedAt","url","userId","widgetId"]; Spalten der Wegwerf-Tabelle (10): [dieselben zehn]; gemessene "WidgetInstance"-Zeilen (Befund C, von runDashboardAreaChecks angelegt): [{"id":"widget-a1","userId":"user-a1","tenantId":"TENANT-A"},{"id":"widget-a2","userId":"user-a2","tenantId":"TENANT-A"},{"id":"widget-b1","userId":"user-b1","tenantId":"TENANT-B"}] +favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste: bestanden — ungebundenes prisma.favoriteLink.findMany({ where: { userId: 'user-a1', widgetId: 'widget-a1' }, orderBy: [{ position: 'asc' }, { title: 'asc' }] }) (die Form von list) liefert 0 Zeile(n), obwohl 2 tatsaechlich vorhanden sind — das ist der Wert, aus dem favorites-widget.tsx "Noch keine Favoriten." macht +favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen: bestanden — bound.favoriteLink.findMany unter TENANT-A liefert fuer (userId='user-a1', widgetId='widget-a1') 2 Zeile(n): ["fav-a1-1","fav-a1-2"] — die Zeile von user-a2 fehlt (anwendungsseitige Benutzerfilterung); ein gebundenes findMany({ where: { widgetId: 'widget-a2' } }) unter DEMSELBEN Mandanten liefert dagegen 1 Zeile(n) des Kollegen user-a2 (["fav-a2-1"]) — die Regel auf "FavoriteLink" kennt keine Benutzerdimension +favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null: bestanden — bound.favoriteLink.findUnique({ where: { id: 'fav-a1-1' } }) unter TENANT-B (die Zeile gehoert TENANT-A) liefert null +favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut: bestanden — bound.favoriteLink.delete unter TENANT-B auf die unter TENANT-A liegende Zeile fav-a1-1 wirft PrismaClientKnownRequestError (code P2025): No record was found for a delete. — die Wartungsrolle liest die Zeile danach noch: true +favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei: bestanden — bound.favoriteLink.create unter TENANT-A mit widgetId='widget-b1' (gehoert TENANT-B, unter TENANT-A per gebundenem widgetInstance.findUnique unsichtbar: null) GELINGT (id=fav-a1-fremdes-widget) — der Fremdschluessel prueft am Zeilenschutz VORBEI (dokumentiertes PostgreSQL-Verhalten); bound.favoriteLink.create unter TENANT-A mit widgetId="widget-gibt-es-nicht" scheitert mit PrismaClientKnownRequestError (code P2003): Foreign key constraint violated — der Unterschied zwischen beiden Antworten ist das Existenzorakel (T-GWH-05) +favoritelink-gebundenes-anlegen-eigener-mandant-gelingt: bestanden — bound.favoriteLink.create unter TENANT-A mit widgetId='widget-a1' gelingt (id=fav-a1-neu); die Wartungsrolle liest danach tenantId="TENANT-A", createdAt und updatedAt gesetzt +Alle 137 Pruefungen bestanden. +``` + +Die tragende Belegzeile ist `favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste`: der +IDENTISCHE `findMany`, den `list` heute stellt, liefert UNGEBUNDEN `[]`, +während die Wartungsrolle zwei Zeilen sieht — das ist der Wert, aus dem +`favorites-widget.tsx` `Noch keine Favoriten.` macht (siehe (f3)). + +Der Fremdschlüssel ist als mitgebaute Relation gemessen, nicht nur behauptet +(Befund C, WINDOWS #27): die Wegwerf-Tabelle `"FavoriteLink"` trägt +`FOREIGN KEY ("widgetId") REFERENCES "WidgetInstance"("id") ON DELETE CASCADE` +auf die von `runDashboardAreaChecks` bereits angelegte Tabelle; vorher wurde +über die Wartungsrolle gemessen, welche `WidgetInstance`-Zeilen tatsächlich +stehen (drei, siehe Belegausgabe von Prüfung 2), statt sie anzunehmen. + +Das Ergebnis von Prüfung 7 (`favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei`) +ist zweigeteilt und trägt eine Entscheidung für Aufgabe 2: ein gebundenes +`create` unter TENANT-A mit `widgetId` eines TENANT-B-Widgets GELINGT — der +Fremdschlüssel prüft an der Zeilenschutz-Regel von `WidgetInstance` VORBEI +(dokumentiertes PostgreSQL-Verhalten: referentielle Integrität umgeht Row +Security). Derselbe Aufruf mit einer wirklich fehlenden `widgetId` scheitert +dagegen laut mit einer FK-Verletzung (Code P2003). Der Unterschied zwischen +beiden Antworten — "existiert nicht" (500/Fehler) vs. "gehört einem fremden +Mandanten" (gelingt) — ist ein Existenzorakel über Mandantengrenzen +(T-GWH-05, medium). Aufgabe 2 baut deshalb einen anwendungsseitigen +Besitzriegel in `create`, der beide Fälle auf dieselbe Antwort +(`Widget not found`) abbildet. + +### (f2) Signaltabelle je umzustellendem Pfad + +| Pfad | Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) | Konkretes Signal | Frontend lässt es durch? | +|---|---|---|---| +| `list` (`GET /favorites?widgetId=`) | Ungebundener `findMany` liefert `[]` statt der eigenen Zeilen | `200 []` | Ja — `fetchFavorites` (`favorites-api.ts:22-29`) gibt `[]` durch, siehe (f3) | +| `create` (`POST /favorites`) | Ungebundenes `create` schreibt eine Zeile, die unter der Mandantenkennung des Aufrufers physisch korrekt liegt, aber der Icon-Suchpfad ist unverändert; der Riegel aus Aufgabe 2 prüft VOR dem Schreiben, ob `widgetId` existiert und dem Aufrufer gehört | `NotFoundException('Widget not found')`, 404, wenn das Widget nicht dem Aufrufer gehört | Ja — `createFavorite` (`favorites-api.ts:33-46`) wirft `Failed to create favorite` bei `!res.ok` | +| `update` (`PATCH /favorites/:id`) | Ungebundener `findUnique` liefert `null` statt der eigenen Zeile, Vorprüfung greift bereits heute (userId-Vergleich) | `NotFoundException('FavoriteLink not found')`, 404 | Ja — `updateFavorite` wirft `Failed to update favorite` | +| `remove` (`DELETE /favorites/:id`) | Dieselbe Form wie `update` | `NotFoundException('FavoriteLink not found')`, 404 | Ja — `deleteFavorite` wirft `Failed to delete favorite` | +| `getIconBytes` (`GET /favorites/:id/icon`) | Dieselbe Vorprüfung wie `update`/`remove` | `NotFoundException('FavoriteLink not found')`, 404 | Ja — ``-Ladefehler, vom Widget nicht gesondert behandelt | + +### (f3) Welcher Code Leere als Abwesenheit deutet + +Die `list`-Kette, alle Glieder namentlich: `list` liefert `[]` (die Belegzeile +`favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste`) → +`GET /favorites?widgetId=` antwortet `200 []` → `fetchFavorites` +(`favorites-api.ts:22-29`) prüft nur `!res.ok` (bei `200` erfüllt das nicht) +und gibt `[]` durch → `favorites-widget.tsx:212-213` zeigt +`t('favorites.empty')` = **`Noch keine Favoriten.`** (`de.json`, Zeile 219). +"Zeile unsichtbar" und "nie einen gespeichert" sind für das Frontend +derselbe Wert `[]` — das ist NICHT die Familie eines lauten Fehlers, sondern +dieselbe Familie wie WINDOWS #23/#25/#26/#28 (module-registry, dashboard, +calendar, auth). + +`update`/`remove`/`getIconBytes` deuten Leere dagegen LAUT: die +Vorprüfung (`findUnique` → `null` oder fremder `userId`) wirft +`NotFoundException('FavoriteLink not found')`, 404 — `favorites-api.ts` +übersetzt das in `Failed to update/delete favorite`, das Widget setzt +`t('favorites.error')` im `catch`. Diese Richtung ist harmlos, weil ein zu +kleines Ergebnis dort bereits heute einen Fehler auslöst, der nicht mit dem +Scharfschalten neu entsteht. + +### (f4) Was dieser Durchlauf bewusst nicht löst + +- **(a) Die fehlende Benutzerdimension der Regel.** Prüfung 4 + (`favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen`) + zeigt zweigeteilt: die eigenen Zeilen kommen korrekt, UND ein gebundenes + `findMany` auf die `widgetId` eines Kollegen DESSELBEN Mandanten liefert + dessen Zeile ebenfalls — die Regel auf `FavoriteLink` kennt keine + Benutzerdimension (dieselbe Lehre wie bei `CalendarSource`, + `DashboardLayout`, `WidgetInstance`). Die anwendungsseitige + `userId`-Filterung bleibt bestehen und ist bis zur Etappe-3-Entscheidung + (2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben + Mandanten. +- **(b) Das Frontend.** Die `list`-Kette aus (f3) wird nicht geändert; + Ledger-Eintrag in Aufgabe 3. +- **(c) Die Mandantenquelle — warum der dashboard-Präzedenzfall und nicht + der auth-Präzedenzfall.** `favorites.controller.ts` `extractContext` liest + `req.tenantId ?? req.user?.tenantId` — WORTGLEICH mit + `dashboard.controller.ts`, unter dessen Bindung `WidgetInstance` liegt. + `FavoriteLink` hängt über `widgetId` an `WidgetInstance`; würde + `favorites` stattdessen an das Claim binden (wie `auth` für + Selbstbedienung), während `dashboard` an der Guard-Kennung bleibt, lägen + Widget und Link unter einem `x-tenant-id`-Wechsel eines SUPER_ADMIN in + verschiedenen Mandanten. `favorites-api.ts` sendet die Kopfzeile heute + nicht (`grep -rn "x-tenant-id" apps/web/src`: nur die vier + Marktplatz-Stellen) — die Entscheidung hängt an der Bauform, nicht am + heutigen Aufrufer. +- **(d) Die Etappe-4-Vorabprüfung.** Für einen bekannten Nutzer/Widget die + Favoritenzahl über die Wartungsrolle und über den gebundenen `findMany` + daneben halten — dieselbe Form wie bei `dashboard`/`calendar`. + +### (f5) Was dieser Durchlauf bewusst nicht anfasst + +- Der Icon-Proxy (`icon-discovery.service.ts`, Befund G) — erreicht die + Datenbank NICHT und nimmt KEINE Client-URL: `getIconBytes` holt nur die + GESPEICHERTE `iconUrl` einer Zeile, die der Aufrufer besitzt (T-QFIP-01); + `discoverFavoriteIconUrl` nimmt die Nutzer-URL nur für den + SSRF-gesicherten Abruf (T-08-05). Kein Mandantenbezug — unverändert, in + der Testdatei eine Attrappe. +- Die DTOs (`create-favorite.dto.ts`, `update-favorite.dto.ts`) — nur + gelesen, kein Mandantenfeld. +- `dashboard.controller.ts` — nur gelesen (Präzedenzfall für (f4)(c)). +- Das Frontend (`favorites-widget.tsx`, `favorites-api.ts`) — nur + beschrieben, nicht geändert. +- Schema und Migrationen. + +## Bereich settings + +Dieser Abschnitt erweitert die Kritikschrift um den Bereich `settings` +(Quick-Task 260911-gwh) und beschreibt ihn zum Zeitpunkt seiner Umstellung. +Anders als jeder Bereich davor trägt dieser Bereich die Reihenfolgebedingung +Befund K aus den Läufen `tenders` und `dkv`: `getDecryptedSmtpConfig(tenantId)` +ist der einzige Versandpfad für Ausschreibungs- und DKV-Mails. Zusätzlich +trägt der Startpfad des Mailmoduls den SECHSTEN Fall der +Hintergrunddienst-Falle — anders als bei `dkv` (WINDOWS #21) verdeckt hier +eine Rückfallkette das Verstummen mit einem falschen Transport statt +schlichter Leere. + +### (s1) Die Messung + +Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen weiteren +Abschnitt (`runSettingsAreaChecks`) erweitert, mit der Policy für +`SmtpConfig` (aus der ausgelieferten Migration +`20260909140000_rls_remaining_tenant_tables`) WORTGLEICH extrahiert. Die +Wegwerf-Tabelle trägt zusätzlich den Eindeutigkeitsindex +`SmtpConfig_tenantId_key` WORTGLEICH aus `20260629130000_add_missing_tables` +— ohne ihn misst Prüfung 8 nichts. Tatsächlich beobachtete Ausgabe dieses +Laufs (2026-09-11, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`): + +``` +smtpconfig-regelstand-eindeutig: bestanden — die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "SmtpConfig" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand +smtpconfig-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model SmtpConfig, skalare Felder ohne Relation, 10): ["createdAt","encryptedPassword","encryption","fromAddress","host","id","port","tenantId","updatedAt","username"]; Spalten der Wegwerf-Tabelle (10): [dieselben zehn]; Eindeutigkeitsindex "SmtpConfig_tenantId_key" ueber pg_indexes: ["CREATE UNIQUE INDEX \"SmtpConfig_tenantId_key\" ON public.\"SmtpConfig\" USING btree (\"tenantId\")"] +smtpconfig-startpfad-generierter-client-ungebunden-liefert-null: bestanden — ungebundenes prisma.smtpConfig.findFirst() (die Form von loadAnySmtpConfigForStartupTransport) liefert null, obwohl 2 Zeilen existieren — das ist der Wert, mit dem mail.module.ts nach dem Scharfschalten auf Umgebungsvariablen und zuletzt localhost:1025 zurueckfaellt, ein falscher Transport statt einer Meldung +smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile: bestanden — dieselbe Abfrage ueber die Wartungsrolle liefert genau EINE Zeile, tenantId="TENANT-A" — nichts in der Abfrage bestimmt, WELCHER Mandant gezogen wird +smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null: bestanden — ungebundenes prisma.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } }) (die Form von getDecryptedSmtpConfig) liefert null, waehrend die Wartungsrolle die Zeile liest +smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten: bestanden — gebunden unter TENANT-A liefert findUnique: host="smtp-a.example.invalid", fromAddress="a@example.invalid", encryptedPassword="enc(a-passwort-platzhalter)" +smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null: bestanden — gebunden unter TENANT-B liefert findUnique({ where: { tenantId: 'TENANT-A' } }): null +smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut: bestanden — ungebundenes prisma.smtpConfig.upsert(...) (die Form von saveSmtpConfig) wirft PrismaClientUnknownRequestError: ConnectorError ... SQLSTATE 42501, "new row violates row-level security policy for table \"SmtpConfig\"" — die Wartungsrolle liest danach weiterhin host="smtp-a.example.invalid" +smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert: bestanden — gebundenes upsert unter TENANT-A trifft die eigene Zeile (id=smtp-a), die Wartungsrolle liest danach den neuen Host, updatedAt gesetzt; smtp-b bleibt unveraendert +Alle 137 Pruefungen bestanden. +``` + +Die tragenden Belegzeilen sind drei: `smtpconfig-startpfad-generierter-client-ungebunden-liefert-null` +für den Startpfad, `smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null` +für Befund K, und `smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut` +für den Speicherkonflikt. Prüfung 8 wirft — gemessen, nicht angenommen — +**`PrismaClientUnknownRequestError`**, dieselbe Fehlerklasse, die 260910-krx +für `DashboardLayout` gemessen hat (nicht `PrismaClientKnownRequestError` +mit `P2002`, die Form der Bereiche `tenders`/`user`): die Regel weist den +Schreibzugriff ab, bevor eine Eindeutigkeit überhaupt geprüft wird. Die +zugrundeliegende PostgreSQL-Meldung (SQLSTATE `42501`, "new row violates +row-level security policy") ist in der Belegausgabe wörtlich enthalten. + +### (s2) Signaltabelle je Pfad + +| Pfad | Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) | Signal | Frontend | +|---|---|---|---| +| `getSmtpConfig` (`GET /settings/smtp`) | Ungebundener `findUnique` liefert `null` statt der eigenen Zeile | Controller gibt `null` → NestJS sendet `200` mit LEEREM Rumpf | Ja — verschluckt, siehe (s3) | +| `saveSmtpConfig` (`PUT /settings/smtp`) | Ungebundenes `upsert` scheitert am Eindeutigkeitsindex, weil die physisch vorhandene Zeile unsichtbar ist | `PrismaClientUnknownRequestError`, 500 | Nein — kein spezifischer Übersetzungspfad im Controller, roher 500 | +| `getDecryptedSmtpConfig` — Aufrufer `tender-mail.service.ts` (`resolveTransport`) | Ungebundener `findUnique` liefert `null` | `logger.warn('No SMTP configuration ... skipping tender mail send (will retry next run)')`, KEIN Wurf, Digest übersprungen | — (Hintergrunddienst, kein Frontend-Pfad) | +| `getDecryptedSmtpConfig` — Aufrufer `dkv-mail.service.ts` (`sendExportEmail`) | Dieselbe Form | `throw new Error('No SMTP configuration found for tenant ...')` | — (Hintergrunddienst) | +| `testSmtpConfig` (`POST /settings/smtp/test`) | Rückgriff auf `getDecryptedSmtpConfig` liefert `null`, `password`/`username` bleiben `undefined` (falls DTO leer) | Verbindungstest scheitert am Postfach, `{ success: false }` — irreführend, zeigt auf das Postfach statt auf die Datenbank | Ja — Testergebnis wird angezeigt | +| Startpfad `loadAnySmtpConfigForStartupTransport()` (`mail.module.ts`, beim Start) | Ungebundenes `findFirst()` liefert `null` statt einer beliebigen Zeile; die Rückfallkette von `mail.module.ts` greift (Priorität 2-4, zuletzt `localhost:1025`) — EIGENE Spalte, siehe (s4)(a) | Kein Fehler, ein FALSCHER, aber vorhandener Transport; `MailService` fängt jeden Transportfehler (T-02-12), Controller antwortet `200` | Ja — doppelt verdeckt | + +### (s3) Welcher Code Leere als Abwesenheit deutet + +Die `getSmtpConfig`-Kette, alle Glieder namentlich: `getSmtpConfig` liefert +`null` (Belegzeile `smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null` +zeigt dieselbe Form für den Versandpfad) → `settings.controller.ts` gibt +`null` zurück, ohne zu werfen → NestJS' `ExpressAdapter.reply` sendet bei +`isNil(body)` einen LEEREN Rumpf mit Status `200` (dieselbe Adapter-Kette wie +in (h3), 260911-fh9) → `fetchSmtp` (`settings-api.ts:51-57`) prüft nur +`res.status === 404` (nicht erfüllt) und `!res.ok` (bei `200` nicht erfüllt), +dann `res.json()` auf den leeren Rumpf → wirft → `smtp-settings-form.tsx:76-78` +`.catch(() => { /* Silent fail */ })` → leeres Formular: "SMTP nicht +eingerichtet", während die Zugangsdaten physisch noch da sind. "Nicht +eingerichtet" und "Zeile unsichtbar" sind für das Frontend derselbe Zustand +— dieselbe Familie wie WINDOWS #23/#25/#26/#28. + +Trägt der Administrator die Zugangsdaten unter diesem Eindruck neu ein, läuft +`saveSmtpConfig` als `upsert({ where: { tenantId } })`: unter der +UNGEBUNDENEN Form ist die Zeile unsichtbar, der Upsert versucht ein `INSERT` +und scheitert am Eindeutigkeitsindex `SmtpConfig_tenantId_key` — +`PrismaClientUnknownRequestError` (Prüfung 8, gemessen statt vorweggenommen; +dieselbe Fehlerklasse wie die `dashboard`-Lehre aus 260910-krx, NICHT `P2002`). + +Die beiden Versandpfade reagieren unterschiedlich auf `null` +(`getDecryptedSmtpConfig`): `tender-mail.service.ts` protokolliert eine +Warnung und überspringt den Versand (Wiederholung beim nächsten Lauf), +`dkv-mail.service.ts` wirft einen Fehler, der die aufrufende Pipeline +abbricht — beide Formen sind bereits vor diesem Lauf so verdrahtet, ändern +sich hier nicht. + +### (s4) Was dieser Durchlauf bewusst nicht löst + +**(a) Der Startpfad — der sechste Fall der Hintergrunddienst-Falle, +ausgeschrieben statt still getroffen.** `mail.module.ts`'s +`MailerModule.forRootAsync({ useFactory: async ... })` ruft +`SettingsService.loadAnySmtpConfigForStartupTransport()` +(vormals `getStartupSmtpConfig()`) BEIM START, vor jedem Anfragekontext. +Zwei Zustände, beide gehören benannt: + +- **Heute** ist die Abfrage bereits FALSCH, nicht nur ungenau: bei mehreren + Mandanten trägt der SMTP-Server und die Absenderadresse EINES beliebigen + Mandanten die Kennwort-Zurücksetzungs- und Willkommensmails ALLER + Mandanten (T-GWH-03, Nutzung fremder Zugangsdaten, nicht nur Sichtbarkeit). +- **Nach dem Scharfschalten** liefert `findFirst()` `null` → + `mail.module.ts` fällt auf Priorität 2 (`MAIL_*`), 3 (`TESSERA_SMTP_*`), + zuletzt 4 (`localhost:1025`, Mailhog) zurück → `MailService` fängt jeden + Transportfehler (`mail.service.ts:69-81`, T-02-12) und der Controller + antwortet `200`. Das Verstummen ist damit DOPPELT verdeckt: erst durch die + Rückfallkette (ein falscher, aber vorhandener Transport statt Leere), dann + durch das absichtliche Verschlucken im Versand. + +Drei geprüfte Formen: **(a) An einen konkret aufgelösten Mandanten binden** +— nicht möglich, `useFactory` hat beim Start keinen Anfragekontext. +**(b) Umbau auf Transport je Versand** — abgelehnt als Funktion für DIESEN +Lauf, mit Grund: die Vorlage steht bereits in `DkvMailService`/ +`TenderMailService` (Transport je Versand aus +`getDecryptedSmtpConfig(tenantId)`), `MailService` müsste dafür nur den +Mandanten entgegennehmen, den `requestPasswordReset` aus der Funktionszeile +bereits hat — ein Umbau des Mailmoduls, kein Bindungsumbau, NICHT dieser +Auftrag (siehe ``). **(c) Als benannte Altlast +weiterführen, mit Markierung** — GEWÄHLT: Methode umbenannt +(`loadAnySmtpConfigForStartupTransport()`, dkv-Präzedenzfall — ein Name, den +niemand für einen Anfrageweg hält), Kopfkommentar mit beiden Zuständen, +Modulkommentar in `mail.module.ts`, eigener Ledger-Eintrag. + +**Die Unsymmetrie zu BEIDEN Präzedenzfällen:** `getAllActiveConfigs` (ldap) +ist heute korrekt und verstummt erst später; `loadAnyActiveConfigForScheduler` +(dkv, WINDOWS #21) ist heute bereits falsch und verstummt zusätzlich später, +ABER mit einer Protokollzeile ("no active config found — cron job not +registered"). Der Mail-Startpfad ist heute bereits falsch UND verstummt +später OHNE Protokollzeile, weil die Rückfallkette ihn überdeckt — das ist +eine DRITTE Ausprägung, keine der beiden Vorlagen deckt sie vollständig. + +**Entscheidung: EIGENER Ledger-Eintrag statt Anschluss an #21.** Andere +Datei (`mail.module.ts`/`settings.service.ts` statt +`dkv-scheduler.service.ts`), andere Reparatur (Transport je Versand statt +Mehrmandanten-Planung), andere Verdeckungsform (Rückfallkette statt bloßer +Leere) — drei eigenständige Unterschiede, kein Wiederholungsfall von #21. + +**(b) Befund K ist erfüllt.** Die Reihenfolgebedingung aus (t4) Befund K und +(d4) Übergaben-Absatz — `getDecryptedSmtpConfig(tenantId)` müsse gebunden +sein, bevor Etappe 4 scharfschaltet — ist mit Aufgabe 2 dieses Laufs +ERFÜLLT: die Methode läuft seither über GENAU EINEN Klienten `tenantPrisma`. +Beide Stellen bekommen in Aufgabe 3 einen Nachtrag (siehe (t4), (d4) unten in +diesem Dokument sowie den Hintergrunddienst-Abschnitt in +`docs/mandantentrennung-zugriffsklassifikation.md`). Die Etappe-4-Vorabprüfung +muss diese Bedingung ab jetzt NICHT mehr führen. + +**(c) Das Frontend.** Die `getSmtpConfig`-Kette aus (s3) wird nicht +geändert; Ledger-Eintrag in Aufgabe 3 (Familie #28 — dieselbe +200-leerer-Rumpf-Kette wie `auth`). + +**(d) Die Mandantenquelle `req.tenantId` im Controller.** Bewusst NICHT auf +das Claim umgestellt (anders als die Selbstbedienungs-Begründung aus +`auth`): `settings.controller.ts` ist eine ADMIN-Konfigurationsseite, für +die `req.tenantId` (per `x-tenant-id` für SUPER_ADMIN umschaltbar) die +RICHTIGE Quelle ist (D-10) — ein SUPER_ADMIN konfiguriert damit gezielt die +SMTP-Zugangsdaten eines ANDEREN Mandanten. Der Controller bleibt deshalb +unverändert und steht nicht in der Erlaubnisliste. + +**(e) Die Etappe-4-Vorabprüfung.** Für einen bekannten Mandanten die +`SmtpConfig`-Zeile über die Wartungsrolle lesen und den gebundenen +`findUnique` daneben halten — dieselbe Form wie bei `dashboard`/`calendar`. + +### (s5) Was dieser Durchlauf bewusst nicht anfasst + +- `settings.controller.ts` — nur gelesen (siehe (s4)(d)). +- `apps/api/src/settings/dto/smtp-config.dto.ts` — nur gelesen, kein + Mandantenfeld. +- `tender-mail.service.ts` (`resolveTransport`) — nur gelesen, ruft + weiterhin `getDecryptedSmtpConfig(tenantId)` unverändert auf. +- `dkv-mail.service.ts` (`sendExportEmail`) — dieselbe Form. +- `mail.service.ts` — nur gelesen; `mail.module.ts` wird für die + Umbenennung des Startpfad-Aufrufs angefasst (Aufgabe 2), hier nur + angekündigt. +- Das Frontend (`smtp-settings-form.tsx`, `settings-api.ts`) — nur + beschrieben, nicht geändert. +- Schema und Migrationen. +- `nodemailer` — kein echter Transport in irgendeinem Test dieses Laufs + (lokal gibt es keinen `mailhog`); in der neuen Testdatei per + `vi.mock('nodemailer')` ersetzt. + +## Etappe 2 — Abschluss + +Etappe 2 der Mandantentrennung ist mit diesem Lauf (260911-gwh) vollständig: +jede klassifizierte Fundstelle in `apps/api/src` ist entweder gebunden oder +mit geschriebenem Grund an der Stelle ungebunden. Die Zahlen unten sind aus +den eigenen Messanweisungen des Klassifikationsdokuments abgeleitet +(Übersichtszeilen, Summenzeile, Klassen-Verteilung), nicht neu geschätzt. + +**Läufe.** Zwölf Bereichs-/Regel-Läufe von 260909-ipc bis 260911-gwh (gezählt +aus `.planning/STATE.md`, Reihenfolge): `ldap` (260909-ipc), `groups` +(260909-jts), `tenders` (260909-laa), `dkv` (260909-mir), `user` +(260910-das), `module-registry` (260910-exd), `dashboard` (260910-krx), das +Regelschluss-Plan `tenders`/`SearchProvider` (260910-jab), `tenant` +(260911-e2s), `calendar` (260911-cwh), `auth` (260911-fh9), `favorites`/ +`settings` (260911-gwh, dieser Lauf). + +**Summenzeile der Übersichtstabelle, vorher/nachher.** Zum Kopf des +Klassifikationsdokuments (Stand 260909-eor): 227 Rohtreffer über 59 +Datei-Modell-Paare. Nach Aufgabe 3 dieses Laufs (siehe Summenzeile in +`docs/mandantentrennung-zugriffsklassifikation.md`, DERIVIERT aus den +Bereichszeilen, nicht abgeschrieben): siehe dortige Summenzeile für die +Endzahl. + +**Klassen-Verteilung.** Siehe `docs/mandantentrennung-zugriffsklassifikation.md`, +Abschnitt "Klassen-Verteilung", Stand 260911-gwh (Aufgabe 3) für die +Paarzahl und die Verteilung nach den vier Klassen. + +**Bewusst ungebundene Reste je Bereich, mit Grund:** + +- `tenders`: der D-03-Katalog (platform-global, `Tender`/`TenderSource`/ + `TenderSourcePollConfig`), die zwei Fan-out-Adapter (E-Mail/RSS, ein Tick + pro Postfach bzw. Feed über alle Mandanten), die übergreifenden Hälften + der beiden Hintergrunddienste (Etappe-3-Übergabe), `createPlatform`/ + `remove` in `tender-rss-feed.service.ts` (WINDOWS #24). +- `ldap`: `getAllActiveConfigs`/`onApplicationBootstrap` (Befund B), + `resolveEmailForWrite` (plattformweit eindeutiger Schlüssel, T-IPC-04). +- `dkv`: der Planer-Startpfad `loadAnyActiveConfigForScheduler()` + (WINDOWS #21). +- `user`: `findByUsername` (plattformweit eindeutiger Schlüssel), die + Erstanlage-Prüfung und beide `tenant`-Zugriffe in `admin-seed.service.ts`, + der Schleifentreiber `tenant.findMany` der Plattform-Administratorsicht. +- `module-registry`/`dashboard`: der Modulkatalog (`Module`, keine Regel + heute — Befund E). +- `auth`: die drei Anmeldefunktionen (`validateUser`, + `requestPasswordReset`, `resetPassword`) über die SECURITY-DEFINER-Wege. +- `tenant`: die Mandantentabelle selbst (`Tenant`, keine eigene + `tenantId`-Spalte, keine Regel in irgendeiner Migration). +- `settings`: der sechste Fall der Hintergrunddienst-Falle, + `loadAnySmtpConfigForStartupTransport()` (dieser Lauf, siehe (s4)(a)). + +**Offene Ledger-Einträge dieser Etappe** (Nummern siehe `.planning/WINDOWS.md`, +Kopfzähler geprüft): #18 (Schalter aus), #20 (Verbindungsfehler, an dieselbe +Bedingung gebunden wie #18), #21 (dkv-Planer-Startpfad), #22 +(plattformweite Eindeutigkeit von `username`/`email`), #23 +(module-registry, unterscheidbares Signal fehlt), #24 (plattformweite +RSS-Verwaltung unter der Anwendungsrolle), #25 (dashboard, beweisvernichtende +Fehlerrichtung), #26 (calendar, verschluckte Leere), #27 +(Relationszugriffe für die Bestandsaufnahme unsichtbar), #28 (auth, +verschluckte Leere), plus die drei neuen Einträge dieses Laufs (Startpfad +des Mailmoduls, verschluckte Leere `favorites`, verschluckte Leere +`settings` — Nummern siehe Aufgabe 3 dieses Plans). + +**Prüfungen und Tests.** Werkzeug: siehe die Zeile `Alle N Prüfungen +bestanden.` des letzten Laufs von `rls-scratch-check.mjs` in dieser Aufgabe. +Tests: siehe die letzte Testausgabe von `npm --prefix apps/api run test` in +dieser Aufgabe. + +**Was für Etappe 3 bleibt:** + +- Der Anmeldeweg unter je Mandant eindeutigen Namen (`username`/`email`, + Etappe-3-Entscheidung (1)) — siehe (h4)(a). +- Die Benutzerdimension der Regeln (Etappe-3-Entscheidung (2)) — siehe + (k4)/(f4)(a) und die übrigen Bereiche mit derselben Beobachtung. +- Die Modulkatalog-Regel für `Module` (Befund E, `module-registry`) — sobald + eine Regel eingeführt wird, müssen die heute bewusst ungebundenen + Katalogzugriffe nachgezogen werden. +- Die Kennzeichnung der `bewusst-uebergreifend`-Stellen (Systemkontext, + siehe "Die drei Klassen" im Klassifikationsdokument). +- Der Mandantenwechsel im Ausschreibungs-Digest ((t4), der Sonderfall eines + Nutzers mit Treffern unter zwei verschiedenen Mandanten). + +**Was Etappe 4 (`rls-preflight.mjs`) VOR dem Scharfschalten prüfen muss** — +die in den Bereichsabschnitten benannten Vorabprüfungen, als Liste (OHNE die +jetzt erfüllte Befund-K-Bedingung, die entfällt): + +- `ldap`: das Verstummen von `getAllActiveConfigs` (Befund B). +- `dkv`: das Verstummen des Planer-Startpfads (WINDOWS #21). +- `tenders`: wachsende Zahl von `TenderMatch`-Zeilen mit `notifiedAt IS NULL` + ohne Versandprotokoll (t3). +- `module-registry`: aktive Aktivierungszeilen vorhanden, aber die + Auflösung liefert für einen bekannten Administrator eine leere Menge + (WINDOWS #23). +- `dashboard`: eine physisch vorhandene `DashboardLayout`-Zeile für einen + bekannten Benutzer, aber der gebundene Lesezugriff liefert `null` + (WINDOWS #25). +- `calendar`: physisch vorhandene `CalendarSource`-Zeilen je Mandant über + die Wartungsrolle zählen und mit der gebundenen Zählung vergleichen + (k4)(e). +- `auth`: einen bekannten Benutzer über die Wartungsrolle lesen und den + gebundenen `findUnique` unter seinem Claim-Mandanten daneben halten + (WINDOWS #28). +- `favorites`: für einen bekannten Nutzer/Widget die Favoritenzahl über die + Wartungsrolle und über den gebundenen `findMany` daneben halten (f4)(d). +- `settings`: für einen bekannten Mandanten die `SmtpConfig`-Zeile über die + Wartungsrolle lesen und den gebundenen `findUnique` daneben halten + (s4)(e). +- Das Verstummen des Mail-Startpfads (dieser Lauf, (s4)(a)) — EIGENES + Signal, NICHT an #21 angeschlossen. + ## Verweis Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang