From 7d45e2fffd17e95034b799a60c77b64d7aea02f7 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 10 Sep 2026 11:17:07 +0200 Subject: [PATCH] test(quick-260910-exd): Fehlerrichtung fuer module-registry messen, kein Produktivcode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rls-scratch-check.mjs: achter Abschnitt runModuleRegistryAreaChecks mit 13 benannten Pruefungen gegen die echten, aus den ausgelieferten Migrationen geschnittenen Regeln (Group/GroupMembership/ModuleGrant/TenantModuleActivation), neu angelegt nur: Modulkatalog-Tabelle ohne Zeilenschutz, Eindeutigkeitsindex auf TenantModuleActivation, zwei Direkt-Freigaben, eine fremde Mitgliedschaft - Alle 66 Pruefungen bestanden (53 bisherige + 13 neue), 810 Tests gruen, Typpruefung sauber - docs/mandantentrennung-etappe2-fehlerrichtung.md: neuer Abschnitt "Bereich module-registry" mit den fuenf Unterabschnitten (m1-m5), inklusive Praezisierung aus Befund E (Katalogbindung ist HEUTE wirkungslos, nicht katastrophal — die Bedingung wird als Bedingung notiert) und der unbeschoenigten Antwort auf die Signalfrage ("keines") Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR --- apps/api/scripts/rls-scratch-check.mjs | 356 ++++++++++++++++++ ...andantentrennung-etappe2-fehlerrichtung.md | 209 ++++++++++ 2 files changed, 565 insertions(+) diff --git a/apps/api/scripts/rls-scratch-check.mjs b/apps/api/scripts/rls-scratch-check.mjs index 660b1f2..8874611 100644 --- a/apps/api/scripts/rls-scratch-check.mjs +++ b/apps/api/scripts/rls-scratch-check.mjs @@ -1710,6 +1710,361 @@ async function runUserAreaChecks(adminUrl, scratchRoleUrl, results) { } } +/** + * Liest die ausgelieferte Migration, die die Tabellen "Module" und + * "TenantModuleActivation" samt dem Eindeutigkeitsindex auf + * ("tenantId","moduleId") anlegt (Dateiname endet auf + * "_add_module_registry"). + */ +function readModuleRegistryMigrationSql() { + const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.endsWith('_add_module_registry')) + .map((entry) => entry.name); + if (dirs.length !== 1) return null; + return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8'); +} + +/** + * Liest die ausgelieferte Migration, die (unter anderem) die beiden + * partiellen Eindeutigkeitsindizes auf "ModuleGrant" anlegt (Dateiname endet + * auf "_add_groups_and_module_grants") — dieselbe Datei, aus der + * runGroupsAreaChecks() NICHT liest (jene braucht nur die RLS-Policy- + * Migration), deshalb ein eigenes, unabhaengiges Lesehilfsmittel. + */ +function readAddGroupsAndModuleGrantsMigrationSql() { + const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.endsWith('_add_groups_and_module_grants')) + .map((entry) => entry.name); + if (dirs.length !== 1) return null; + return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8'); +} + +/** + * Schneidet eine `CREATE UNIQUE INDEX "" ...;`-Anweisung + * wortgleich aus einem Migrationstext, nach demselben Muster wie + * extractPolicySql() oben. + */ +function extractIndexSql(migrationSql, indexName) { + const re = new RegExp(`CREATE UNIQUE INDEX "${indexName}"[\\s\\S]*?;`); + const match = migrationSql.match(re); + return match ? match[0] : null; +} + +/** + * Aufgabe 1 (260910-exd) — misst die dreizehn im Plan genannten + * Verhaltensweisen des Bereichs `module-registry` unter der Rolle ohne + * BYPASSRLS. Setzt auf den Tabellen "Group", "GroupMembership", + * "ModuleGrant" und "TenantModuleActivation" auf, die runGroupsAreaChecks() + * bereits angelegt und mit Policies WORTGLEICH aus den ausgelieferten + * Migrationen versehen hat — dieser Abschnitt legt sie NICHT neu an. Neu + * angelegt werden nur: die Tabelle "Module" OHNE Zeilenschutz (die zu + * messende Eigenschaft selbst), der Eindeutigkeitsindex auf + * ("tenantId","moduleId") fuer "TenantModuleActivation" (bis hierhin fehlte + * er, weil runGroupsAreaChecks() ihn nicht braucht), zwei Direkt-Freigaben + * (eine je Mandant) und die Mitgliedschaft (group-b, user-a), die + * runGroupsAreaChecks() unter gebundenem Kontext bewusst nicht anlegen + * konnte. + * + * Die bereits von runGroupsAreaChecks() eingefuegte Zeile + * `grant-foreign-group` (TENANT-A, mod-1, groupId=group-b) wird + * WIEDERVERWENDET, nicht neu erzeugt. + * + * Muss NACH runUserAreaChecks() und VOR runTransactionShapeMeasurement() + * laufen (siehe Aufrufkette in main()) — Letztere setzt weiterhin auf der + * von runGroupsAreaChecks() angelegten Tabelle "Group" auf, dieser + * Abschnitt aendert daran nichts. + */ +async function runModuleRegistryAreaChecks(adminUrl, scratchRoleUrl, results) { + const moduleRegistryMigrationSql = readModuleRegistryMigrationSql(); + const activationUniqueIndexSql = moduleRegistryMigrationSql + ? extractIndexSql( + moduleRegistryMigrationSql, + 'TenantModuleActivation_tenantId_moduleId_key', + ) + : null; + + const groupsAndGrantsMigrationSql = readAddGroupsAndModuleGrantsMigrationSql(); + const grantGroupUniqueIndexSql = groupsAndGrantsMigrationSql + ? extractIndexSql(groupsAndGrantsMigrationSql, 'ModuleGrant_tenant_module_group_unique') + : null; + const grantUserUniqueIndexSql = groupsAndGrantsMigrationSql + ? extractIndexSql(groupsAndGrantsMigrationSql, 'ModuleGrant_tenant_module_user_unique') + : null; + + if (!activationUniqueIndexSql || !grantGroupUniqueIndexSql || !grantUserUniqueIndexSql) { + report( + results, + 'module-registry-regeln-aus-migration-gefunden', + false, + 'Eindeutigkeitsindex fuer "TenantModuleActivation" und/oder die beiden partiellen Eindeutigkeitsindizes fuer "ModuleGrant" nicht in den ausgelieferten Migrationen gefunden', + ); + return; + } + + await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => { + // (a) Katalogtabelle OHNE Zeilenschutz — die zu messende Eigenschaft + // selbst (Befund E), nach dem Muster der Tabelle "Tenant" im + // Abschnitt des Bereichs `user`. + await db.$executeRawUnsafe(` + CREATE TABLE "Module" ( + id text PRIMARY KEY, + name text NOT NULL + ); + `); + await db.$executeRawUnsafe(`GRANT SELECT, INSERT, UPDATE, DELETE ON "Module" TO ${SCRATCH_ROLE_NAME}`); + await db.$executeRawUnsafe(` + INSERT INTO "Module" (id, name) VALUES ('mod-1', 'Modul Eins'), ('mod-2', 'Modul Zwei'); + `); + + // (b) Eindeutigkeitsindex auf ("tenantId","moduleId") fuer + // "TenantModuleActivation", wortgleich aus der ausgelieferten Migration + // — ohne ihn liesse sich Pruefung 12 nicht messen. + await db.$executeRawUnsafe(activationUniqueIndexSql); + + // Zusaetzliche Aktivierungszeile fuer Pruefung 12: (TENANT-B, mod-2), + // unter TENANT-A unsichtbar, aber physisch vorhanden. + await db.$executeRawUnsafe(` + INSERT INTO "TenantModuleActivation" (id, "tenantId", "moduleId", "isActive") VALUES + ('activation-b2', 'TENANT-B', 'mod-2', true); + `); + + // (c) je eine Direkt-Freigabezeile pro Mandant (userId, kein groupId). + await db.$executeRawUnsafe(` + INSERT INTO "ModuleGrant" (id, "tenantId", "moduleId", "groupId", "userId") VALUES + ('grant-direct-a', 'TENANT-A', 'mod-1', NULL, 'user-a'), + ('grant-direct-b', 'TENANT-B', 'mod-1', NULL, 'user-b'); + `); + + // (d) die Mitgliedschaft (group-b, user-a), die runGroupsAreaChecks() + // unter gebundenem Kontext bewusst NICHT anlegen konnte (die Regel wies + // sie ab) — hier ueber die Verwaltungsrolle gesetzt, weil Pruefung 6 + // sonst aus dem falschen Grund bestuende. + await db.$executeRawUnsafe(` + INSERT INTO "GroupMembership" (id, "groupId", "userId", source) VALUES + ('membership-cross', 'group-b', 'user-a', 'MANUAL'); + `); + }); + + const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); + try { + // 1: modulegrant-ungebunden-null-zeilen — die Belegzeile dieses + // Abschnitts. + const actualGrantCount = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => { + const [row] = await db.$queryRaw`SELECT count(*)::int AS n FROM "ModuleGrant"`; + return row.n; + }, + ); + const unboundGrantRows = await prisma.$queryRaw`SELECT "tenantId" FROM "ModuleGrant"`; + report( + results, + 'modulegrant-ungebunden-null-zeilen', + unboundGrantRows.length === 0, + `ungebundener SELECT auf "ModuleGrant" liefert ${unboundGrantRows.length} Zeile(n), tatsaechlich vorhanden sind ${actualGrantCount}`, + ); + + // 2: tenantmoduleactivation-ungebunden-null-zeilen — dasselbe fuer die + // Aktivierungstabelle, die der Kurzschluss fuer ADMIN/SUPER_ADMIN als + // EINZIGE liest. + const actualActivationCount = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => { + const [row] = await db.$queryRaw`SELECT count(*)::int AS n FROM "TenantModuleActivation"`; + return row.n; + }, + ); + const unboundActivationRows = + await prisma.$queryRaw`SELECT "tenantId" FROM "TenantModuleActivation"`; + report( + results, + 'tenantmoduleactivation-ungebunden-null-zeilen', + unboundActivationRows.length === 0, + `ungebundener SELECT auf "TenantModuleActivation" liefert ${unboundActivationRows.length} Zeile(n), tatsaechlich vorhanden sind ${actualActivationCount}`, + ); + + // 3: admin-kurzschluss-gebunden-liefert-nur-eigene-aktivierungen + const activeActivationsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw`SELECT "tenantId" FROM "TenantModuleActivation" WHERE "isActive" = true ORDER BY id`, + ); + report( + results, + 'admin-kurzschluss-gebunden-liefert-nur-eigene-aktivierungen', + activeActivationsForA.length === 1 && activeActivationsForA[0].tenantId === 'TENANT-A', + `forTenant(TENANT-A) liefert ${activeActivationsForA.length} aktive Aktivierung(en): ${JSON.stringify(activeActivationsForA.map((r) => r.tenantId))}`, + ); + + // 4: direktfreigabe-gebunden-nur-eigene-zeile + const directGrantsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw`SELECT id FROM "ModuleGrant" WHERE "userId" = 'user-a' ORDER BY id`, + ); + report( + results, + 'direktfreigabe-gebunden-nur-eigene-zeile', + directGrantsForA.length === 1 && directGrantsForA[0].id === 'grant-direct-a', + `forTenant(TENANT-A) liefert fuer die Direkt-Freigabe-Abfrage (userId=user-a) ${directGrantsForA.length} Zeile(n): ${JSON.stringify(directGrantsForA.map((r) => r.id))}`, + ); + + // Der Drei-Tabellen-Weg (ModuleGrant ueber Group ueber GroupMembership), + // Grundlage fuer Pruefung 5 und 6 — EINE Abfrage, zwei Aussagen. + const groupPathForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw` + SELECT mg.id FROM "ModuleGrant" mg + JOIN "Group" g ON g.id = mg."groupId" + JOIN "GroupMembership" gm ON gm."groupId" = g.id + WHERE mg."tenantId" = current_tenant_id() AND gm."userId" = 'user-a' + ORDER BY mg.id + `, + ); + + // 5: gruppenpfad-gebunden-folgt-der-gruppenregel + report( + results, + 'gruppenpfad-gebunden-folgt-der-gruppenregel', + groupPathForA.length === 1 && groupPathForA[0].id === 'grant-a', + `forTenant(TENANT-A) liefert ueber den Drei-Tabellen-Weg (Freigabe ueber Gruppe ueber Mitgliedschaft) fuer user-a ${groupPathForA.length} Zeile(n): ${JSON.stringify(groupPathForA.map((r) => r.id))}`, + ); + + // 6: gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus + const excludesForeignGroup = !groupPathForA.some((r) => r.id === 'grant-foreign-group'); + report( + results, + 'gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus', + excludesForeignGroup, + `forTenant(TENANT-A) liefert 'grant-foreign-group' ueber denselben Drei-Tabellen-Weg NICHT (Ergebnis: ${JSON.stringify(groupPathForA.map((r) => r.id))}), obwohl die Regel auf "ModuleGrant" diese Zeile nachweislich durchlaesst (T-JTS-03) und die Mitgliedschaft (group-b, user-a) vorhanden ist — diese Verteidigung greift erst nach dem Scharfschalten`, + ); + + // 7: gruppenpfad-ueber-die-wartungsrolle-liefert-die-fremde-gruppe-mit — + // die Gegenmessung ueber die Verwaltungsrolle mit BYPASSRLS. + const groupPathViaAdmin = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => db.$queryRaw` + SELECT mg.id FROM "ModuleGrant" mg + JOIN "Group" g ON g.id = mg."groupId" + JOIN "GroupMembership" gm ON gm."groupId" = g.id + WHERE mg."tenantId" = 'TENANT-A' AND gm."userId" = 'user-a' + ORDER BY mg.id + `, + ); + const adminIncludesForeignGroup = groupPathViaAdmin.some((r) => r.id === 'grant-foreign-group'); + report( + results, + 'gruppenpfad-ueber-die-wartungsrolle-liefert-die-fremde-gruppe-mit', + adminIncludesForeignGroup, + `dieselbe Abfrage ueber die Verwaltungsrolle (BYPASSRLS) liefert ${JSON.stringify(groupPathViaAdmin.map((r) => r.id))} — der Ausschluss aus Pruefung 6 kommt damit nachweislich von der Bindung, nicht vom Aufbau`, + ); + + // 8: module-tabelle-traegt-keinen-zeilenschutz + const unboundModuleRows = await prisma.$queryRaw`SELECT id FROM "Module" ORDER BY id`; + const [moduleRlsRow] = + await prisma.$queryRaw`SELECT relrowsecurity FROM pg_class WHERE relname = 'Module'`; + const moduleHasNoRls = + unboundModuleRows.length === 2 && moduleRlsRow?.relrowsecurity === false; + report( + results, + 'module-tabelle-traegt-keinen-zeilenschutz', + moduleHasNoRls, + `ungebundenes SELECT auf "Module" liefert ${unboundModuleRows.length} Zeile(n): ${JSON.stringify(unboundModuleRows.map((r) => r.id))}; pg_class.relrowsecurity fuer "Module" = ${JSON.stringify(moduleRlsRow?.relrowsecurity)}`, + ); + + // 9: katalog-bindung-aendert-heute-nichts-an-der-ergebnismenge + const boundModuleRows = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw`SELECT id FROM "Module" ORDER BY id`, + ); + const catalogUnaffectedByBinding = + boundModuleRows.length === unboundModuleRows.length && + boundModuleRows.every((r, i) => r.id === unboundModuleRows[i].id); + report( + results, + 'katalog-bindung-aendert-heute-nichts-an-der-ergebnismenge', + catalogUnaffectedByBinding, + `forTenant(TENANT-A) liefert ${JSON.stringify(boundModuleRows.map((r) => r.id))}, ungebunden liefert ${JSON.stringify(unboundModuleRows.map((r) => r.id))} — identisch, weil "Module" keine Regel traegt (Befund E: die Nichtbindung des Katalogs ist heute keine Rettung vor Unsichtbarkeit, sondern eine Frage der Wahrhaftigkeit der Aufzeichnung; sie wird erst zur Rettung, WENN Etappe 3 dieser Tabelle eine Regel gibt)`, + ); + + // 10: gebundener-join-auf-den-katalog-liefert-den-modulnamen + const activationWithModuleName = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw` + SELECT tma."moduleId", m.name FROM "TenantModuleActivation" tma + JOIN "Module" m ON m.id = tma."moduleId" + WHERE tma."isActive" = true + ORDER BY tma.id + `, + ); + report( + results, + 'gebundener-join-auf-den-katalog-liefert-den-modulnamen', + activationWithModuleName.length === 1 && + activationWithModuleName[0].moduleId === 'mod-1' && + activationWithModuleName[0].name === 'Modul Eins', + `forTenant(TENANT-A) liefert fuer den Verbund aus Aktivierung und Katalog: ${JSON.stringify(activationWithModuleName)}`, + ); + + // 11: aktivierung-gebundenes-einfuegen-fremder-mandant-abgelehnt + let foreignActivationInsertRejected = false; + let foreignActivationInsertDetail = ''; + try { + await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => + tx.$executeRaw`INSERT INTO "TenantModuleActivation" (id, "tenantId", "moduleId", "isActive") VALUES ('activation-rejected-foreign', 'TENANT-B', 'mod-2', true)`, + ); + foreignActivationInsertDetail = + 'gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B ist NICHT fehlgeschlagen'; + } catch (err) { + const sqlState = sqlStateOf(err); + foreignActivationInsertRejected = sqlState === '42501'; + foreignActivationInsertDetail = `gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE ${sqlState ?? 'unbekannt'} (${err.message.trim()})`; + } + report( + results, + 'aktivierung-gebundenes-einfuegen-fremder-mandant-abgelehnt', + foreignActivationInsertRejected, + foreignActivationInsertDetail, + ); + + // 12: aktivierung-eindeutigkeit-traegt-den-mandanten-keine-unsichtbare-kollision + let ownActivationInsertSucceeded = false; + let ownActivationInsertDetail = ''; + try { + await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => + tx.$executeRaw`INSERT INTO "TenantModuleActivation" (id, "tenantId", "moduleId", "isActive") VALUES ('activation-a2', 'TENANT-A', 'mod-2', true)`, + ); + ownActivationInsertSucceeded = true; + ownActivationInsertDetail = + 'gebundenes INSERT von (TENANT-A, mod-2) ist GELUNGEN, obwohl (TENANT-B, mod-2) bereits existiert und unter TENANT-A unsichtbar ist — der Eindeutigkeitsindex fuehrt mit der Mandantenkennung, genau die Entlastung, die die Bereiche `tenders` und `user` NICHT hatten (dort: unsichtbare Zeile, falsches "frei", harter Eindeutigkeitsfehler)'; + } catch (err) { + ownActivationInsertDetail = `gebundenes INSERT von (TENANT-A, mod-2) unerwartet abgewiesen: ${err.message.trim()}`; + } + report( + results, + 'aktivierung-eindeutigkeit-traegt-den-mandanten-keine-unsichtbare-kollision', + ownActivationInsertSucceeded, + ownActivationInsertDetail, + ); + + // 13: freigabe-eindeutigkeitsindizes-fuehren-mit-der-mandantenkennung — + // Textmessung statt Datenbankmessung. + const groupIndexLeadsWithTenant = /ON "ModuleGrant"\("tenantId",\s*"moduleId",\s*"groupId"\)/.test( + grantGroupUniqueIndexSql, + ); + const userIndexLeadsWithTenant = /ON "ModuleGrant"\("tenantId",\s*"moduleId",\s*"userId"\)/.test( + grantUserUniqueIndexSql, + ); + report( + results, + 'freigabe-eindeutigkeitsindizes-fuehren-mit-der-mandantenkennung', + groupIndexLeadsWithTenant && userIndexLeadsWithTenant, + `aus 20260804130130_add_groups_and_module_grants extrahiert: ${JSON.stringify(grantGroupUniqueIndexSql)} und ${JSON.stringify(grantUserUniqueIndexSql)} — beide partiellen Eindeutigkeitsindizes auf "ModuleGrant" fuehren mit der Mandantenkennung, dieselbe Entlastung wie Pruefung 12, hier fuer die Freigabetabelle`, + ); + } finally { + await prisma.$disconnect(); + } +} + /** * Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen * den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte @@ -1943,6 +2298,7 @@ async function main() { await runTendersAreaChecks(adminUrl, scratchRoleUrlString, results); await runDkvAreaChecks(adminUrl, scratchRoleUrlString, results); await runUserAreaChecks(adminUrl, scratchRoleUrlString, results); + await runModuleRegistryAreaChecks(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 06c1af7..c836a48 100644 --- a/docs/mandantentrennung-etappe2-fehlerrichtung.md +++ b/docs/mandantentrennung-etappe2-fehlerrichtung.md @@ -1248,6 +1248,215 @@ auf den ungebundenen Basisclient zurückgebaut — genau properties of undefined (reading 'update')"; der Rückbau wurde zurückgenommen, derselbe Testlauf danach wieder grün. +## Bereich module-registry + +Dieser Abschnitt erweitert die Kritikschrift um den Bereich `module-registry` +(Quick-Task 260910-exd) und beschreibt ihn zum Zeitpunkt seiner Umstellung. +Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt +beantwortet sie für den Bereich, der bei JEDER Modulanfrage entscheidet, wer +was benutzen darf: die Aktivierungsstufe (`TenantModuleActivation`) und die +Freigabestufe (`ModuleGrant`). Bleibt hier eine Abfrage ungebunden, sieht das +nach dem Scharfschalten nicht wie ein Fehler aus, sondern wie ein +Rechteentzug — die sichtbarste Ausprägung der umgekehrten Fehlerrichtung im +gesamten Vorhaben und zugleich die am wenigsten meldungswahrscheinliche. + +### (m1) Die Messung + +Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen achten +Abschnitt (`runModuleRegistryAreaChecks`) erweitert. Er setzt auf den vier +Tabellen auf, die `runGroupsAreaChecks` bereits mit Policies WORTGLEICH aus +den ausgelieferten Migrationen anlegt (`Group`, `GroupMembership`, +`ModuleGrant`, `TenantModuleActivation`) und legt selbst nur hinzu: die +Tabelle `"Module"` OHNE Zeilenschutz (die zu messende Eigenschaft selbst), +den Eindeutigkeitsindex auf `("tenantId","moduleId")` für +`"TenantModuleActivation"` (wortgleich aus `20260619103242_add_module_registry` +geschnitten), zwei Direkt-Freigabezeilen und die Mitgliedschaft (group-b, +user-a), die `runGroupsAreaChecks` unter gebundenem Kontext bewusst nicht +anlegen konnte. Die Zeile `grant-foreign-group` aus dem Abschnitt `groups` +wird wiederverwendet. Tatsächlich beobachtete Ausgabe dieses Laufs +(2026-09-10, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`): + +``` +modulegrant-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "ModuleGrant" liefert 0 Zeile(n), tatsaechlich vorhanden sind 5 +tenantmoduleactivation-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "TenantModuleActivation" liefert 0 Zeile(n), tatsaechlich vorhanden sind 3 +admin-kurzschluss-gebunden-liefert-nur-eigene-aktivierungen: bestanden — forTenant(TENANT-A) liefert 1 aktive Aktivierung(en): ["TENANT-A"] +direktfreigabe-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert fuer die Direkt-Freigabe-Abfrage (userId=user-a) 1 Zeile(n): ["grant-direct-a"] +gruppenpfad-gebunden-folgt-der-gruppenregel: bestanden — forTenant(TENANT-A) liefert ueber den Drei-Tabellen-Weg (Freigabe ueber Gruppe ueber Mitgliedschaft) fuer user-a 1 Zeile(n): ["grant-a"] +gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus: bestanden — forTenant(TENANT-A) liefert 'grant-foreign-group' ueber denselben Drei-Tabellen-Weg NICHT (Ergebnis: ["grant-a"]), obwohl die Regel auf "ModuleGrant" diese Zeile nachweislich durchlaesst (T-JTS-03) und die Mitgliedschaft (group-b, user-a) vorhanden ist — diese Verteidigung greift erst nach dem Scharfschalten +gruppenpfad-ueber-die-wartungsrolle-liefert-die-fremde-gruppe-mit: bestanden — dieselbe Abfrage ueber die Verwaltungsrolle (BYPASSRLS) liefert ["grant-a","grant-foreign-group"] — der Ausschluss aus Pruefung 6 kommt damit nachweislich von der Bindung, nicht vom Aufbau +module-tabelle-traegt-keinen-zeilenschutz: bestanden — ungebundenes SELECT auf "Module" liefert 2 Zeile(n): ["mod-1","mod-2"]; pg_class.relrowsecurity fuer "Module" = false +katalog-bindung-aendert-heute-nichts-an-der-ergebnismenge: bestanden — forTenant(TENANT-A) liefert ["mod-1","mod-2"], ungebunden liefert ["mod-1","mod-2"] — identisch, weil "Module" keine Regel traegt (Befund E: die Nichtbindung des Katalogs ist heute keine Rettung vor Unsichtbarkeit, sondern eine Frage der Wahrhaftigkeit der Aufzeichnung; sie wird erst zur Rettung, WENN Etappe 3 dieser Tabelle eine Regel gibt) +gebundener-join-auf-den-katalog-liefert-den-modulnamen: bestanden — forTenant(TENANT-A) liefert fuer den Verbund aus Aktivierung und Katalog: [{"moduleId":"mod-1","name":"Modul Eins"}] +aktivierung-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE 42501 (ERROR: new row violates row-level security policy for table "TenantModuleActivation") +aktivierung-eindeutigkeit-traegt-den-mandanten-keine-unsichtbare-kollision: bestanden — gebundenes INSERT von (TENANT-A, mod-2) ist GELUNGEN, obwohl (TENANT-B, mod-2) bereits existiert und unter TENANT-A unsichtbar ist — der Eindeutigkeitsindex fuehrt mit der Mandantenkennung, genau die Entlastung, die die Bereiche `tenders` und `user` NICHT hatten (dort: unsichtbare Zeile, falsches "frei", harter Eindeutigkeitsfehler) +freigabe-eindeutigkeitsindizes-fuehren-mit-der-mandantenkennung: bestanden — aus 20260804130130_add_groups_and_module_grants extrahiert: beide partiellen Eindeutigkeitsindizes auf "ModuleGrant" ("tenantId","moduleId","groupId") und ("tenantId","moduleId","userId") fuehren mit der Mandantenkennung, dieselbe Entlastung wie Pruefung 12, hier fuer die Freigabetabelle +Alle 66 Pruefungen bestanden. +``` + +Die Belegzeile, die diesen Abschnitt trägt, ist +`modulegrant-ungebunden-null-zeilen`: der IDENTISCHE `SELECT "tenantId" FROM +"ModuleGrant"` ohne vorheriges `set_config` liefert **0 Zeilen**, nicht etwa +die 5 tatsächlich vorhandenen — an der echten, ausgelieferten Policy +gemessen. `tenantmoduleactivation-ungebunden-null-zeilen` misst dieselbe +Unsichtbarkeit für die Tabelle, die der Rollen-Kurzschluss für +ADMIN/SUPER_ADMIN als EINZIGE liest: auch hier fällt der +Verwaltungszugriff nach dem Scharfschalten aus. + +**TEIL 2, Beleg statt Behauptung für Befund B** — keine Transaktion in +diesem Bereich: + +``` +$ grep -rn '\$transaction(' apps/api/src/module-registry --include=*.ts | grep -v spec +$ echo $? +1 +``` + +Null Treffer, Rückgabewert 1. Der im Kopf von `prisma-tenant.extension.ts` +verlangte erneute Test ist damit für diesen Bereich beantwortet: kein neuer +Transaktionsfall, `withTenantTransaction()` wird hier nicht gebraucht und in +Aufgabe 2/3 nicht eingeführt. + +**TEIL 3, Beleg statt Behauptung für Befund G** — die Aufrufermessung für +`isModuleActive` und `findActiveForTenant`: + +``` +$ grep -rn "isModuleActive" apps/api/src apps/web/src packages +apps/api/src/module-registry/module-registry.service.ts:133: async isModuleActive(tenantId: string, moduleSlug: string): Promise { + +$ grep -rn "findActiveForTenant" apps/api/src apps/web/src packages +apps/api/src/module-registry/module-registry.service.ts:35: async findActiveForTenant(tenantId: string) { +apps/api/src/module-registry/module-registry.controller.ts:51: * uses, not just tenant-wide activation. findActiveForTenant on +``` + +`isModuleActive` hat genau EINEN Treffer, die Definition selbst — ihr +Kopfkommentar behauptet "Used by ModuleGuard to gate access to +module-specific endpoints"; der Wächter ruft sie nachweislich nie auf +(`module.guard.ts` hält keinen eigenen Datenbankzugriff, siehe Befund D). +`findActiveForTenant` hat zwei Treffer, die Definition und eine +Prosa-Erwähnung im Kopfkommentar des Controllers ("stays unchanged for Plan +15-03's marketplace catalog") — auch sie hat keinen echten Aufrufer, der +Marktplatz-Katalog wird nachweislich von `getCatalogFlags` bedient. Beide +Kommentare werden in Aufgabe 3 richtiggestellt, mit Bezug auf diese Messung. + +### (m2) Signaltabelle je umgestelltem Pfad + +| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort | +|---|---|---| +| `ModuleAccessService.getAccessibleModuleIds`, Rollen-Kurzschluss (ADMIN/SUPER_ADMIN) | Der gebundene Aktivierungs-Lesezugriff liefert 0 Zeilen statt der mandantenweit aktiven Module | Sidebar und Marktplatz zeigen für JEDEN Administrator des Mandanten keine Module mehr; jede Modulroute antwortet mit 403 | +| `ModuleAccessService.getAccessibleModuleIds`, Direktweg (USER) | Der gebundene Freigabe-Lesezugriff über `userId` liefert 0 Zeilen statt der eigenen Direkt-Freigaben | Der Benutzer verliert genau die Module, die ihm direkt zugewiesen waren — ununterscheidbar von einem echten Entzug | +| `ModuleAccessService.getAccessibleModuleIds`, Gruppenweg (USER) | Der gebundene, verschachtelte Freigabe-Lesezugriff über die Gruppenmitgliedschaft liefert 0 Zeilen statt der Gruppen-Freigaben | Der Benutzer verliert alle über Gruppen geerbten Module, während seine Direkt-Freigaben unberührt bleiben — ein teilweiser, schwer zu erklärender Verlust | +| `ModuleAccessService.getAccessibleModuleIds`, Schnittmengenabfrage | Die gebundene Aktivierungsabfrage über `moduleId: { in: grantedIds }` liefert 0 Zeilen, obwohl Freigaben vorliegen | Ein Benutzer mit vorhandenen Freigaben sieht trotzdem kein Modul — von der leeren Vorgabemenge (kein Freigabe) nicht zu unterscheiden | +| `ModuleAccessService.findAccessibleModules` | Der Rückgabepunkt bei leerer `accessibleIds`-Menge liefert `[]`, ohne den Katalog überhaupt anzufragen | `GET /modules/active` liefert eine leere Liste; die Sidebar ist leer | +| `ModuleAccessService.getCatalogFlags` | Der gebundene Aktivierungs-Lesezugriff liefert 0 Zeilen statt der mandantenweit aktiven Module | Der Marktplatz zeigt für JEDES Modul `isActiveForTenant: false` UND `hasAccess: false` — eine ANDERE Unwahrheit als der Sperrhinweis: "nicht aktiviert" statt "nicht freigegeben" | +| `ModuleRegistryService.findActiveForTenant` (heute ohne Aufrufer) | Der gebundene Aktivierungs-Lesezugriff liefert 0 Zeilen | Betrifft heute keinen erreichbaren Pfad — die Falle liegt in einer KÜNFTIGEN Verdrahtung, siehe TEIL 3 | +| `ModuleRegistryService.activateForTenant` | Der gebundene Schreibzugriff schlägt fehl bzw. die Existenzprüfung des Katalogs (ungebunden) liefert `null` | `POST /modules/:id/activate` liefert `404 Module with id '...' not found`, obwohl das Modul existiert | +| `ModuleRegistryService.deactivateForTenant` | Der gebundene Lesezugriff auf die Aktivierung liefert `null` statt der vorhandenen Zeile | `POST /modules/:id/deactivate` liefert `404 Module '...' is not activated for this tenant` — LAUT, siehe (m3) | +| `ModuleRegistryService.isModuleActive` (heute ohne Aufrufer) | Der gebundene Aktivierungs-Lesezugriff liefert `null`, die Methode gibt `false` zurück | Betrifft heute keinen erreichbaren Pfad — die stille Falle für eine KÜNFTIGE Verdrahtung, siehe (m3) | +| `ModuleRegistryService.findAll`/`findBySlug`/`seedModule` (bewusst UNGEBUNDEN, Modulkatalog) | Betrifft nicht diese Pfade selbst — sie binden nicht und liefern deshalb weiterhin korrekt. Das Risiko liegt in einer KÜNFTIGEN Bindung (Etappe 3, sobald `Module` eine Regel bekommt) | Würde man sie binden: der gesamte Modulkatalog verschwände für JEDEN Mandanten — hier ausdrücklich als Grenze festgehalten, nicht als heute beobachtbares Verhalten | +| `ModuleAccessService`, Katalogzugriffe in `findAccessibleModules`/`getCatalogFlags` (bewusst UNGEBUNDEN) | Dieselbe Grenze wie oben, hier für die beiden Katalog-Lesezugriffe des Nachbardienstes | Dieselbe Folge: eine künftige Bindung würde den Katalog mandantenweit unsichtbar machen | + +### (m3) Welcher Code Leere als Abwesenheit deutet + +Nach Wirkung sortiert: + +1. **`ModuleAccessService.getAccessibleModuleIds`, `grantedIds.length === 0` + → `return new Set()`** — der Rückgabepunkt bei leerer Freigabemenge. Der + Vorgabezustand ist geschlossen (D-04/T-EXD-04); eine zu leer gebliebene + Freigabeabfrage sieht identisch aus wie ein Benutzer ohne jede Freigabe. +2. **`ModuleAccessService.getAccessibleModuleIds`, Rollen-Kurzschluss** — + `if (role === 'ADMIN' || role === 'SUPER_ADMIN')` liest ausschließlich + `tenantModuleActivation`; eine leere Aktivierungsliste liefert ein leeres + Set, ohne die Freigabestufe überhaupt zu befragen. Betrifft damit JEDEN + Administrator des Mandanten gleichzeitig. +3. **`ModuleAccessService.findAccessibleModules`, `accessibleIds.size === 0` + → `return []`** — der Rückgabepunkt bei leerer Modulliste, bedient + `GET /modules/active` und damit die Sidebar. +4. **`ModuleAccessService.getCatalogFlags`** — eine leere Aktivierungsliste + lässt die zurückgegebene Map leer; der Controller + (`module-registry.controller.ts`, `findCatalog`) mappt einen fehlenden + Eintrag auf BEIDE Flags `false`. Das erzeugt eine ANDERE Unwahrheit als + der Sperrhinweis der Freigabestufe: "nicht aktiviert" statt "nicht + freigegeben" — der Marktplatz zeigt dem Benutzer den falschen Grund für + die Sperre. +5. **`ModuleGuard.canActivate`, die 403-Stelle** — + `if (!accessibleModuleIds.has(module.id)) throw new ForbiddenException(...)`. + Dieselbe Meldung für "wirklich keine Freigabe" und "die Aufösung hat + nichts gefunden" — siehe die Leitfrage dieses Abschnitts unten. +6. **`ModuleRegistryService.isModuleActive`, Vorgabewert `false`** — heute + ohne Aufrufer (TEIL 3), aber die stille Falle für morgen: `return + activation?.isActive === true` liefert bei `null` (leerer, gebundener + Lesezugriff) exakt dasselbe `false` wie eine echte, mandantenweite + Deaktivierung. Wird dieser Pfad morgen verdrahtet, ist er von Fall an + nicht mehr vom Rest dieses Abschnitts zu unterscheiden. + +**Gegenrichtung, damit dieser Abschnitt nicht nur aus Alarm besteht:** +`ModuleRegistryService.deactivateForTenant` wirft bei leerer +Aktivierungsabfrage (`if (!activation) throw new NotFoundException(...)`) +LAUT — die Ausnahme meldet sich sofort und verständlich, statt ein stilles +`false` zu liefern. Dieselbe laute Richtung gilt für `activateForTenant` bei +unbekannter `moduleId`. + +**Die Frage, die dieser Bereich vor allen anderen beantworten muss: welches +Signal unterscheidet "wirklich keine Freigabe" von "die Abfrage hat nichts +gefunden"?** Die Antwort lautet **keines** — schlicht, ohne Beschönigung. +Drei Stellen sehen heute identisch aus, ob der Benutzer tatsächlich keine +Freigabe hat oder ob eine gebundene Abfrage nach dem Scharfschalten leer +lief: dieselbe `ForbiddenException`-Meldung im Wächter +("Module '...' is not accessible for this user"), dieselbe leere Modulliste +mit Status 200 (`GET /modules/active`), kein einziger Protokolleintrag. Der +Zusatz, der diesen Bereich von allen vorherigen unterscheidet: der +Betroffene hat eine fertige, FALSCHE Erklärung zur Hand ("mein Administrator +hat mir das entzogen") und meldet deshalb keinen Fehler — anders als etwa +bei `LdapConfigScheduler`, wo niemand eine plausible Alltagserklärung für +ausbleibende Synchronisation hat. + +Die eine Asymmetrie, die sich zu einem Signal machen LIESSE, wird hier +benannt und an Etappe 4 übergeben, nicht gelöst: nach dem Scharfschalten ist +ein zu kleines Ergebnis in diesem Bereich TOTAL und nicht selektiv — JEDER +Benutzer JEDES Mandanten verliert gleichzeitig jedes Modul, während die +Aktivierungs- und Freigabetabellen weiterhin Zeilen halten. Die +Vorabprüfung von Etappe 4 (`rls-preflight.mjs`) kann genau das feststellen: +aktive Aktivierungszeilen vorhanden, aber die Auflösung liefert für einen +bekannten Administrator eine leere Menge. Eine Laufzeitwarnung an den oben +genannten Stellen wird ERWOGEN und VERWORFEN, mit derselben Begründung wie +bei `getAllActiveConfigs` im Bereich `ldap` und den fünf Stellen im Bereich +`tenders`: eine leere Menge ist für einen Benutzer ohne Freigabe und auf +einer frischen Installation der Normalzustand, eine Warnung wäre Dauerlärm +und verlöre ihr Signal. + +### (m4) Was dieser Durchlauf bewusst nicht löst + +- **T-JTS-02/T-JTS-03 bleiben aufgezeichnet und ungefixt.** Gemessen in + Aufgabe 1 (Prüfungen 6/7): die Regel auf `ModuleGrant` prüft nur die + Mandantenkennung der Zeile selbst, nicht die referenzierte Gruppe; die + Regel auf `GroupMembership` prüft nur die Gruppenseite. Die Bindung fügt + auf der LESESEITE eine zweite Verteidigung hinzu (der Drei-Tabellen-Weg + schließt die fremde Gruppe gebunden aus), aber erst nach Etappe 4 — die + Schreibseiten-Gegenprüfung in `module-grants.service.ts` + (`assertTargetBelongsToTenant`) bleibt deshalb der heutige Schutz und wird + durch diesen Durchlauf NICHT ersetzt. +- **Die Regel für den Modulkatalog gehört zu Etappe 3.** Gemessen in + Aufgabe 1 (Prüfung 8/9, Befund E): `"Module"` trägt heute keinen + Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal. + Katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt + — dann verschwände der gesamte Katalog für jeden Mandanten. Diese + Bedingung steht hier als Bedingung, nicht als heute beobachtbare Tatsache. +- **Die offene Architekturfrage `req.tenantPrisma`** — auch dieser Bereich + entscheidet sie nicht. Er bindet dienst-intern, wie `ldap`, `groups`, + `tenders`, `dkv` und `user` es vormachen. + +### (m5) Was dieser Durchlauf bewusst NICHT anfasst + +- `module.guard.ts` — geprüft (Befund D: hält keinen eigenen + Datenbankzugriff, seine Richtigkeit ist vollständig eine Funktion dessen, + was `ModuleAccessService` zurückgibt) und bewusst gelassen, keine Bindung + nötig. +- `module-registry.controller.ts` — geprüft (hält ebenfalls keinen eigenen + Datenbankzugriff) und bewusst gelassen. +- Das Frontend — geprüft und bewusst gelassen, keine Datei dieses Plans. +- Schema und Migrationen — geprüft und bewusst gelassen, keine + Schemaänderung in dieser Etappe. + ## Verweis Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang