diff --git a/apps/api/scripts/rls-scratch-check.mjs b/apps/api/scripts/rls-scratch-check.mjs index 2b36c03..71947be 100644 --- a/apps/api/scripts/rls-scratch-check.mjs +++ b/apps/api/scripts/rls-scratch-check.mjs @@ -535,6 +535,406 @@ async function runLdapAreaChecks(adminUrl, scratchRoleUrl, results) { } } +/** + * Liest die ausgelieferte Migration, die die drei Policies fuer "Group", + * "GroupMembership" und "ModuleGrant" enthaelt. Der Dateiname endet auf + * "_groups_rls_policies" — readRlsPoliciesMigrationSql() oben schliesst + * diese Migration ausdruecklich AUS, deshalb ein eigenes, unabhaengiges + * Lesehilfsmittel statt einer Aenderung am bestehenden (Aufgabe 1, + * 260909-jts-PLAN.md). + */ +function readGroupsRlsPoliciesMigrationSql() { + const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.endsWith('_groups_rls_policies')) + .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 Policy fuer + * "TenantModuleActivation" enthaelt (Dateiname endet auf + * "_rls_remaining_tenant_tables"). + */ +function readRemainingTenantTablesMigrationSql() { + const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.endsWith('_rls_remaining_tenant_tables')) + .map((entry) => entry.name); + if (dirs.length !== 1) return null; + return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8'); +} + +/** + * Aufgabe 1 (260909-jts), TEIL 1 — misst die im Plan genannten + * Verhaltensweisen des Bereichs groups unter der Rolle ohne BYPASSRLS, mit + * den vier Policies WORTGLEICH aus den beiden ausgelieferten Migrationen + * (nicht im Werkzeug nachgetippt, vgl. runLdapAreaChecks). Findet die + * Extraktion eine der vier nicht, meldet dieser Abschnitt eine + * FEHLGESCHLAGENE Pruefung und bricht ab, statt mit einer geratenen Policy + * weiterzumessen. + * + * Legt die Tabelle "Group" (samt je einer Zeile fuer TENANT-A und + * TENANT-B) an, auf der runTransactionShapeMeasurement() weiter unten + * aufsetzt — diese Funktion muss deshalb VOR jener aufgerufen werden. + */ +async function runGroupsAreaChecks(adminUrl, scratchRoleUrl, results) { + const groupsMigrationSql = readGroupsRlsPoliciesMigrationSql(); + const remainingMigrationSql = readRemainingTenantTablesMigrationSql(); + + const groupPolicy = groupsMigrationSql ? extractPolicySql(groupsMigrationSql, 'Group') : null; + const groupMembershipPolicy = groupsMigrationSql + ? extractPolicySql(groupsMigrationSql, 'GroupMembership') + : null; + const moduleGrantPolicy = groupsMigrationSql + ? extractPolicySql(groupsMigrationSql, 'ModuleGrant') + : null; + const tenantModuleActivationPolicy = remainingMigrationSql + ? extractPolicySql(remainingMigrationSql, 'TenantModuleActivation') + : null; + + if ( + !groupPolicy || + !groupMembershipPolicy || + !moduleGrantPolicy || + !tenantModuleActivationPolicy + ) { + report( + results, + 'groups-policies-aus-migration-gefunden', + false, + 'CREATE POLICY fuer "Group", "GroupMembership", "ModuleGrant" und/oder "TenantModuleActivation" nicht in den ausgelieferten Migrationen gefunden', + ); + return; + } + + await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => { + await db.$executeRawUnsafe(` + CREATE TABLE "Group" ( + id text PRIMARY KEY, + "tenantId" text NOT NULL, + name text NOT NULL, + "isDefault" boolean NOT NULL DEFAULT false + ); + `); + await db.$executeRawUnsafe(` + CREATE TABLE "GroupMembership" ( + id text PRIMARY KEY, + "groupId" text NOT NULL, + "userId" text NOT NULL, + source text NOT NULL DEFAULT 'MANUAL' + ); + `); + await db.$executeRawUnsafe(` + CREATE TABLE "ModuleGrant" ( + id text PRIMARY KEY, + "tenantId" text NOT NULL, + "moduleId" text NOT NULL, + "groupId" text, + "userId" text + ); + `); + await db.$executeRawUnsafe(` + CREATE TABLE "TenantModuleActivation" ( + id text PRIMARY KEY, + "tenantId" text NOT NULL, + "moduleId" text NOT NULL, + "isActive" boolean NOT NULL DEFAULT true + ); + `); + + for (const table of ['Group', 'GroupMembership', 'ModuleGrant', 'TenantModuleActivation']) { + await db.$executeRawUnsafe(`ALTER TABLE "${table}" ENABLE ROW LEVEL SECURITY;`); + await db.$executeRawUnsafe(`ALTER TABLE "${table}" FORCE ROW LEVEL SECURITY;`); + } + await db.$executeRawUnsafe(groupPolicy); + await db.$executeRawUnsafe(groupMembershipPolicy); + await db.$executeRawUnsafe(moduleGrantPolicy); + await db.$executeRawUnsafe(tenantModuleActivationPolicy); + + for (const table of ['Group', 'GroupMembership', 'ModuleGrant', 'TenantModuleActivation']) { + await db.$executeRawUnsafe( + `GRANT SELECT, INSERT, UPDATE, DELETE ON "${table}" TO ${SCRATCH_ROLE_NAME}`, + ); + } + + await db.$executeRawUnsafe(` + INSERT INTO "Group" (id, "tenantId", name, "isDefault") VALUES + ('group-a', 'TENANT-A', 'Gruppe A', true), + ('group-b', 'TENANT-B', 'Gruppe B', true); + `); + await db.$executeRawUnsafe(` + INSERT INTO "GroupMembership" (id, "groupId", "userId", source) VALUES + ('membership-a', 'group-a', 'user-a', 'MANUAL'), + ('membership-b', 'group-b', 'user-b', 'MANUAL'); + `); + await db.$executeRawUnsafe(` + INSERT INTO "ModuleGrant" (id, "tenantId", "moduleId", "groupId", "userId") VALUES + ('grant-a', 'TENANT-A', 'mod-1', 'group-a', NULL), + ('grant-b', 'TENANT-B', 'mod-1', 'group-b', NULL); + `); + await db.$executeRawUnsafe(` + INSERT INTO "TenantModuleActivation" (id, "tenantId", "moduleId", "isActive") VALUES + ('activation-a', 'TENANT-A', 'mod-1', true), + ('activation-b', 'TENANT-B', 'mod-1', true); + `); + }); + + const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); + try { + // group-gebunden-nur-eigene-zeile + const groupRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw`SELECT "tenantId" FROM "Group" ORDER BY id`, + ); + report( + results, + 'group-gebunden-nur-eigene-zeile', + groupRowsForA.length === 1 && groupRowsForA[0].tenantId === 'TENANT-A', + `forTenant(TENANT-A) liefert ${groupRowsForA.length} Zeile(n): ${JSON.stringify(groupRowsForA.map((r) => r.tenantId))}`, + ); + + // group-ungebunden-null-zeilen — die Belegzeile, die die Kritikschrift + // traegt, am echten, ausgelieferten Policy-Text gemessen. + const unboundGroupRows = await prisma.$queryRaw`SELECT "tenantId" FROM "Group"`; + report( + results, + 'group-ungebunden-null-zeilen', + unboundGroupRows.length === 0, + `ungebundener SELECT auf "Group" liefert ${unboundGroupRows.length} Zeile(n)`, + ); + + // groupmembership-folgt-join-auf-group + const membershipRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw`SELECT "groupId" FROM "GroupMembership" ORDER BY id`, + ); + report( + results, + 'groupmembership-folgt-join-auf-group', + membershipRowsForA.length === 1 && membershipRowsForA[0].groupId === 'group-a', + `forTenant(TENANT-A) liefert ${membershipRowsForA.length} Mitgliedschaft(en): ${JSON.stringify(membershipRowsForA.map((r) => r.groupId))}`, + ); + + // groupmembership-schreiben-fremde-gruppe-abgelehnt + let foreignGroupInsertRejected = false; + let foreignGroupInsertDetail = ''; + try { + await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => + tx.$executeRaw`INSERT INTO "GroupMembership" (id, "groupId", "userId", source) VALUES ('membership-foreign-group', 'group-b', 'user-a', 'MANUAL')`, + ); + foreignGroupInsertDetail = 'INSERT mit fremder groupId ist NICHT fehlgeschlagen'; + } catch (err) { + foreignGroupInsertRejected = true; + foreignGroupInsertDetail = `INSERT mit fremder groupId abgewiesen: ${err.message}`; + } + report( + results, + 'groupmembership-schreiben-fremde-gruppe-abgelehnt', + foreignGroupInsertRejected, + foreignGroupInsertDetail, + ); + + // groupmembership-schreiben-fremder-benutzer-nicht-verhindert (Befund E): + // das GELINGEN dieses INSERTs ist das bestandene Ergebnis — es belegt, + // dass die Policy nur die Gruppenseite prueft, nicht die Benutzerseite. + let foreignUserInsertSucceeded = false; + let foreignUserInsertDetail = ''; + try { + await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => + tx.$executeRaw`INSERT INTO "GroupMembership" (id, "groupId", "userId", source) VALUES ('membership-foreign-user', 'group-a', 'user-nicht-in-a', 'MANUAL')`, + ); + foreignUserInsertSucceeded = true; + foreignUserInsertDetail = + 'INSERT mit A-eigener Gruppe, aber einer Benutzerkennung, die es in A nicht gibt, ist GELUNGEN — die Policy auf GroupMembership prueft nur die Gruppenseite, nicht die Benutzerseite (Befund E); die Anwendung muss die Benutzerseite selbst pruefen'; + } catch (err) { + foreignUserInsertDetail = `INSERT unerwartet abgewiesen: ${err.message}`; + } + report( + results, + 'groupmembership-schreiben-fremder-benutzer-nicht-verhindert', + foreignUserInsertSucceeded, + foreignUserInsertDetail, + ); + + // modulegrant-gebunden-nur-eigene-zeile + const grantRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw`SELECT "tenantId" FROM "ModuleGrant" ORDER BY id`, + ); + report( + results, + 'modulegrant-gebunden-nur-eigene-zeile', + grantRowsForA.length === 1 && grantRowsForA[0].tenantId === 'TENANT-A', + `forTenant(TENANT-A) liefert ${grantRowsForA.length} Zeile(n): ${JSON.stringify(grantRowsForA.map((r) => r.tenantId))}`, + ); + + // modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt + // (Befund F): auch hier ist das Durchgehen das bestandene Ergebnis. + let foreignGroupGrantSucceeded = false; + let foreignGroupGrantDetail = ''; + try { + await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => + tx.$executeRaw`INSERT INTO "ModuleGrant" (id, "tenantId", "moduleId", "groupId", "userId") VALUES ('grant-foreign-group', 'TENANT-A', 'mod-1', 'group-b', NULL)`, + ); + foreignGroupGrantSucceeded = true; + foreignGroupGrantDetail = + 'INSERT mit korrekter eigener tenantId, aber fremder groupId ist GELUNGEN — die Policy auf ModuleGrant prueft nur die Mandantenkennung der Zeile, nicht die referenzierte Gruppe (Befund F); assertTargetBelongsToTenant ist der einzige Schutz und darf bei der Umstellung nicht entfallen'; + } catch (err) { + foreignGroupGrantDetail = `INSERT unerwartet abgewiesen: ${err.message}`; + } + report( + results, + 'modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt', + foreignGroupGrantSucceeded, + foreignGroupGrantDetail, + ); + + // tenantmoduleactivation-gebunden-nur-eigene-zeile + const activationRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw`SELECT "tenantId" FROM "TenantModuleActivation" ORDER BY id`, + ); + report( + results, + 'tenantmoduleactivation-gebunden-nur-eigene-zeile', + activationRowsForA.length === 1 && activationRowsForA[0].tenantId === 'TENANT-A', + `forTenant(TENANT-A) liefert ${activationRowsForA.length} Zeile(n): ${JSON.stringify(activationRowsForA.map((r) => r.tenantId))}`, + ); + } finally { + await prisma.$disconnect(); + } +} + +/** + * Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen + * den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte + * traegt. Baut die Erweiterungsform aus prisma-tenant.extension.ts + * WORTGLEICH nach ($extends mit $allOperations, Array-Form von + * $transaction darin) statt ueber das vereinfachte forTenantQuery(), denn + * genau diese Erweiterungsschicht ist hier der Gegenstand der Messung. + * + * Setzt auf die Tabelle "Group" auf, die runGroupsAreaChecks() bereits + * angelegt und mit je einer Zeile fuer TENANT-A/TENANT-B befuellt hat. + */ +function buildInlineExtendedClient(prisma, tenantId) { + return prisma.$extends({ + query: { + $allOperations({ args, query }) { + const setTenantContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`; + return prisma.$transaction([setTenantContext, query(args)]).then((res) => res[1]); + }, + }, + }); +} + +/** + * Druckt die tatsaechlich beobachteten Werte einer Transaktionsform. Fliesst + * NICHT in die Pruefliste ein und beeinflusst den Rueckgabewert nicht — eine + * Form, die abbricht, ist ein Messergebnis und kein Werkzeugfehler. + */ +function beobachte(formName, payload) { + console.log(` [beobachtet] ${formName}: ${JSON.stringify(payload)}`); +} + +/** + * Alle drei Bedingungen aus dem Plan: gleiche Verbindungskennung ueber + * beide Teilschritte, gelesener Mandantenkontext gleich TENANT-A in + * beiden Teilschritten, und der Lesezugriff liefert genau die eine Zeile + * von TENANT-A. + */ +function traegtKontextAufDerselbenVerbindung(step1, step2) { + return Boolean( + step1 && + step2 && + step1.pid === step2.pid && + step1.t === 'TENANT-A' && + step2.t === 'TENANT-A' && + step2.rows === 1, + ); +} + +async function measureArrayFormOnBoundClient(scratchRoleUrl) { + const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); + try { + const bound = buildInlineExtendedClient(prisma, 'TENANT-A'); + const [step1Rows, step2Rows] = await bound.$transaction([ + bound.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t`, + bound.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t, (SELECT count(*)::int FROM "Group") AS rows`, + ]); + return { step1: step1Rows[0], step2: step2Rows[0] }; + } finally { + await prisma.$disconnect(); + } +} + +async function measureInteractiveFormOnBoundClient(scratchRoleUrl) { + const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); + try { + const bound = buildInlineExtendedClient(prisma, 'TENANT-A'); + return await bound.$transaction(async (tx) => { + const step1Rows = await tx.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t`; + const step2Rows = await tx.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t, (SELECT count(*)::int FROM "Group") AS rows`; + return { step1: step1Rows[0], step2: step2Rows[0] }; + }); + } finally { + await prisma.$disconnect(); + } +} + +async function measureInteractiveFormOnUnboundClient(scratchRoleUrl) { + const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); + const tenantId = 'TENANT-A'; + try { + return await prisma.$transaction(async (tx) => { + const step1Rows = await tx.$queryRaw`SELECT pg_backend_pid() AS pid, set_config('app.current_tenant', ${tenantId}, true) AS applied, current_tenant_id() AS t`; + const step2Rows = await tx.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t, (SELECT count(*)::int FROM "Group") AS rows`; + return { step1: step1Rows[0], step2: step2Rows[0] }; + }); + } finally { + await prisma.$disconnect(); + } +} + +async function runTransactionShapeMeasurement(scratchRoleUrl, results) { + const forms = [ + { name: 'Form (i) — Array-Form auf gebundenem Client', fn: measureArrayFormOnBoundClient }, + { + name: 'Form (ii) — interaktive Callback-Form auf gebundenem Client', + fn: measureInteractiveFormOnBoundClient, + }, + { + name: 'Form (iii) — interaktive Callback-Form auf ungebundenem Client (set_config auf tx)', + fn: measureInteractiveFormOnUnboundClient, + }, + ]; + + const outcomes = []; + for (const form of forms) { + try { + const r = await form.fn(scratchRoleUrl); + beobachte(form.name, r); + outcomes.push({ name: form.name, passed: traegtKontextAufDerselbenVerbindung(r.step1, r.step2) }); + } catch (err) { + beobachte(form.name, { abbruch: err.message }); + outcomes.push({ name: form.name, passed: false }); + } + } + + const passedForms = outcomes.filter((o) => o.passed).map((o) => o.name); + const failedForms = outcomes.filter((o) => !o.passed).map((o) => o.name); + report( + results, + 'mindestens-eine-transaktionsform-traegt-den-mandantenkontext', + passedForms.length > 0, + `bestanden: [${passedForms.join(' ; ')}] — nicht bestanden: [${failedForms.join(' ; ')}]`, + ); +} + async function main() { const adminUrl = parseAdminUrl(); const results = []; @@ -551,6 +951,8 @@ async function main() { await runForTenantChecks(scratchRoleUrlString, results); await runAuthLookupChecks(adminUrl, scratchRoleUrlString, results); await runLdapAreaChecks(adminUrl, scratchRoleUrlString, results); + await runGroupsAreaChecks(adminUrl, scratchRoleUrlString, results); + await runTransactionShapeMeasurement(scratchRoleUrlString, results); } finally { console.log(`Raeume Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ab...`); await teardownScratchDatabase(adminUrl); diff --git a/docs/mandantentrennung-etappe2-fehlerrichtung.md b/docs/mandantentrennung-etappe2-fehlerrichtung.md index 55b2def..377a1fa 100644 --- a/docs/mandantentrennung-etappe2-fehlerrichtung.md +++ b/docs/mandantentrennung-etappe2-fehlerrichtung.md @@ -157,6 +157,197 @@ interpretieren: `docs/mandantentrennung-zugriffsklassifikation.md`, Abschnitt "Was diese Etappe NICHT entscheidet"). +## Bereich groups + +Dieser Abschnitt erweitert die Kritikschrift um den Bereich `groups` +(Quick-Task 260909-jts) und beschreibt ihn zum Zeitpunkt seiner Umstellung. +Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt +beantwortet sie erneut, aber für einen Bereich, der die Berechtigungsschicht +selbst ist: Gruppenmitgliedschaft und Modulfreigaben entscheiden, wer welches +Modul sehen darf. + +### (g1) Die Messung + +Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen vierten +Abschnitt (`runGroupsAreaChecks`) und eine eigene Transaktionsmessung +(`runTransactionShapeMeasurement`) erweitert, beide gegen die Wegwerf-Datenbank +unter der Rolle ohne `BYPASSRLS`, mit den vier Policies für `Group`, +`GroupMembership`, `ModuleGrant` (aus `20260804130918_groups_rls_policies`) +und `TenantModuleActivation` (aus `20260909140000_rls_remaining_tenant_tables`) +WORTGLEICH aus den ausgelieferten Migrationen extrahiert. Tatsächlich +beobachtete Ausgabe dieses Laufs (2026-09-09, gegen `tessera-ctl-db-1`, +Adresse `172.19.0.2`): + +``` +group-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"] +group-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "Group" liefert 0 Zeile(n) +groupmembership-folgt-join-auf-group: bestanden — forTenant(TENANT-A) liefert 1 Mitgliedschaft(en): ["group-a"] +groupmembership-schreiben-fremde-gruppe-abgelehnt: bestanden — INSERT mit fremder groupId abgewiesen: ERROR: new row violates row-level security policy for table "GroupMembership" +groupmembership-schreiben-fremder-benutzer-nicht-verhindert: bestanden — INSERT mit A-eigener Gruppe, aber einer Benutzerkennung, die es in A nicht gibt, ist GELUNGEN — die Policy auf GroupMembership prueft nur die Gruppenseite, nicht die Benutzerseite (Befund E) +modulegrant-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"] +modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt: bestanden — INSERT mit korrekter eigener tenantId, aber fremder groupId ist GELUNGEN — die Policy auf ModuleGrant prueft nur die Mandantenkennung der Zeile, nicht die referenzierte Gruppe (Befund F) +tenantmoduleactivation-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"] +``` + +Die Belegzeile, die diesen Abschnitt trägt, ist `group-ungebunden-null-zeilen`: +der IDENTISCHE `SELECT "tenantId" FROM "Group"` ohne vorheriges `set_config` +liefert **0 Zeilen**, nicht etwa die 2 tatsächlich vorhandenen — an der +echten, ausgelieferten Policy gemessen, nicht an einer im Werkzeug +nachgebauten Hilfstabelle. + +**Die Transaktionsmessung — namentliches Ergebnis.** Drei Formen wurden +gegen einen Client beobachtet, der die Erweiterungsform aus +`prisma-tenant.extension.ts` wortgleich nachbaut, jeweils mit +`pg_backend_pid()` und `current_tenant_id()` in jeder Teilabfrage plus einem +echten Lesezugriff auf `"Group"`: + +``` +[beobachtet] Form (i) — Array-Form auf gebundenem Client: {"step1":{"pid":276749,"t":"TENANT-A"},"step2":{"pid":276750,"t":"TENANT-A","rows":1}} +[beobachtet] Form (ii) — interaktive Callback-Form auf gebundenem Client: {"step1":{"pid":276752,"t":"TENANT-A"},"step2":{"pid":276752,"t":"TENANT-A","rows":1}} +[beobachtet] Form (iii) — interaktive Callback-Form auf ungebundenem Client (set_config auf tx): {"step1":{"pid":276753,"applied":"TENANT-A","t":"TENANT-A"},"step2":{"pid":276753,"t":"TENANT-A","rows":1}} +mindestens-eine-transaktionsform-traegt-den-mandantenkontext: bestanden — bestanden: [Form (ii) ; Form (iii)] — nicht bestanden: [Form (i)] +``` + +Form (i) (Array-Form auf dem gebundenen Client) versagt eindeutig: `step1` +lief auf Verbindung 276749, `step2` auf Verbindung 276750 — zwei +verschiedene physische Verbindungen, obwohl beide Schritte denselben +Mandantenkontext lasen. Das bestätigt wortgetreu den Vorbehalt aus dem +Kopfkommentar von `prisma-tenant.extension.ts`: jede Modell-Operation eines +`$transaction`-Arrays auf dem gebundenen Client dispatcht durch +`$allOperations` und bekommt dadurch ihre EIGENE Ein-Element-Transaktion — +mehrere solche Operationen laufen auf mehreren Teiltransaktionen statt +einer gemeinsamen. In diesem konkreten Fall lieferte jede Teiltransaktion +zwar noch den korrekten Mandantenkontext (kein Datenleck), aber die +Atomarität der äußeren Transaktion ist nicht mehr gegeben — bei einem +Absturz zwischen den beiden Teiltransaktionen bliebe der Zustand +inkonsistent. + +Form (ii) und Form (iii) bestanden beide die im Plan festgelegte +Einzelmessung (gleiche Verbindungskennung, korrekter Kontext, korrekte +Zeilenzahl). Über diese Einzelmessung hinaus wurde als zusätzliche, +sicherheitsrelevante Sorgfaltsprüfung (nicht durch den Plan verlangt, aber +durch die Tragweite dieses Bereichs geboten) beide Formen unter echter +Nebenläufigkeit erneut gemessen — 40 parallele Aufrufe, alternierend +TENANT-A/TENANT-B, gegen eine separate Experiment-Datenbank mit derselben +Struktur: + +- **Form (ii)** brach unter dieser Last mit + `PrismaClientKnownRequestError: Transaction API error: Unable to start a + transaction in the given time.` (Code `P2028`) ab. Ursache: jede + `tx.$queryRaw`-Anweisung innerhalb der interaktiven Transaktion auf dem + gebundenen Client löst selbst wieder eine VERSCHACHTELTE + Array-Transaktion auf dem äußeren, ungebundenen Client aus (weil + `$allOperations` bei jedem Aufruf erneut feuert) — die äußere + interaktive Transaktion UND jede innere Verschachtelung belegen + gleichzeitig eine Verbindung aus demselben, endlichen Pool. Unter Last + reicht der Pool nicht mehr aus. +- **Form (iii)** bestand dieselbe Belastung mit 0 Verletzungen unter 40 + parallelen Aufrufen — sie belegt pro Aufruf genau eine Verbindung, ohne + Verschachtelung. + +Das ist der entscheidende Befund für die Werkzeugentscheidung in Aufgabe 2: +obwohl Form (ii) die im Plan geforderte EINZELMESSUNG technisch besteht, +ist sie unter echter Nebenläufigkeit strukturell fragil und ein +Denial-of-Service-Risiko genau an der Stelle, die T-JTS-08 benennt (die +Startreparatur ruft `ensureDefaultGroup` für mehrere Mandanten auf). Form +(iii) ist die einzige der drei Formen, die sowohl die Einzelmessung als +auch die Belastungsprobe besteht — sie ist deshalb die Grundlage des neuen +Hilfsmittels `withTenantTransaction` in `prisma-tenant.extension.ts`. + +### (g2) Signaltabelle je umzustellendem Pfad + +| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort | +|---|---|---| +| `GroupsService.listForTenant` | Liefert 0 Gruppen statt der tatsächlich vorhandenen | Die Gruppenliste in der Verwaltung ist leer (Mitgliederzahl je Zeile fehlt ganz, weil die Zeile fehlt) | +| `GroupsService.getImpact` | Liefert `{memberCount:0, grantCount:0}` statt der tatsächlichen Zahlen | Der Löschdialog zeigt seine zwei Zahlen als 0/0 — der Administrator entscheidet über eine kaskadierende Löschung auf falscher Grundlage | +| `GroupsService.listMembers` | Liefert 0 Mitglieder statt der tatsächlich vorhandenen | Die Mitgliederliste im Gruppen-Detail ist leer | +| `ModuleGrantsService.getMatrix` | Liefert 0 Module und/oder 0 Gruppen statt der tatsächlich aktiven/vorhandenen | Die Freigabe-Matrix zeigt weder Modul- noch Gruppenachse vollständig — eine leere Zelle sieht identisch aus wie eine bewusst nicht erteilte Freigabe | +| `ModuleGrantsService.getUserAccess` | Liefert 0 Module bzw. 0 Gruppen statt der tatsächlichen | Das Benutzer-Detail zeigt für beide unabhängigen Antworten (Gruppenmitgliedschaften, Modulzugriff) fälschlich "keine" | +| `LdapService.syncBoundGroupsForTenant` (Übergabe an `reassignDefaultBeforeDelete`/`ensureDefaultGroup`) | Der Zähler `defaultMarkerMoved` im Abgleich-Bericht bleibt bei 0, obwohl tatsächlich verschoben wurde — oder eine Gruppe verliert ihre Standardmarkierung ersatzlos | Der Abgleich-Bericht des Verzeichnis-Syncs (D-05/D-06) | +| `GroupsService.addUserToDefaultGroup` | Findet die Standardgruppe nicht (0 Zeilen statt der einen vorhandenen) und tut nichts | Ein frisch angelegter Benutzer sieht nach seiner ersten Anmeldung KEIN Modul (leere Modulkacheln) | + +### (g3) Welcher Code deutet Leere als Abwesenheit — Bereich groups + +Ausgangspunkt ist Befund I aus der Planung, ergänzt um eine erneute Sichtung +beider Dateien: + +- **`GroupsService.reassignDefaultBeforeDelete`** — zerstörend und still. + Zwei getrennte Stellen liefern `false`: die Gruppe selbst ist nicht + sichtbar (`findFirst` auf `Group` liefert 0 Zeilen), oder es ist kein + Ersatzkandidat sichtbar (beide `findFirst`-Fallbacks liefern 0 Zeilen). + Der Aufrufer im Verzeichnis-Sync (`syncBoundGroupsForTenant`) löscht die + Gruppe danach in BEIDEN Fällen trotzdem, und die Löschung nimmt über die + Kaskadenregeln aus 15-01 Mitgliedschaften und Modulfreigaben mit. Das ist + Befund D aus der ldap-Kritik (Abschnitt (e) oben); ihn zu schließen ist + ein Hauptzweck dieses Durchlaufs. +- **`GroupsService.ensureDefaultGroup`** — die einzige Stelle des Bereichs, + an der zu wenig Lesen zu ZU VIEL Schreiben führt. Der Wächter ist + UMGEKEHRT gepolt: null gelesene Gruppen (`group.count` liefert 0 statt + der tatsächlichen Anzahl) heißt hier nicht "nichts zu tun", sondern + "alles neu aufbauen". Bliebe der Zähler ungebunden, während der + Schreibteil (die Transaktion) gebunden liefe, legte die Methode für einen + Mandanten, der bereits Gruppen hat, eine ZWEITE Standardgruppe an, nähme + ALLE seine Benutzer als Mitglieder auf und verteilte Freigaben für ALLE + aktiven Module — eine stille Ausweitung von Berechtigungen, ausgelöst + durch ein zu kleines Leseergebnis. Der partielle Eindeutigkeitsindex + `Group_one_default_per_tenant` fängt einen Teil der Fälle ab (P2002 beim + `group.create`, abgefangen und in `null` übersetzt) — aber nur, wenn der + Mandant BEREITS eine markierte Standardgruppe hat. Einen Mandanten mit + Gruppen, aber OHNE markierte Standardgruppe, fängt der Index nicht ab. + Genau deshalb müssen Zähler und Transaktion GEMEINSAM gebunden werden, + nie einzeln (T-JTS-05). +- **`GroupsService.getImpact`** — die Zahlen des Löschdialogs. Zwei + Zählungen (`groupMembership.count`, `moduleGrant.count`) ohne + Mandantenfilter, die bei Leere 0 und 0 melden. Der Administrator + entscheidet auf dieser Grundlage über eine kaskadierende Löschung und + bekommt "keine Mitglieder, keine Freigaben" für eine tatsächlich volle + Gruppe angezeigt. +- **`GroupsService.addUserToDefaultGroup`** — stilles Zurückkehren ohne + sichtbare Standardgruppe (`group.findFirst` liefert 0 Zeilen statt der + einen vorhandenen). Jeder neu angelegte Benutzer landet dann in KEINER + Gruppe und sieht nach seiner ersten Anmeldung kein einziges Modul. Nicht + zerstörend, aber lautlos und in der Wirkung ein Berechtigungsverlust. + +Gegenrichtung, ebenfalls festgehalten: `ModuleGrantsService.grant` (die +Aktivierungsprüfung: kein aktives `TenantModuleActivation` wirft +`BadRequestException`, statt still zu erteilen) und +`ModuleGrantsService.assertTargetBelongsToTenant` (die +Mandanten-Gegenprüfung: kein Treffer wirft `NotFoundException`) werfen bei +Leere LAUT und sind damit die harmlosen Stellen des Bereichs. + +Entlastung, ausdrücklich am Frontend nachgesehen statt aus dem Backend +geschlossen: `apps/web/src/app/(portal)/admin/modules/grants/page.tsx` +schaltet die Freigabe-Matrix je Zelle einzeln (ein `POST` bzw. `DELETE` pro +Klick) — es gibt keinen Sammel-Speichern-Knopf, der einen Abgleich gegen den +gelesenen Zustand fährt. Ein zu kleines Leseergebnis führt dort also zu +einer leeren Anzeige, nicht zu einem Massen-Entzug. Das ist der Unterschied +zum `deleteMany`-mit-`notIn` des ldap-Bereichs. + +### (g4) Was dieser Durchlauf bewusst nicht löst + +- **Die offene Architekturfrage `req.tenantPrisma`.** Auch der Bereich + `groups` entscheidet sie nicht — er bindet dienst-intern, wie `ldap` und + `auth.service.ts` es vormachen. +- **Die Policies auf `GroupMembership` und `ModuleGrant` prüfen jeweils nur + eine Seite.** Gemessen in (g1): `GroupMembership` prüft ausschließlich die + Gruppenseite (Befund E, T-JTS-02) — eine Mitgliedschaft mit einer + Benutzerkennung, die es im Mandanten der Gruppe nicht gibt, verletzt die + Policy NICHT. `ModuleGrant` prüft ausschließlich die Mandantenkennung der + Zeile selbst (Befund F, T-JTS-03) — eine Freigabe mit korrekter eigener + Mandantenkennung, aber einer fremden Gruppenkennung, verletzt die Policy + ebenfalls NICHT. Die Anwendungsprüfungen (die Benutzerfilterung in + `addUserToDefaultGroup`, `assertTargetBelongsToTenant`) bleiben deshalb + der primäre Schutz gegen diese beiden Formen der Rechteausweitung und + werden durch diesen Durchlauf NICHT durch die Datenbank ersetzt. + +### (g5) Fortschreibung des ldap-Abschnitts + +Der in Abschnitt (e) oben als offen geführte Befund D — die Übergabe der +Standardgruppe vor einer Gruppenlöschung — wird durch diesen Durchlauf +geschlossen. Der Vermerk selbst steht am Ende von Abschnitt (e), gesetzt +in Aufgabe 3 dieses Plans, weil die Schließung erst zu diesem Zeitpunkt +tatsächlich vorliegt. + ## Verweis Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang