diff --git a/apps/api/scripts/rls-scratch-check.mjs b/apps/api/scripts/rls-scratch-check.mjs index 4aca9a4..025b579 100644 --- a/apps/api/scripts/rls-scratch-check.mjs +++ b/apps/api/scripts/rls-scratch-check.mjs @@ -3096,6 +3096,355 @@ async function runCalendarAreaChecks(adminUrl, scratchRoleUrl, results) { } } +/** + * Liest die Migration `20260618112124_auth_multi_tenancy` (Dateiname endet + * auf "_auth_multi_tenancy") — die einzige, die `CREATE TABLE "Tenant"` und + * den Fremdschluessel `User_tenantId_fkey` enthaelt. + */ +function readAuthMultiTenancyMigrationSql() { + const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.endsWith('_auth_multi_tenancy')) + .map((entry) => entry.name); + if (dirs.length !== 1) return null; + return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8'); +} + +/** + * Schneidet die Spaltennamen aus dem `CREATE TABLE "Tenant" ( ... );`-Block + * der Migration — NICHT aus `readSchemaModelFieldNames('Tenant')` (Befund M): + * `schema.prisma` fuehrt bei `Tenant` vier Relationsfelder (`users`, + * `ldapConfig`, `groups`, `moduleGrants`), die keine Spalten sind und die + * Client-Vergleichspruefung faelschlich durchfallen liessen. + */ +function readTenantCreateTableColumns(migrationSql) { + const match = migrationSql.match(/CREATE TABLE "Tenant" \(([\s\S]*?)\n\);/); + if (!match) return []; + const columns = []; + for (const rawLine of match[1].split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('CONSTRAINT')) continue; + const m = line.match(/^"([a-zA-Z]+)"/); + if (m) columns.push(m[1]); + } + return columns; +} + +/** + * Schneidet `ALTER TABLE "User" ADD CONSTRAINT "User_tenantId_fkey" ...;` + * wortgleich aus der Migration — nicht getippt (Aufgabe 1, TEIL 1). + */ +function readUserTenantForeignKeySql(migrationSql) { + const match = migrationSql.match( + /ALTER TABLE "User" ADD CONSTRAINT "User_tenantId_fkey"[\s\S]*?;/, + ); + return match ? match[0] : null; +} + +/** + * Aufgabe 1 (260911-e2s) — misst die neun im Plan genannten + * Verhaltensweisen des Bereichs `tenant` unter der Rolle ohne BYPASSRLS. Auf + * `Tenant` selbst ist nichts zu binden (keine Regel in irgendeiner + * ausgelieferten Migration, einschliesslich `20260910120000_...` — Pruefung + * 1) — dieser Abschnitt hat trotzdem neun Pruefungen, weil drei der acht + * Zugriffsstellen des Controllers ueber eine Relationseinbindung + * (`include: { _count: { select: { users } } }`) in die GESCHUETZTE Tabelle + * "User" hineinzaehlen (Befund F). + * + * Setzt auf den bereits vorhandenen Wegwerf-Tabellen "Tenant" (aus + * `runUserAreaChecks`, dort nur `id`/`slug`) und "User" (aus + * `runAuthLookupChecks`, mit Zeilenschutz und wortgleicher Regel) auf und + * erweitert "Tenant" um die vier fehlenden Spalten sowie den Fremdschluessel + * `User_tenantId_fkey` — keine spaetere Pruefung setzt auf diesen + * Erweiterungen auf (Befund M: `runTransactionShapeMeasurement` und + * `runConcurrencyProbe` fassen weder "User" noch "Tenant" an). Muss deshalb + * NACH `runCalendarAreaChecks()` und VOR `runTransactionShapeMeasurement()` + * laufen (siehe Aufrufkette in main()). + */ +async function runTenantAreaChecks(adminUrl, scratchRoleUrl, results) { + // Pruefung 1: keine Regel auf "Tenant" in irgendeiner ausgelieferten + // Migration — liest jede Datei zur Laufzeit, statt der Dokumentation zu + // glauben. + const migrationDirNames = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + const violatingMigrations = []; + for (const dirName of migrationDirNames) { + const sql = readFileSync(join(MIGRATIONS_DIR, dirName, 'migration.sql'), 'utf-8'); + if ( + /CREATE POLICY \w+ ON "Tenant"/.test(sql) || + /ALTER TABLE "Tenant"/.test(sql) + ) { + violatingMigrations.push(dirName); + } + } + const widenMigrationDirName = migrationDirNames.find((d) => + d.endsWith('_rls_widen_membership_grant_and_platform_read'), + ); + report( + results, + 'tenant-keine-regel-in-allen-ausgelieferten-migrationen', + violatingMigrations.length === 0 && Boolean(widenMigrationDirName), + `${migrationDirNames.length} Migrationsverzeichnisse gelesen, darunter "${widenMigrationDirName ?? 'NICHT GEFUNDEN'}" — "Tenant" kommt darin nicht vor; ${violatingMigrations.length} Verzeichnis(se) mit CREATE POLICY/ALTER TABLE auf "Tenant": ${JSON.stringify(violatingMigrations)}`, + ); + + const authMultiTenancySql = readAuthMultiTenancyMigrationSql(); + const tenantColumnsFromMigration = authMultiTenancySql + ? readTenantCreateTableColumns(authMultiTenancySql) + : []; + const userTenantFkSql = authMultiTenancySql + ? readUserTenantForeignKeySql(authMultiTenancySql) + : null; + if (!authMultiTenancySql || tenantColumnsFromMigration.length === 0 || !userTenantFkSql) { + report( + results, + 'tenant-migration-auth-multi-tenancy-und-fremdschluessel-gefunden', + false, + `Migration *_auth_multi_tenancy=${Boolean(authMultiTenancySql)}, CREATE TABLE "Tenant"-Spalten=${tenantColumnsFromMigration.length}, User_tenantId_fkey gefunden=${Boolean(userTenantFkSql)}`, + ); + return; + } + + await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => { + // (a) Vier fehlende Spalten, Typen aus dem ausgelieferten + // `CREATE TABLE "Tenant"` (20260618112124_auth_multi_tenancy). + // ABWEICHUNG: fuer "name" und "updatedAt" braucht das Nachruesten gegen + // die beiden bereits vorhandenen Zeilen (TENANT-A/TENANT-B, angelegt von + // runUserAreaChecks) einen DEFAULT, den die ausgelieferte Migration + // selbst nicht hat (dort NOT NULL ohne DEFAULT) — betrifft nur dieses + // Nachruesten hier, keine Aussage ueber den ausgelieferten Stand. + await db.$executeRawUnsafe( + `ALTER TABLE "Tenant" ADD COLUMN "name" TEXT NOT NULL DEFAULT 'Platzhalter';`, + ); + await db.$executeRawUnsafe( + `ALTER TABLE "Tenant" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true;`, + ); + await db.$executeRawUnsafe( + `ALTER TABLE "Tenant" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;`, + ); + await db.$executeRawUnsafe( + `ALTER TABLE "Tenant" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;`, + ); + + // (b) Fremdschluessel wortgleich aus der Migration geschnitten (oben), + // nicht getippt — die vier vorhandenen "User"-Zeilen referenzieren + // ausschliesslich TENANT-A/TENANT-B, beide existieren bereits. + await db.$executeRawUnsafe(userTenantFkSql); + + // (c) Dritte Mandantenzeile ohne Benutzer. + await db.$executeRawUnsafe( + `INSERT INTO "Tenant" (id, slug, name) VALUES ('TENANT-C', 'tenant-c', 'Tenant C');`, + ); + }); + + const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); + try { + // Pruefung 2: gebunden (TENANT-A), ungebunden und ueber die Wartungsrolle + // liefern DIESELBEN drei Kennungen — der Beleg "nichts zu binden". + const boundIdsRaw = ( + await forTenantQuery(prisma, 'TENANT-A', (tx) => tx.$queryRaw`SELECT id FROM "Tenant" ORDER BY id`) + ).map((r) => r.id); + const unboundIdsRaw = (await prisma.$queryRaw`SELECT id FROM "Tenant" ORDER BY id`).map( + (r) => r.id, + ); + const adminIdsRaw = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => (await db.$queryRaw`SELECT id FROM "Tenant" ORDER BY id`).map((r) => r.id), + ); + const allIdenticalRaw = + JSON.stringify(boundIdsRaw) === JSON.stringify(unboundIdsRaw) && + JSON.stringify(unboundIdsRaw) === JSON.stringify(adminIdsRaw); + report( + results, + 'tenant-gebunden-und-ungebunden-liefern-dieselben-zeilen', + allIdenticalRaw, + `Roh-SQL gebunden (TENANT-A): ${JSON.stringify(boundIdsRaw)}; ungebunden: ${JSON.stringify(unboundIdsRaw)}; Wartungsrolle: ${JSON.stringify(adminIdsRaw)}`, + ); + + // Pruefung 3 — steht VOR den Client-Pruefungen (4-9); faellt sie durch, + // bricht der Abschnitt ab (Lehre aus Pruefung 8 im Bereich `calendar`). + const migrationColumnsSorted = [...tenantColumnsFromMigration].sort(); + 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 = 'Tenant' + `; + return rows.map((r) => r.column_name).sort(); + }, + ); + const columnsMatch = + migrationColumnsSorted.length > 0 && + migrationColumnsSorted.length === tableColumns.length && + migrationColumnsSorted.every((f, i) => f === tableColumns[i]); + report( + results, + 'tenant-wegwerftabelle-deckt-alle-spalten-des-generierten-clients', + columnsMatch, + `Spalten aus CREATE TABLE "Tenant" in 20260618112124_auth_multi_tenancy (${migrationColumnsSorted.length}): ${JSON.stringify(migrationColumnsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}`, + ); + if (!columnsMatch) { + return; + } + + // Pruefung 4: derselbe Vergleich ueber den generierten Client. + const bound = buildInlineExtendedClient(prisma, 'TENANT-A'); + const clientBoundIds = (await bound.tenant.findMany({ orderBy: { id: 'asc' } })).map( + (t) => t.id, + ); + const clientUnboundIds = (await prisma.tenant.findMany({ orderBy: { id: 'asc' } })).map( + (t) => t.id, + ); + const clientIdsMatch = + JSON.stringify(clientBoundIds) === JSON.stringify(boundIdsRaw) && + JSON.stringify(clientUnboundIds) === JSON.stringify(boundIdsRaw); + report( + results, + 'tenant-generierter-client-zeilen-gebunden-und-ungebunden-identisch', + clientIdsMatch, + `generierter Client gebunden (TENANT-A): ${JSON.stringify(clientBoundIds)}; ungebunden: ${JSON.stringify(clientUnboundIds)}; Roh-SQL-Vergleichswert (Pruefung 2): ${JSON.stringify(boundIdsRaw)}`, + ); + + // Wartungszahl je Mandant, fuer Pruefung 5/6/9. + const adminUserCountRows = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => + db.$queryRaw`SELECT "tenantId", count(*)::int AS c FROM "User" GROUP BY "tenantId"`, + ); + const adminUserCounts = new Map(adminUserCountRows.map((r) => [r.tenantId, r.c])); + + // Pruefung 5 — die tragende Belegzeile: die Abfrage, die `findAll` + // heute stellt, UNGEBUNDEN auf dem generierten Client: jeder Zaehler ist + // 0, waehrend die Wartungsrolle je Mandant mehr als 0 zaehlt. + const clientUnboundWithCounts = await prisma.tenant.findMany({ + include: { _count: { select: { users: true } } }, + orderBy: { id: 'asc' }, + }); + const allUnboundCountsZero = clientUnboundWithCounts.every((t) => t._count.users === 0); + const groundTruthHasPositiveCounts = ['TENANT-A', 'TENANT-B'].every( + (id) => (adminUserCounts.get(id) ?? 0) > 0, + ); + report( + results, + 'tenant-generierter-client-benutzerzaehler-ungebunden-null-fuer-jeden-mandanten', + allUnboundCountsZero && groundTruthHasPositiveCounts, + `das ist die Zahl, die admin/tenants/page.tsx als Benutzeranzahl anzeigen wuerde — ungebunden: ${JSON.stringify(clientUnboundWithCounts.map((t) => ({ id: t.id, userCount: t._count.users })))}; Wartungszahl je Mandant: ${JSON.stringify(Object.fromEntries(adminUserCounts))}`, + ); + + // Pruefung 6: dieselbe Abfrage gebunden unter TENANT-A. + const clientBoundWithCounts = await bound.tenant.findMany({ + include: { _count: { select: { users: true } } }, + orderBy: { id: 'asc' }, + }); + const countByIdBound = new Map(clientBoundWithCounts.map((t) => [t.id, t._count.users])); + const boundCountsMatchExpectation = + countByIdBound.get('TENANT-A') === adminUserCounts.get('TENANT-A') && + countByIdBound.get('TENANT-B') === 0 && + countByIdBound.get('TENANT-C') === 0 && + (adminUserCounts.get('TENANT-B') ?? 0) > 0; + report( + results, + 'tenant-generierter-client-benutzerzaehler-gebunden-nur-eigener-mandant', + boundCountsMatchExpectation, + `gebunden unter TENANT-A: A=${countByIdBound.get('TENANT-A')} (Wartungszahl=${adminUserCounts.get('TENANT-A')}), B=${countByIdBound.get('TENANT-B')} (Wartungszahl=${adminUserCounts.get('TENANT-B') ?? 0}), C=${countByIdBound.get('TENANT-C')}`, + ); + + // Pruefung 7 — die Abfrage, die `remove` heute stellt, ungebunden: + // Zaehler 0 trotz aktiver Benutzer bei der Wartungsrolle, der Riegel + // T-02-09 liesse das Loeschen durch; das anschliessende ungebundene + // `delete` ueber den generierten Client scheitert LAUT am + // Fremdschluessel, der den Zeilenschutz umgeht. + const removeQueryUnbound = await prisma.tenant.findUnique({ + where: { id: 'TENANT-A' }, + include: { _count: { select: { users: { where: { isActive: true } } } } }, + }); + const unboundActiveCount = removeQueryUnbound?._count.users; + const adminActiveCountA = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => { + const rows = + await db.$queryRaw`SELECT count(*)::int AS c FROM "User" WHERE "tenantId" = 'TENANT-A' AND "isActive" = true`; + return rows[0].c; + }, + ); + const gateWouldPassThrough = unboundActiveCount === 0 && adminActiveCountA > 0; + + let deleteThrew = false; + let deleteErrCtor = 'unbekannt'; + let deleteErrCode; + let deleteErrMessage = ''; + try { + await prisma.tenant.delete({ where: { id: 'TENANT-A' } }); + } catch (err) { + deleteThrew = true; + deleteErrCtor = err?.constructor?.name ?? 'unbekannt'; + deleteErrCode = err?.code; + deleteErrMessage = (err.message ?? '').toString().trim(); + } + const stillExistsAfterDelete = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => { + const rows = await db.$queryRaw`SELECT id FROM "Tenant" WHERE id = 'TENANT-A'`; + return rows.length === 1; + }, + ); + report( + results, + 'tenant-loeschriegel-ungebunden-vakuum-fremdschluessel-faengt-laut', + gateWouldPassThrough && deleteThrew && stillExistsAfterDelete, + `ungebundener Relationszaehler ueber aktive Benutzer fuer TENANT-A=${unboundActiveCount}, Wartungszahl=${adminActiveCountA} — der Riegel T-02-09 liesse das Loeschen durch; ungebundenes prisma.tenant.delete ueber den generierten Client wirft ${deleteErrCtor}${deleteErrCode ? ` (code ${deleteErrCode})` : ''}: ${deleteErrMessage} — die Zeile existiert ueber die Wartungsrolle danach noch: ${stillExistsAfterDelete}; die referentielle Pruefung des Fremdschluessels umgeht den Zeilenschutz und faengt das Loeschen trotzdem ab`, + ); + + // Pruefung 8 — dasselbe Loeschen fuer TENANT-C (ohne Benutzer) gelingt: + // falsifiziert "Loeschen scheitert immer". + let deleteCSucceeded = false; + let deleteCDetail = ''; + try { + await prisma.tenant.delete({ where: { id: 'TENANT-C' } }); + deleteCSucceeded = true; + deleteCDetail = + 'ungebundenes prisma.tenant.delete fuer TENANT-C (ohne Benutzer) ist NICHT fehlgeschlagen'; + } catch (err) { + deleteCDetail = `ungebundenes prisma.tenant.delete fuer TENANT-C ist unerwartet fehlgeschlagen: ${err.message}`; + } + const goneAfterDeleteC = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => { + const rows = await db.$queryRaw`SELECT id FROM "Tenant" WHERE id = 'TENANT-C'`; + return rows.length === 0; + }, + ); + report( + results, + 'tenant-loeschen-ohne-benutzer-gelingt-wie-heute', + deleteCSucceeded && goneAfterDeleteC, + `${deleteCDetail}; ueber die Wartungsrolle danach noch vorhanden: ${!goneAfterDeleteC}`, + ); + + // Pruefung 9 — die Form, die Aufgabe 3 einbaut: `prisma.tenant.findMany` + // ungebunden als Treiber, dann je VERBLIEBENEM Mandanten (TENANT-C ist + // seit Pruefung 8 geloescht) ein gebundener Zaehlaufruf. + const remainingTenants = await prisma.tenant.findMany({ orderBy: { id: 'asc' } }); + const fanOutResults = []; + let fanOutAllMatch = remainingTenants.length > 0; + for (const t of remainingTenants) { + const boundForT = buildInlineExtendedClient(prisma, t.id); + const count = await boundForT.user.count({ where: { tenantId: t.id } }); + const adminCount = adminUserCounts.get(t.id) ?? 0; + fanOutResults.push({ id: t.id, gebunden: count, wartung: adminCount }); + if (count !== adminCount) fanOutAllMatch = false; + } + report( + results, + 'tenant-fan-out-je-mandant-gebundene-zaehlung-stimmt', + fanOutAllMatch, + `Fan-out je verbliebenem Mandanten (${remainingTenants.length}): ${JSON.stringify(fanOutResults)}`, + ); + } finally { + await prisma.$disconnect(); + } +} + /** * Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen * den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte @@ -3333,6 +3682,7 @@ async function main() { await runModuleRegistryAreaChecks(adminUrl, scratchRoleUrlString, results); await runDashboardAreaChecks(adminUrl, scratchRoleUrlString, results); await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results); + await runTenantAreaChecks(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 f0a5725..3176d21 100644 --- a/docs/mandantentrennung-etappe2-fehlerrichtung.md +++ b/docs/mandantentrennung-etappe2-fehlerrichtung.md @@ -2259,6 +2259,217 @@ Browsers und im API-Log, an keiner Stelle in der Benutzeroberfläche. - Schema und Migrationen — geprüft und bewusst gelassen, keine Schemaänderung in dieser Etappe. +## Bereich tenant + +Dieser Abschnitt erweitert die Kritikschrift um den Bereich `tenant` +(Quick-Task 260911-e2s) und beschreibt ihn zum Zeitpunkt seiner Umstellung. +Anders als jeder Bereich davor betrifft er nicht mandantengebundene Tabellen, +sondern die Mandantentabelle SELBST — `Tenant` hat per Definition keine +`tenantId`-Spalte und ist deshalb die einzige Tabelle, auf der es nichts zu +binden gibt. Der Bereich traegt trotzdem zwei Dinge, die alle neun Bereiche +davor vertagt oder uebersehen haben: die seit Etappe 1 offene Entscheidung +zum gebundenen Klienten auf dem Anfrageobjekt (Aufgabe 2), und einen Befund, +den die Erwartung "null Umstellungsarbeit" verdeckt haette — drei der acht +Zugriffe zaehlen ueber eine Relationseinbindung in die GESCHUETZTE Tabelle +`User` hinein (Aufgabe 3). + +### (n1) Die Messung + +Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen zwoelften +Abschnitt (`runTenantAreaChecks`) erweitert. Sechs der neun neuen Pruefungen +(4, 5, 6, 7, 8, 9) laufen ueber den GENERIERTEN CLIENT +(`prisma.tenant.findMany`/`findUnique`/`delete`, `bound.tenant.findMany`, +`bound.user.count`) statt ueber Roh-SQL — bewusst, weil der Relationszaehler +(`include: { _count: { select: { users } } }`), den `findAll`/`findOne` +tatsaechlich benutzen, eine Client-Form ist: Roh-SQL sieht ihn strukturell +nicht (Fehler 7 des Vorhabens — "Roh-SQL ist nicht der generierte Client"). +Pruefung 1 liest zur Laufzeit jede der 34 ausgelieferten +`migration.sql`-Dateien und prueft auf `CREATE POLICY ... ON "Tenant"` sowie +`ALTER TABLE "Tenant"` — ausdruecklich EINSCHLIESSLICH +`20260910120000_rls_widen_membership_grant_and_platform_read`, die "Tenant" +nirgends nennt. Tatsaechlich beobachtete Ausgabe dieses Laufs (2026-09-11, +gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`): + +``` +tenant-keine-regel-in-allen-ausgelieferten-migrationen: bestanden — 34 Migrationsverzeichnisse gelesen, darunter "20260910120000_rls_widen_membership_grant_and_platform_read" — "Tenant" kommt darin nicht vor; 0 Verzeichnis(se) mit CREATE POLICY/ALTER TABLE auf "Tenant": [] +tenant-gebunden-und-ungebunden-liefern-dieselben-zeilen: bestanden — Roh-SQL gebunden (TENANT-A): ["TENANT-A","TENANT-B","TENANT-C"]; ungebunden: ["TENANT-A","TENANT-B","TENANT-C"]; Wartungsrolle: ["TENANT-A","TENANT-B","TENANT-C"] +tenant-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Spalten aus CREATE TABLE "Tenant" in 20260618112124_auth_multi_tenancy (6): ["createdAt","id","isActive","name","slug","updatedAt"]; Spalten der Wegwerf-Tabelle (6): ["createdAt","id","isActive","name","slug","updatedAt"] +tenant-generierter-client-zeilen-gebunden-und-ungebunden-identisch: bestanden — generierter Client gebunden (TENANT-A): ["TENANT-A","TENANT-B","TENANT-C"]; ungebunden: ["TENANT-A","TENANT-B","TENANT-C"]; Roh-SQL-Vergleichswert (Pruefung 2): ["TENANT-A","TENANT-B","TENANT-C"] +tenant-generierter-client-benutzerzaehler-ungebunden-null-fuer-jeden-mandanten: bestanden — das ist die Zahl, die admin/tenants/page.tsx als Benutzeranzahl anzeigen wuerde — ungebunden: [{"id":"TENANT-A","userCount":0},{"id":"TENANT-B","userCount":0},{"id":"TENANT-C","userCount":0}]; Wartungszahl je Mandant: {"TENANT-B":2,"TENANT-A":2} +tenant-generierter-client-benutzerzaehler-gebunden-nur-eigener-mandant: bestanden — gebunden unter TENANT-A: A=2 (Wartungszahl=2), B=0 (Wartungszahl=2), C=0 +tenant-loeschriegel-ungebunden-vakuum-fremdschluessel-faengt-laut: bestanden — ungebundener Relationszaehler ueber aktive Benutzer fuer TENANT-A=0, Wartungszahl=2 — der Riegel T-02-09 liesse das Loeschen durch; ungebundenes prisma.tenant.delete ueber den generierten Client wirft PrismaClientKnownRequestError (code P2003): Invalid `prisma.tenant.delete()` invocation: Foreign key constraint violated on the constraint: `User_tenantId_fkey` — die Zeile existiert ueber die Wartungsrolle danach noch: true; die referentielle Pruefung des Fremdschluessels umgeht den Zeilenschutz und faengt das Loeschen trotzdem ab +tenant-loeschen-ohne-benutzer-gelingt-wie-heute: bestanden — ungebundenes prisma.tenant.delete fuer TENANT-C (ohne Benutzer) ist NICHT fehlgeschlagen; ueber die Wartungsrolle danach noch vorhanden: false +tenant-fan-out-je-mandant-gebundene-zaehlung-stimmt: bestanden — Fan-out je verbliebenem Mandanten (2): [{"id":"TENANT-A","gebunden":2,"wartung":2},{"id":"TENANT-B","gebunden":2,"wartung":2}] +Alle 110 Pruefungen bestanden. +``` + +Die Belegzeile, die diesen Abschnitt traegt, ist +`tenant-generierter-client-benutzerzaehler-ungebunden-null-fuer-jeden-mandanten` +(Pruefung 5): dieselbe Abfrage, die `findAll` heute stellt, liefert +UNGEBUNDEN fuer JEDEN Mandanten `userCount: 0`, waehrend die Wartungsrolle +fuer TENANT-A und TENANT-B je 2 aktive Benutzer zaehlt — das ist exakt die +Zahl, die `admin/tenants/page.tsx` nach dem Scharfschalten anzeigen wuerde. +Der Fremdschluessel `User_tenantId_fkey` (Pruefung 7, wortgleich aus Zeile +105 von `20260618112124_auth_multi_tenancy` geschnitten) faengt das daraus +folgende Loeschen zwar ab — aber laut (SQLSTATE 23503, Prisma-Code `P2003`), +nicht mit der verstaendlichen 400-Meldung des Riegels T-02-09. + +### (n2) Signaltabelle je Pfad + +| Pfad | Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) | Konkretes Signal | Frontend laesst es durch? | +|---|---|---|---| +| `TenantController.findAll` | Der Relationszaehler (`include: { _count: { select: { users } } }`) laeuft ungebunden auf dem generierten Client (Pruefung 5): `userCount` ist 0 fuer JEDEN Mandanten, die Mandantenzeilen selbst bleiben vollstaendig (`Tenant` ohne Regel) | Die Mandantenliste zeigt jeden Mandanten mit 0 Benutzern — eine falsche Zahl, keine leere Liste | Ja — `admin/tenants/page.tsx` zeigt `tenant.userCount` ungeprueft an | +| `TenantController.findOne` | Dieselbe Form wie `findAll`, fuer eine einzelne Kennung | `userCount: 0` fuer den betrachteten Mandanten | Ja — dieselbe Anzeige (falls einzeln abgefragt) | +| `TenantController.create` | Kein Datenbankzugriff des Controllers selbst — delegiert an `TenantService.create` | Keins (Controller-Ebene) | — | +| `TenantController.update` | Kein Datenbankzugriff des Controllers selbst — delegiert an `TenantService.update`, nach ungebundenem `findById` (Tenant ohne Regel, unveraendert) | Keins | — | +| `TenantController.remove` | Der Relationszaehler ueber AKTIVE Benutzer laeuft ungebunden: `_count.users` ist 0 (Pruefung 7), der Riegel T-02-09 passiert, `tenant.delete` laeuft — und trifft den Fremdschluessel `User_tenantId_fkey` (`ON DELETE RESTRICT`): SQLSTATE 23503, Prisma-Code `P2003`, HTTP 500 mit generischer Meldung statt der verstaendlichen 400 | Ein Loeschversuch schlaegt laut fehl statt mit "Cannot delete tenant with active users" | Teilweise — `handleDelete` in `admin/tenants/page.tsx` prueft `res.ok`, tut bei nicht-OK aber NICHTS sichtbares (siehe (n3)) | +| `TenantService.findAll` | Ungebunden, `Tenant` ohne Regel — unveraendert korrekt. Hat heute KEINEN Aufrufer (Befund L) | Keins (totes Codeglied) | — | +| `TenantService.findById` | Ungebunden, `Tenant` ohne Regel — unveraendert korrekt, wird von `TenantController.update` als Existenzpruefung benutzt | Keins | — | +| `TenantService.create` | Ungebunden, `Tenant` ohne Regel; ruft danach `groupsService.ensureDefaultGroup(tenant.id)` auf — dieser Aufruf laeuft seit 260909-jts vollstaendig gebunden ueber `forTenant()`/`withTenantTransaction()`, gebunden an den soeben angelegten Mandanten | Keins — die Standardgruppen-Anlage funktioniert nach dem Scharfschalten unveraendert | — | +| `TenantService.update` | Ungebunden, `Tenant` ohne Regel — unveraendert korrekt | Keins | — | +| `TenantGuard` | Kein Datenbankzugriff (Aufgabe 2 entfernt den letzten, ungenutzten Prisma-Aufruf) | Keins | — | + +### (n3) Welcher Code eine falsche Zahl als Wahrheit deutet + +Anders als bei jedem Bereich davor ist die gefaehrliche Form hier nicht +LEERE, sondern eine FALSCHE ZAHL, die sich als Wahrheit ausgibt. + +**Backend:** `TenantController.findAll`/`findOne` liefern `userCount: 0` ohne +jedes Signal — kein Fehler, kein leeres Feld, eine plausibel aussehende Zahl, +die schlicht falsch ist. `TenantController.remove` laesst den Riegel T-02-09 +passieren (Zaehler 0, obwohl aktive Benutzer existieren) und der +Fremdschluessel antwortet laut mit der falschen Botschaft (500 statt 400, +siehe (n1)/(n2)). + +**Frontend**, namentlich mit Stelle: + +- `apps/web/src/app/(portal)/admin/tenants/page.tsx` zeigt `tenant.userCount` + ungeprueft in der Tabellenzeile an (Zeile 209: `{tenant.userCount}`). + `fetchTenants` (Zeilen 44-55) prueft zwar `res.ok`, aber bei nicht-OK + passiert NICHTS sichtbares — kein Fehlertext, keine Markierung, die Liste + bleibt leer oder veraltet stehen (`catch { // silently fail }`). + `handleDelete` (Zeilen 122-133) prueft ebenfalls `res.ok`, aber bei + nicht-OK (der 500er aus dem Fremdschluessel) passiert wieder NICHTS: der + Bestaetigungsdialog (`deleteConfirm`) bleibt offen, `fetchTenants()` wird + nicht erneut aufgerufen — fuer den Administrator sieht das aus wie ein + Knopf, der nicht reagiert, nicht wie ein Fehler. +- `apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx` + faengt jede nicht-OK-Antwort in eine LEERE Liste + (`.then((res) => (res.ok ? res.json() : []))`) und jeden Netzwerkfehler in + ein stilles Nichts (`.catch(() => {})`) — der SUPER_ADMIN sieht im + Mandanten-Wechsel-Dropdown des Marktplatzes schlicht keine Mandanten, ohne + Hinweis, dass eine Abfrage fehlgeschlagen ist statt "es gibt keine". + +Zur Ausfuehrungszeit an den genannten Dateien und Zeilen erneut zu pruefen — +Zeilennummern koennen sich verschieben. + +### (n4) Was dieser Durchlauf bewusst nicht löst + +**(a) Die Entscheidung zur Anfrageobjekt-Eigenschaft.** Gemessen (Befund B/C +der Planung, in Aufgabe 1 wiederholt): `apps/api/src/tenant/tenant.guard.ts` +und `apps/api/src/tenant/tenant.middleware.ts` setzen +`req.tenantPrisma = forTenant(this.prisma, tenantId)`, aber eine Volltextsuche +ueber `apps/api/src` (`grep -rn '\.tenantPrisma'`) findet ausserhalb dieser +beiden Dateien KEINEN Lesezugriff — nur drei Kommentare, die die Middleware +nennen. Die Middleware selbst ist NIRGENDS verdrahtet: weder `apps/api/src` +noch `apps/api/src/main.ts` enthalten ein `MiddlewareConsumer`, ein +`configure(` oder einen `.apply(...).forRoutes(...)`-Aufruf auf +`TenantMiddleware` — `app.module.ts` implementiert kein `NestModule`. +ENTSCHIEDEN (260911-e2s, Aufgabe 2): der Guard setzt nur noch +`req.tenantId`; `tenant.middleware.ts` ist GELOESCHT (eine nie aufgerufene +Kopie des Guards mit identischer Logik). GRUND: neun umgestellte Bereiche vor +diesem binden ausnahmslos dienst-intern, ein Klient je Methode +(`forTenant(this.prisma, tenantId)` in der jeweiligen Service-Methode) — die +Konvention ist durch neunfache Praxis entschieden, nicht durch diesen Plan +neu erfunden. Eine tote Verdrahtung, die wie ein Sicherheitsmechanismus +AUSSIEHT (ein gebundener Klient, scheinbar bereit zur Benutzung), ist +schlimmer als gar keine — sie suggeriert einem spaeteren Leser einen Schutz, +den es nicht gibt. + +**(b) Die Erkennungsluecke der Bestandsaufnahme (Befund G).** +`rls-access-inventory.spec.ts` sammelt (Datei, Modell)-Paare ausschliesslich +ueber `this.prisma.` und `.` — eine +Relationseinbindung (`include:`, Relationszaehler `_count`) in eine ZWEITE +Tabelle erzeugt kein Paar und ist fuer das Werkzeug unsichtbar. In Aufgabe 1 +erneut vermessen: + +*Alle `_count`-Stellen ausserhalb von `tenant/`* (`grep -rn "_count" +apps/api/src --include=*.ts | grep -v spec`): `groups.service.ts:66` (auf +bereits GEBUNDENEM Klienten — harmlos, die Bindung schuetzt bereits) und +`tenders.controller.ts:405` (`groupBy` auf der plattformweiten, ungeschuetzten +`Tender`, kein Relationszugriff in eine zweite Tabelle — harmlos). + +*Alle `include:`-Stellen* (`grep -rn "include:" apps/api/src --include=*.ts +| grep -v spec`, 19 Treffer in 8 Dateien): jede Stelle einzeln beurteilt — +aeusserer Aufruf gebunden oder nicht, aeussere Tabelle geschuetzt oder nicht, +eingebundene Tabelle geschuetzt oder nicht: + +| Datei | Aeusserer Aufruf | Aeussere Tabelle geschuetzt | Eingebundene Tabelle geschuetzt | Urteil | +|---|---|---|---|---| +| `tenant.controller.ts` (3 Stellen: `findAll`/`findOne`/`remove`, vor Aufgabe 3) | ungebunden | Nein (`Tenant`) | JA (`User`) | GEFAEHRLICH — die einzige Auspraegung, in Aufgabe 3 behoben | +| `ldap-config.service.ts:309` (`getAllActiveConfigs`) | ungebunden (bewusst uebergreifend) | JA (`LdapConfig`) | eingebunden: `tenant`, `fieldMappings` | harmlos — die AEUSSERE Tabelle ist bereits geschuetzt, der bekannte Etappe-3-Fall (Benutzerdimension) bekommt dadurch nichts Neues | +| `tenders.controller.ts:612` (`sources`) | ungebunden | Nein (`Tender`, D-03 plattformweit) | Nein (`TenderSource`, ebenfalls plattformweit) | harmlos — beide Seiten plattformweit | +| übrige 14 Stellen (`groups.service.ts`, `module-grants.service.ts`, `dashboard.service.ts`, `dkv.service.ts`, `tender-*.service.ts`, `user.service.ts`) | ueberwiegend gebunden oder auf bereits geschuetzten/plattformweiten Tabellen | — | — | harmlos, einzeln nachgesehen | + +Die gefaehrliche Auspraegung (ungebundener aeusserer Aufruf auf einer +UNGESCHUETZTEN Tabelle, Einbindung in eine GESCHUETZTE Tabelle) existierte im +gesamten API-Quelltext genau EINMAL: in diesem Bereich, vor Aufgabe 3. +ENTSCHEIDUNG gegen einen Ledger-Eintrag: die einzige Auspraegung wird in +diesem Plan behoben; die Wiederholung beider Messungen zur Ausfuehrungszeit +fand keine zweite — faende eine spaetere Wiederholung eine zweite +Auspraegung, waere DANN ein `gsd-tools windows append`-Eintrag anzulegen, mit +Verweis auf diesen Absatz. + +**(c) Der Fremdschluessel als Rueckhalt, mit einer Luecke.** +`User_tenantId_fkey` faengt auch INAKTIVE Benutzer, waehrend der Riegel +T-02-09 nur AKTIVE zaehlt — ein Mandant mit ausschliesslich inaktiven +Benutzern ist heute wie nach diesem Plan nicht loeschbar (500 statt der +verstaendlichen 400). Bestehendes Verhalten, gemessen (Pruefung 7/8 zeigen +die Mechanik, nicht diesen Spezialfall direkt), nicht Gegenstand dieses +Auftrags. + +**(d) `TenantService.findAll` ohne Aufrufer** (Befund L) — bleibt totes +Codeglied, nicht entfernt (Scope). + +**(e) Der unerreichbare `null`-Zweig des Guards** — SUPER_ADMIN ohne +`tenantId` und ohne `x-tenant-id`-Header ist mit dem heutigen +Sitzungsnachweis unerreichbar (`User.tenantId` ist `String`, nicht nullbar), +bleibt aber unveraendert und wird in Aufgabe 2 als heutiges Verhalten +getestet, nicht umgebaut. + +**(f) Der Header-Wert wird nicht gegen vorhandene Mandanten geprueft.** Ein +SUPER_ADMIN kann per `x-tenant-id` eine erfundene Kennung schicken (D-10 wie +entworfen) — sie bindet an einen leeren Kontext, null Zeilen, kein Leck. + +**(g) Die Mehrkosten des Fan-outs.** Nach Aufgabe 3 kostet `findAll` eine +gebundene Zaehlabfrage je Mandant statt eines Joins — bei einstelliger +Mandantenzahl belanglos, dieselbe Form wie +`UserService.findAllForPlatformAdmin`. + +**(h) Die Etappe-4-Vorabpruefung.** Die Benutzerzahl je Mandant ueber die +Wartungsrolle gegen die gebundene Fan-out-Zaehlung ist dieselbe Pruefung wie +im Bereich `user` — kein eigener Eintrag noetig. + +### (n5) Was dieser Durchlauf bewusst nicht anfasst + +- Der direkte Prisma-Zugriff im Controller (Muster wie `user.controller.ts`) + — bleibt, Wartbarkeitsvermerk, keine Verschiebung in den Dienst. +- Die redundante `@UseGuards(RolesGuard)`-Klassenregistrierung neben der + globalen `APP_GUARD`-Registrierung von `RolesGuard`. +- Das Frontend — in (n3) beschrieben, nicht geaendert. +- `tenant.service.ts` — unveraendert. +- Die veraltete Tabellenliste im Abschnitt `## Mandantentrennung` von + `docs/anleitung-entwicklung.md` ("aktuell nur auf User, + PasswordResetToken, ..." — seit `20260909140000` sind es 23 Tabellen); + Aufgabe 3 aendert in jener Datei NUR die Absaetze zu Guard und Middleware, + diese Liste bleibt stehen und ist hier als bekannte Ungenauigkeit + festgehalten. +- Die historische Nennung der Middleware in + `docs/mandantentrennung-datenbankrolle.md:124` — beschreibt den Stand VOR + Etappe 1 korrekt, nicht zu aendern. +- Schema und Migrationen — geprueft und bewusst gelassen, `Tenant` bekommt + KEINE Regel. + ## Verweis Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang