From b848ba6baa1ba6b59691250e39680314e96e648e Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 10 Sep 2026 10:16:12 +0200 Subject: [PATCH] feat(quick-260910-das): messen die Kette user unsichtbar->frei->Eindeutigkeitsfehler - runUserAreaChecks in rls-scratch-check.mjs: 12 neue Pruefungen gegen die ausgelieferte User-Policy (baut auf der vom Anmeldeweg-Abschnitt angelegten Tabelle auf, legt zusaetzlich Tenant ohne Zeilenschutz an) - Belegt: ungebundene Suche nach vorhandenem Benutzernamen liefert 0 Zeilen, gebundene Suche nach fremdem Benutzernamen ebenso ("frei"), und das anschliessende gebundene INSERT scheitert hart an SQLSTATE 23505 (Eindeutigkeitsverletzung), nicht an 42501 (Zeilenschutz) - SQLSTATE wird aus err.meta.code gelesen, nicht err.code (das bei $executeRaw-Fehlern immer den generischen Prisma-Code P2010 traegt, empirisch gegen tessera-ctl-db-1 geprueft) - docs/mandantentrennung-etappe2-fehlerrichtung.md: neuer Abschnitt "Bereich user" (u1-u5) mit der tatsaechlich beobachteten Ausgabe, Signaltabelle, der vollstaendigen Kette (Befund L) und den Grenzen zu auth.service.ts/ldap.service.ts - Teil 2/3 gemessen: keine Transaktion in apps/api/src/user (Befund B haelt), findByUsername hat genau einen Treffer, die eigene Definition (Befund D haelt) - 789 Tests weiterhin gruen, Typpruefung sauber, Wegwerf-Werkzeug meldet alle 53 Pruefungen bestanden (41 bisherige + 12 neue) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR --- apps/api/scripts/rls-scratch-check.mjs | 320 ++++++++++++++++++ ...andantentrennung-etappe2-fehlerrichtung.md | 270 +++++++++++++++ 2 files changed, 590 insertions(+) diff --git a/apps/api/scripts/rls-scratch-check.mjs b/apps/api/scripts/rls-scratch-check.mjs index 9ae7911..660b1f2 100644 --- a/apps/api/scripts/rls-scratch-check.mjs +++ b/apps/api/scripts/rls-scratch-check.mjs @@ -1391,6 +1391,325 @@ async function runDkvAreaChecks(adminUrl, scratchRoleUrl, results) { } } +/** + * Vergleicht zwei SQL-Fragmente nach Normalisierung von Leerraum und + * abschliessendem Semikolon, damit der Vergleich nicht an Formatierung + * scheitert (Aufgabe 1, 260910-das). + */ +function normalizePolicySql(sql) { + return sql.trim().replace(/;\s*$/, '').replace(/\s+/g, ' ').trim(); +} + +/** + * Die im Anmeldeweg-Abschnitt (runAuthLookupChecks) von Hand getippte + * Policy auf "User" — Wortlaut identisch mit Zeile 285 dieser Datei. Dieser + * Abschnitt haelt sie GEGEN die aus der ausgelieferten Migration + * `20260618112133_rls_policies` geschnittene Fassung, statt eine dritte + * Fassung zu erzeugen (Befund O). + */ +const AUTH_LOOKUP_USER_POLICY_SQL = + 'CREATE POLICY tenant_isolation_policy ON "User" USING ("tenantId" = current_tenant_id());'; + +/** + * Liest den SQLSTATE-Fehlercode aus einem PrismaClientKnownRequestError, + * der aus einem fehlgeschlagenen $executeRaw/$queryRaw stammt. Bei + * Rohabfragen setzt Prisma selbst `.code` auf den generischen Wert + * `P2010` ("Raw query failed") und legt den tatsaechlichen + * PostgreSQL-SQLSTATE-Code unter `.meta.code` ab (empirisch geprueft gegen + * `tessera-ctl-db-1`, 2026-09-10: `.code` ist fuer eine Eindeutigkeits- + * UND eine Zeilenschutz-Verletzung gleichermassen `P2010`, waehrend + * `.meta.code` `23505` bzw. `42501` unterscheidet) — deshalb wird hier + * `.meta.code` gelesen, nicht `.code`. + */ +function sqlStateOf(err) { + return err?.meta?.code ?? null; +} + +/** + * Aufgabe 1 (260910-das) — misst die zwoelf im Plan genannten + * Verhaltensweisen des Bereichs `user` unter der Rolle ohne BYPASSRLS. Die + * Tabelle "User" existiert bereits (vom runAuthLookupChecks()-Abschnitt + * angelegt, samt eingeschaltetem und erzwungenem Zeilenschutz, beiden + * Eindeutigkeitsbedingungen `username`/`email` und zwei Testzeilen in zwei + * Mandanten, Befund O) — dieser Abschnitt legt sie NICHT neu an, sondern + * baut darauf auf und ergaenzt, was ihm fehlt: eine Tabelle "Tenant" ohne + * Zeilenschutz (die zu messende Eigenschaft selbst, nicht Beiwerk) und je + * eine weitere Benutzerzeile pro Mandant. + * + * Muss NACH runDkvAreaChecks() 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 runUserAreaChecks(adminUrl, scratchRoleUrl, results) { + const baseMigrationSql = readRlsPoliciesMigrationSql(); + const extractedUserPolicy = baseMigrationSql + ? extractPolicySql(baseMigrationSql, 'User') + : null; + + if ( + !extractedUserPolicy || + normalizePolicySql(extractedUserPolicy) !== normalizePolicySql(AUTH_LOOKUP_USER_POLICY_SQL) + ) { + report( + results, + 'user-policy-aus-migration-wortgleich', + false, + extractedUserPolicy + ? `Policy aus 20260618112133_rls_policies weicht von der im Anmeldeweg-Abschnitt getippten Fassung ab: extrahiert=${JSON.stringify(normalizePolicySql(extractedUserPolicy))}, getippt=${JSON.stringify(normalizePolicySql(AUTH_LOOKUP_USER_POLICY_SQL))} — die getippte Fassung wird durch die geschnittene ERSETZT, die Abweichung gehoert in die Kritikschrift` + : 'CREATE POLICY fuer "User" nicht in der ausgelieferten 20260618112133_rls_policies-Migration gefunden', + ); + return; + } + report( + results, + 'user-policy-aus-migration-wortgleich', + true, + 'die im Anmeldeweg-Abschnitt (runAuthLookupChecks) von Hand getippte Policy auf "User" ist nach Normalisierung von Leerraum und abschliessendem Semikolon wortgleich mit der aus 20260618112133_rls_policies geschnittenen', + ); + + await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => { + // "Tenant" traegt bewusst KEINEN Zeilenschutz — das ist die zu + // messende Eigenschaft (Befund F/K), nicht ein Versehen. + await db.$executeRawUnsafe(` + CREATE TABLE "Tenant" ( + id text PRIMARY KEY, + slug text NOT NULL UNIQUE + ); + `); + await db.$executeRawUnsafe( + `GRANT SELECT, INSERT, UPDATE, DELETE ON "Tenant" TO ${SCRATCH_ROLE_NAME}`, + ); + await db.$executeRawUnsafe(` + INSERT INTO "Tenant" (id, slug) VALUES + ('TENANT-A', 'tenant-a'), + ('TENANT-B', 'tenant-b'); + `); + + // Je eine weitere Benutzerzeile pro Mandant, zusaetzlich zu den beiden + // bereits vom Anmeldeweg-Abschnitt angelegten (alice/TENANT-A, + // bob/TENANT-B) — role braucht einen gueltigen Aufzaehlungswert + // (Befund O). + await db.$executeRawUnsafe(` + INSERT INTO "User" (id, username, "tenantId", "passwordHash", "isActive", role) + VALUES ('user-a2', 'carol', 'TENANT-A', 'hash-c', true, 'USER'), + ('user-b2', 'dave', 'TENANT-B', 'hash-d', true, 'USER'); + `); + }); + + const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); + try { + // user-gebunden-nur-eigener-mandant + const userRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw`SELECT "tenantId" FROM "User" ORDER BY id`, + ); + report( + results, + 'user-gebunden-nur-eigener-mandant', + userRowsForA.length === 2 && userRowsForA.every((r) => r.tenantId === 'TENANT-A'), + `forTenant(TENANT-A) liefert ${userRowsForA.length} Zeile(n): ${JSON.stringify(userRowsForA.map((r) => r.tenantId))}`, + ); + + // user-ungebunden-null-zeilen — die Belegzeile, die den gesamten + // user-Abschnitt der Kritikschrift traegt: der IDENTISCHE SELECT ohne + // vorheriges set_config liefert null Zeilen, nicht die vier + // tatsaechlich vorhandenen. + const unboundUserRows = await prisma.$queryRaw`SELECT "tenantId" FROM "User"`; + report( + results, + 'user-ungebunden-null-zeilen', + unboundUserRows.length === 0, + `ungebundener SELECT auf "User" liefert ${unboundUserRows.length} Zeile(n)`, + ); + + // user-ungebundene-suche-nach-benutzername-liefert-keine-zeile — die + // Form, die die Erstanlage-Pruefung beim Start heute benutzt: ein + // ungebundenes SELECT mit Gleichheitsbedingung auf einen Benutzernamen, + // den es GIBT ('bob', TENANT-B). + const unboundUsernameLookup = + await prisma.$queryRaw`SELECT id FROM "User" WHERE username = 'bob'`; + report( + results, + 'user-ungebundene-suche-nach-benutzername-liefert-keine-zeile', + unboundUsernameLookup.length === 0, + `ungebundenes SELECT ... WHERE username = 'bob' liefert ${unboundUsernameLookup.length} Zeile(n), obwohl der Benutzer existiert — der aufrufende Code liest daraus "diesen Benutzer gibt es nicht" und legt an`, + ); + + // user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile — + // dieselbe Suche, gebunden an TENANT-A, nach einem Benutzernamen von + // TENANT-B ('bob'). Das ist der Moment, in dem eine Kollisionspruefung + // faelschlich "frei" meldet. + const boundForeignUsernameLookup = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw`SELECT id FROM "User" WHERE username = 'bob'`, + ); + report( + results, + 'user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile', + boundForeignUsernameLookup.length === 0, + `forTenant(TENANT-A) liefert fuer WHERE username = 'bob' (gehoert TENANT-B) ${boundForeignUsernameLookup.length} Zeile(n) — die Kollisionspruefung meldet faelschlich "frei"`, + ); + + // user-eindeutigkeit-greift-trotz-unsichtbarkeit — die wichtigste + // Messung dieser Aufgabe und der Schluss der Kette: unmittelbar nach + // der vorherigen Pruefung ein gebundenes INSERT unter TENANT-A mit + // genau diesem, angeblich freien Benutzernamen ('bob'). Bestanden, wenn + // es abgewiesen wird UND die Ablehnung nachweislich eine + // Eindeutigkeitsverletzung ist (SQLSTATE 23505) und NICHT eine + // Zeilenschutz-Ablehnung (SQLSTATE 42501). + let uniquenessHoldsDespiteInvisibility = false; + let uniquenessDetail = ''; + try { + await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => + tx.$executeRaw`INSERT INTO "User" (id, username, "tenantId", "passwordHash", "isActive", role) VALUES ('user-a-collision', 'bob', 'TENANT-A', 'hash-x', true, 'USER')`, + ); + uniquenessDetail = + 'gebundenes INSERT unter TENANT-A mit dem angeblich freien Benutzernamen "bob" ist NICHT fehlgeschlagen'; + } catch (err) { + const sqlState = sqlStateOf(err); + uniquenessHoldsDespiteInvisibility = sqlState === '23505'; + uniquenessDetail = `gebundenes INSERT unter TENANT-A mit dem angeblich freien Benutzernamen "bob" wird abgewiesen mit SQLSTATE ${sqlState ?? 'unbekannt'} (${err.message.trim()}) — ${ + uniquenessHoldsDespiteInvisibility + ? 'eine Eindeutigkeitsverletzung (23505), NICHT eine Zeilenschutz-Ablehnung: genau die im Auftrag beschriebene Kette' + : 'KEINE Eindeutigkeitsverletzung, sondern eine andere Fehlerklasse — das waere NICHT die im Auftrag beschriebene Kette' + }`; + } + report( + results, + 'user-eindeutigkeit-greift-trotz-unsichtbarkeit', + uniquenessHoldsDespiteInvisibility, + uniquenessDetail, + ); + + // user-gebundenes-einfuegen-fremder-mandant-abgelehnt — die Policy + // traegt keine eigene WITH-CHECK-Klausel; was PostgreSQL daraus fuer + // ein INSERT ableitet, ist eine Eigenschaft der Datenbank, keine des + // Policy-Textes. + let foreignTenantInsertRejected = false; + let foreignTenantInsertDetail = ''; + try { + await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => + tx.$executeRaw`INSERT INTO "User" (id, username, "tenantId", "passwordHash", "isActive", role) VALUES ('user-rejected-foreign-tenant', 'erin', 'TENANT-B', 'hash-e', true, 'USER')`, + ); + foreignTenantInsertDetail = + 'gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B ist NICHT fehlgeschlagen'; + } catch (err) { + foreignTenantInsertRejected = true; + foreignTenantInsertDetail = `gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen: ${err.message.trim()}`; + } + report( + results, + 'user-gebundenes-einfuegen-fremder-mandant-abgelehnt', + foreignTenantInsertRejected, + foreignTenantInsertDetail, + ); + + // user-ungebundenes-einfuegen-abgelehnt — die Zeile, die Befund J + // traegt: bliebe die Erstanlage des Administrators ungebunden, koennte + // eine frische Installation ihren ersten Administrator nach dem + // Scharfschalten nicht anlegen. + let unboundInsertRejected = false; + let unboundInsertDetail = ''; + try { + await prisma.$executeRaw`INSERT INTO "User" (id, username, "tenantId", "passwordHash", "isActive", role) VALUES ('user-rejected-unbound', 'frank', 'TENANT-A', 'hash-f', true, 'USER')`; + unboundInsertDetail = 'ungebundenes INSERT mit gueltiger Mandantenkennung ist NICHT fehlgeschlagen'; + } catch (err) { + unboundInsertRejected = true; + unboundInsertDetail = `ungebundenes INSERT mit gueltiger Mandantenkennung abgewiesen: ${err.message.trim()}`; + } + report( + results, + 'user-ungebundenes-einfuegen-abgelehnt', + unboundInsertRejected, + unboundInsertDetail, + ); + + // user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen + const foreignUpdateAffected = await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => + tx.$executeRaw`UPDATE "User" SET "displayName" = 'ueberschrieben' WHERE id = 'user-b'`, + ); + report( + results, + 'user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen', + foreignUpdateAffected === 0, + `gebundenes UPDATE unter TENANT-A ueber die Kennung 'user-b' (gehoert TENANT-B) allein betrifft ${foreignUpdateAffected} Zeile(n) — die vorgeschalteten Besitz- und Rollenpruefungen bleiben deshalb erhalten und werden in Aufgabe 2/3 nicht durch die Datenbank ersetzt`, + ); + + // user-gebundenes-loeschen-ueber-kennung-allein-trifft-null-zeilen + const foreignDeleteAffected = await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => tx.$executeRaw`DELETE FROM "User" WHERE id = 'user-b2'`, + ); + report( + results, + 'user-gebundenes-loeschen-ueber-kennung-allein-trifft-null-zeilen', + foreignDeleteAffected === 0, + `gebundenes DELETE unter TENANT-A ueber die Kennung 'user-b2' (gehoert TENANT-B) allein betrifft ${foreignDeleteAffected} Zeile(n)`, + ); + + // tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar — die Eigenschaft, auf + // der sowohl die umgestellte Plattform-Administratorsicht als auch die + // Standardgruppen-Reparatur beim Start stehen (Befund F, Befund K). + // Zusaetzlich im Systemkatalog geprueft, damit die Aussage nicht allein + // daran haengt, dass dieser Abschnitt selbst keinen Zeilenschutz + // eingeschaltet hat. + const unboundTenantRows = await prisma.$queryRaw`SELECT id FROM "Tenant" ORDER BY id`; + const [rlsRow] = + await prisma.$queryRaw`SELECT relrowsecurity FROM pg_class WHERE relname = 'Tenant'`; + const tenantReadable = unboundTenantRows.length === 2 && rlsRow?.relrowsecurity === false; + report( + results, + 'tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar', + tenantReadable, + `ungebundenes SELECT auf "Tenant" liefert ${unboundTenantRows.length} Zeile(n): ${JSON.stringify(unboundTenantRows.map((r) => r.id))}; pg_class.relrowsecurity fuer "Tenant" = ${JSON.stringify(rlsRow?.relrowsecurity)}`, + ); + + // user-fan-out-je-mandant-gebunden-liefert-alle-zeilen — die + // Nachbildung der umgestellten Plattform-Administratorsicht: erst die + // Mandanten ungebunden lesen, dann je Mandant EIN gebundener SELECT, + // dann die Ergebnisse vereinigen. Die Gesamtmenge wird ueber die + // Wartungsrolle (mit BYPASSRLS) gemessen, nicht angenommen. + const tenantIds = unboundTenantRows.map((r) => r.id).sort(); + let fannedOutUsernames = []; + for (const tenantId of tenantIds) { + const rows = await forTenantQuery(prisma, tenantId, (tx) => + tx.$queryRaw`SELECT username FROM "User" ORDER BY username`, + ); + fannedOutUsernames.push(...rows.map((r) => r.username)); + } + fannedOutUsernames = fannedOutUsernames.sort(); + + const groundTruthUsernames = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => { + const rows = await db.$queryRaw`SELECT username FROM "User" ORDER BY username`; + return rows.map((r) => r.username).sort(); + }, + ); + + const fanOutMatches = + fannedOutUsernames.length === groundTruthUsernames.length && + fannedOutUsernames.every((u, i) => u === groundTruthUsernames[i]); + report( + results, + 'user-fan-out-je-mandant-gebunden-liefert-alle-zeilen', + fanOutMatches, + `Vereinigung der je-Mandant gebundenen SELECTs liefert ${fannedOutUsernames.length} Benutzernamen: ${JSON.stringify(fannedOutUsernames)}; Gesamtmenge (ueber die Wartungsrolle mit BYPASSRLS gemessen) sind ${groundTruthUsernames.length}: ${JSON.stringify(groundTruthUsernames)}`, + ); + } finally { + await prisma.$disconnect(); + } +} + /** * Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen * den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte @@ -1623,6 +1942,7 @@ async function main() { await runGroupsAreaChecks(adminUrl, scratchRoleUrlString, results); await runTendersAreaChecks(adminUrl, scratchRoleUrlString, results); await runDkvAreaChecks(adminUrl, scratchRoleUrlString, results); + await runUserAreaChecks(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 367136f..04e2e34 100644 --- a/docs/mandantentrennung-etappe2-fehlerrichtung.md +++ b/docs/mandantentrennung-etappe2-fehlerrichtung.md @@ -912,6 +912,276 @@ entscheidet sie nicht — er bindet dienst-intern, wie `ldap`, `groups` und Geprüft und bewusst gelassen — eine Lösung (Unterverzeichnisse je Mandant, Umzug der Bestandsdateien) ist ein eigener Auftrag, siehe (d4). +## Bereich user + +Dieser Abschnitt erweitert die Kritikschrift um den Bereich `user` +(Quick-Task 260910-das) und beschreibt ihn zum Zeitpunkt seiner Umstellung. +Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt +beantwortet sie erneut, für den Bereich, in dem das plattformweite +Eindeutigkeitsproblem tatsächlich wohnt: `username` und `email` sind im +Schema plattformweit eindeutig, nicht je Mandant, und dieser Bereich enthält +als einziger BEIDE Formen gleichzeitig — Wege, die binden MÜSSEN +(Benutzerverwaltung je Mandant), und einen Weg, der binden NICHT DARF +(Nachschlagen auf dem plattformweit eindeutigen Schlüssel `username`). Ein +Quer-Schreiben ist hier keine Offenlegung, sondern eine Rechteausweitung +über die Mandantengrenze hinweg — die schwerste Klasse dieses ganzen +Vorhabens. + +### (u1) Die Messung + +Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen siebten +Abschnitt (`runUserAreaChecks`) erweitert. Die Tabelle `"User"` wird dabei +NICHT neu angelegt — sie existiert bereits, vom Abschnitt des Anmeldewegs, +samt eingeschaltetem und erzwungenem Zeilenschutz, beiden +Eindeutigkeitsbedingungen (`username`, `email`) und zwei Testzeilen in zwei +Mandanten (Befund O). Dieser Abschnitt baut darauf auf: er hält die im +Anmeldeweg-Abschnitt von Hand getippte Policy GEGEN die aus der +ausgelieferten Migration `20260618112133_rls_policies` geschnittene Fassung +(beide sind nach Normalisierung von Leerraum und abschließendem Semikolon +wortgleich — keine Ersetzung nötig), legt eine Tabelle `"Tenant"` +ausdrücklich OHNE Zeilenschutz an (die zu messende Eigenschaft selbst), und +ergänzt je eine weitere Benutzerzeile pro Mandant. Tatsächlich beobachtete +Ausgabe dieses Laufs (2026-09-10, gegen `tessera-ctl-db-1`, Adresse +`172.19.0.2`): + +``` +user-policy-aus-migration-wortgleich: bestanden — die im Anmeldeweg-Abschnitt (runAuthLookupChecks) von Hand getippte Policy auf "User" ist nach Normalisierung von Leerraum und abschliessendem Semikolon wortgleich mit der aus 20260618112133_rls_policies geschnittenen +user-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["TENANT-A","TENANT-A"] +user-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "User" liefert 0 Zeile(n) +user-ungebundene-suche-nach-benutzername-liefert-keine-zeile: bestanden — ungebundenes SELECT ... WHERE username = 'bob' liefert 0 Zeile(n), obwohl der Benutzer existiert — der aufrufende Code liest daraus "diesen Benutzer gibt es nicht" und legt an +user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile: bestanden — forTenant(TENANT-A) liefert fuer WHERE username = 'bob' (gehoert TENANT-B) 0 Zeile(n) — die Kollisionspruefung meldet faelschlich "frei" +user-eindeutigkeit-greift-trotz-unsichtbarkeit: bestanden — gebundenes INSERT unter TENANT-A mit dem angeblich freien Benutzernamen "bob" wird abgewiesen mit SQLSTATE 23505 (Raw query failed. Code: `23505`. Message: `Unique constraint failed: `) — eine Eindeutigkeitsverletzung (23505), NICHT eine Zeilenschutz-Ablehnung: genau die im Auftrag beschriebene Kette +user-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen: Raw query failed. Code: `42501`. Message: `ERROR: new row violates row-level security policy for table "User"` +user-ungebundenes-einfuegen-abgelehnt: bestanden — ungebundenes INSERT mit gueltiger Mandantenkennung abgewiesen: Raw query failed. Code: `42501`. Message: `ERROR: new row violates row-level security policy for table "User"` +user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes UPDATE unter TENANT-A ueber die Kennung 'user-b' (gehoert TENANT-B) allein betrifft 0 Zeile(n) — die vorgeschalteten Besitz- und Rollenpruefungen bleiben deshalb erhalten und werden in Aufgabe 2/3 nicht durch die Datenbank ersetzt +user-gebundenes-loeschen-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes DELETE unter TENANT-A ueber die Kennung 'user-b2' (gehoert TENANT-B) allein betrifft 0 Zeile(n) +tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar: bestanden — ungebundenes SELECT auf "Tenant" liefert 2 Zeile(n): ["TENANT-A","TENANT-B"]; pg_class.relrowsecurity fuer "Tenant" = false +user-fan-out-je-mandant-gebunden-liefert-alle-zeilen: bestanden — Vereinigung der je-Mandant gebundenen SELECTs liefert 4 Benutzernamen: ["alice","bob","carol","dave"]; Gesamtmenge (ueber die Wartungsrolle mit BYPASSRLS gemessen) sind 4: ["alice","bob","carol","dave"] +Alle 53 Pruefungen bestanden. +``` + +Drei Zeilen tragen diesen Abschnitt und werden hier ausdrücklich benannt und +auseinandergehalten, weil sie zusammen die im Auftrag beschriebene Kette +sind: + +- **Die Belegzeile zur Unsichtbarkeit**, `user-ungebunden-null-zeilen`: der + IDENTISCHE `SELECT "tenantId" FROM "User"` ohne vorheriges `set_config` + liefert **0 Zeilen**, nicht etwa die 4 tatsächlich vorhandenen — an der + echten, ausgelieferten Policy gemessen, nicht an einer im Werkzeug + nachgebauten Hilfstabelle. Genau dieselbe Unsichtbarkeit trifft die + ungebundene Suche nach einem GÜLTIGEN Benutzernamen + (`user-ungebundene-suche-nach-benutzername-liefert-keine-zeile`) — die + Form, die die Erstanlage-Prüfung beim Start heute benutzt. +- **Die Zeile zum falschen „frei"**, + `user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile`: + dieselbe Suche, gebunden an TENANT-A, nach dem Benutzernamen `bob` + (gehört TENANT-B), liefert ebenfalls 0 Zeilen — der Moment, in dem eine + Kollisionsprüfung fälschlich „frei" meldet, obwohl der Name vergeben ist. +- **Die Zeile zum harten Eindeutigkeitsfehler**, + `user-eindeutigkeit-greift-trotz-unsichtbarkeit`: unmittelbar danach ein + gebundenes `INSERT` unter TENANT-A mit genau diesem, angeblich freien + Benutzernamen `bob`. Die Ablehnung trägt SQLSTATE **23505** + (Eindeutigkeitsverletzung) — gelesen aus `err.meta.code`, nicht aus + `err.code` (das bei einem fehlgeschlagenen `$executeRaw` immer den + generischen Prisma-Code `P2010` trägt, empirisch gegen + `tessera-ctl-db-1` geprüft, siehe Kopfkommentar von `sqlStateOf()` im + Werkzeug) — und NICHT SQLSTATE 42501 (Zeilenschutz-Ablehnung), die dieser + Abschnitt in zwei anderen Zeilen ebenfalls misst + (`user-gebundenes-einfuegen-fremder-mandant-abgelehnt`, + `user-ungebundenes-einfuegen-abgelehnt`). Diese Unterscheidung ist der + Kern der Messung: nur die erste ist die im Auftrag beschriebene Kette, + die zweite wäre eine ganz andere Geschichte. + +Zusätzlich gemessen, statt behauptet: `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar` +hält sowohl das ungebundene `SELECT` (2 von 2 Zeilen sichtbar) als auch den +Systemkatalog (`pg_class.relrowsecurity` für `"Tenant"` = `false`) +gegeneinander — die Aussage hängt damit nicht allein daran, dass dieser +Abschnitt selbst keinen Zeilenschutz eingeschaltet hat. +`user-fan-out-je-mandant-gebunden-liefert-alle-zeilen` bildet die in +Aufgabe 2/3 gewählte Form der Plattform-Administratorsicht nach (Mandanten +ungebunden lesen, je Mandant EIN gebundener `SELECT`, Ergebnisse +vereinigen) und hält das Ergebnis gegen eine über die Wartungsrolle (mit +`BYPASSRLS`) gemessene Gesamtmenge, nicht gegen eine angenommene Zahl — beide +Mengen sind identisch: `["alice","bob","carol","dave"]`. + +**TEIL 2, Beleg statt Behauptung für Befund B** — keine Transaktion in +diesem Bereich: + +``` +$ grep -rn '\$transaction(' apps/api/src/user --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 D** — die Aufrufermessung für +`findByUsername`: + +``` +$ grep -rn "findByUsername" apps/api/src packages +apps/api/src/user/user.service.ts:21: async findByUsername(username: string) { +``` + +Genau EIN Treffer, die Definition selbst — kein Aufrufer. Etappe 1 +(260909-eor) hat den Anmeldeweg auf die drei schmalen +SECURITY-DEFINER-Funktionen umgezogen; `auth.service.ts` sucht seither über +`auth_lookup_user_by_username` und nicht mehr über diese Methode. Die +Messung bestätigt Befund D unverändert: die Methode bleibt ungebunden +(Entscheidung (c) aus den Planungszeit-Befunden), ihr Kopfkommentar wird in +Aufgabe 2 richtiggestellt. + +### (u2) Signaltabelle je umgestelltem Pfad + +| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort | +|---|---|---| +| `UserService.findById` | Der gebundene `findUnique` liefert `null` statt des eigenen Benutzers | `GET /users/:id` liefert `404 User not found`, obwohl der Benutzer existiert | +| `UserService.create` | Betrifft nicht das Lesen — ein gebundenes `INSERT` mit fremder Mandantenkennung wird von der Policy abgewiesen (`user-gebundenes-einfuegen-fremder-mandant-abgelehnt`) | `POST /users` scheitert mit einer Datenbank-Ablehnung statt einer verständlichen Meldung, sollte die Mandantenkennung je falsch ankommen — nach heutigem Code (Befund G, Selbstbedienungswege ausgenommen) nicht erreichbar, weil `tenantId` aus dem Sitzungsnachweis bzw. der ausdrücklichen SUPER_ADMIN-Übersteuerung stammt | +| `UserService.update` | Der gebundene `update` über die Kennung allein trifft eine fremde Zeile still (0 betroffene Zeilen, `user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen`), nicht laut | `PATCH /users/:id` würfe ohne die vorgeschaltete Prüfung in `user.controller.ts` keinen Fehler, sondern liefe ins Leere — die vorgeschaltete Mandantenprüfung bleibt deshalb Pflicht | +| `UserService.deactivate`/`delete` | Dieselbe stille Form wie `update` | `DELETE /users/:id` bzw. das Deaktivieren würde ohne die vorgeschaltete Prüfung 0 Zeilen treffen, ohne Fehler | +| Neue Methode: Plattform-Administratorsicht, Liste | Der Schleifentreiber (`tenant.findMany`, bewusst ungebunden) liefert 0 Mandanten statt der tatsächlich vorhandenen | Die Benutzerliste des `SUPER_ADMIN` ist für die gesamte Plattform leer, obwohl Mandanten mit Benutzern existieren — `user-fan-out-je-mandant-gebunden-liefert-alle-zeilen` misst die Vereinigung, nicht den Treiber; ein leerer Treiber ist eine Eigenschaft der Mandantentabelle, nicht dieses Bereichs | +| Neue Methode: Plattform-Administratorsicht, Kennungs-Auflösung | Läuft der gebundene Lesezugriff für den Mandanten des Zielbenutzers leer, findet die Methode den Benutzer nicht | `GET /users/:id`, `PATCH /users/:id`, `DELETE /users/:id` liefern für den `SUPER_ADMIN` `404`, obwohl der Benutzer existiert | +| `user.controller.ts`, `findAll` (ADMIN-Zweig) | Der gebundene `findMany` liefert 0 Benutzer statt der tatsächlich vorhandenen | Die Benutzerliste ist für einen Mandanten-Administrator leer — eine leere Liste sieht auf einer frischen Installation wie der Normalzustand aus | +| Selbstbedienungswege (Bild hochladen/löschen, Akzentfarbe, Bild ausliefern) | Der gebundene Zugriff über die eigene Kennung aus dem Sitzungsnachweis liefert 0 Zeilen | `GET /users/me/avatar` liefert die vorhandene `404 No avatar set`, obwohl ein Bild hinterlegt ist — harmlos, siehe (u3) | +| `UserService.findByUsername` (bewusst UNGEBUNDEN) | Betrifft nicht diesen Pfad selbst — er bindet nicht und liefert deshalb weiterhin korrekt. Das Risiko läge in einer KÜNFTIGEN Bindung | Würde man ihn binden: eine gebundene Suche nach einem fremden Benutzernamen meldete „frei" — hier ausdrücklich als Grenze festgehalten, nicht als heute beobachtbares Verhalten (Befund D) | +| `AdminSeedService`, Erstanlage-Prüfung (bewusst UNGEBUNDEN) | Betrifft nicht diesen Pfad selbst — er bindet nicht, läuft aber NACH dem Scharfschalten für JEDEN Administrator ins Leere, weil ohne Mandantenkontext keine Zeile sichtbar ist | Siehe (u3) — die schwerste Ausprägung dieses gesamten Bereichs | +| `AdminSeedService`, beide Zugriffe auf `tenant` (bewusst UNGEBUNDEN) | Betrifft nicht diese Pfade selbst — `Tenant` trägt keinen Zeilenschutz, ein ungebundenes Lesen/Schreiben bleibt korrekt | `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar` misst die tragende Eigenschaft | + +### (u3) Welcher Code Leere als Abwesenheit deutet + +Dies ist der Kern dieses Abschnitts. Die sechs Stellen aus Befund L, +namentlich benannt und nach ihrer Wirkung sortiert: + +**Startverhindernd (eine Stelle, die schwerste Ausprägung im ganzen +Vorhaben):** + +1. **`AdminSeedService.seedAdmin()`, die Erstanlage-Prüfung** — die + vollständige, im Auftrag beschriebene Kette in Reinform: eine + unsichtbare Zeile wird als Abwesenheit gelesen + (`user.findUnique({ where: { username } })` liefert nach dem + Scharfschalten `null`, nicht weil der Administrator fehlt, sondern weil + ohne gesetzten Mandantenkontext keine Zeile der Benutzertabelle sichtbar + ist — `user-ungebundene-suche-nach-benutzername-liefert-keine-zeile`), + die natürliche Folgehandlung ist Anlegen (Schritt 4 läuft, weil Schritt + 3 entfällt), und das Anlegen scheitert hart an der plattformweiten + Eindeutigkeit von `username` + (`user-eindeutigkeit-greift-trotz-unsichtbarkeit`, SQLSTATE 23505, KEINE + Zeilenschutz-Ablehnung). Weil `seedAdmin()` bewusst NICHT gekapselt ist + — der Dateikopf sagt ausdrücklich, ein Fehlschlag solle den Start + weiterhin laut scheitern lassen —, wird aus dieser einen unsichtbaren + Zeile eine **Startsperre**: die Anwendung startet nach dem + Scharfschalten nicht mehr, für jede bestehende Installation mit + gesetzten Administrator-Umgebungswerten. Eine Absicht (laut scheitern) + wird damit ungewollt zu einer Sperre für den Normalfall. + +**Kollisionserzeugend (drei Stellen — dieselbe Kette, an anderen Stellen im +Bereich):** + +2. **`user.controller.ts`, `findAll` im ADMIN-Zweig** — eine leere Liste + heißt „dieser Mandant hat keine Benutzer". Ein Administrator, der seine + Kollegen nicht mehr sieht, legt sie erneut an; jede dieser Anlagen + kollidiert auf `username` bzw. `email`. Die Oberfläche zeigt dabei + nichts Auffälliges: eine leere Benutzerliste ist auf einer frischen + Installation der Normalzustand. +3. **`user.controller.ts`, `findAll` im SUPER_ADMIN-Zweig** — dieselbe + Leere, eine Ebene höher: die Plattformverwaltung sieht eine + Installation ohne jeden Benutzer und würde, bliebe die neue + übergreifende Methode ungebunden statt als gebundene Schleife gebaut, + ebenfalls zum Neuanlegen verleiten. +4. **Eine gebundene Suche nach Benutzername oder Adresse** (Befund D, + `user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile`) + — meldet „frei" für einen Namen, den es gibt. Die Kollisionsprüfung wird + zur Kollisionserzeugung; genau deshalb bleibt `findByUsername` + ungebunden und übersetzt `UserService.create`/`update` die + Eindeutigkeitsverletzung stattdessen an ihrem eigenen Erzeugungspunkt in + eine verständliche Meldung (Aufgabe 2). + +**Bereits gebunden, als Beleg benannt (eine Stelle, nicht Gegenstand +dieses Durchlaufs):** + +5. **`ldap.service.ts`, `upsertMappedUser`** — bereits gebunden, seit + 260909-ipc, in einem ANDEREN Bereich, hier nur zu benennen und nicht + anzufassen: die Identitätssuche entscheidet über Anlegen-oder- + Aktualisieren und läuft in dieselbe Kette. Sie ist der Beleg, dass die + Kette nicht erst nach dem Scharfschalten existiert — die + Suchbedingung trägt bereits heute den Mandanten, ein fremder Halter ist + also bereits heute unsichtbar. Die Übersetzung der + Eindeutigkeitsverletzung gehört deshalb an den gemeinsamen Anlegepunkt + in `user.service.ts` (Aufgabe 2), der in diesem Plan ohnehin angefasst + wird — dort und nicht in `ldap`. + +**Harmlos (eine Stelle):** + +6. **Die Selbstbedienungswege für Bild und Akzentfarbe** (Befund G) — ein + leerer Lesezugriff heißt „kein Bild hinterlegt". Harmlos in der + Wirkung, aber vollständigkeitshalber in der Tabelle (u2) festgehalten. + +**Was ein GEBUNDENER Nachschlageweg auf `username` mit dem Anmeldeweg +machen würde, und warum die Frage hier gegenstandslos ist:** der Anmeldeweg +läuft seit Etappe 1 (260909-eor) über die drei SECURITY-DEFINER-Funktionen +und nicht mehr über `UserService.findByUsername` — belegt durch die +Aufrufermessung aus TEIL 3 oben (genau ein Treffer, die Definition selbst), +nicht behauptet. Würde `findByUsername` dennoch gebunden, säße das Problem +nicht im Anmeldeweg (der diese Methode gar nicht mehr aufruft), sondern +genau in der unter Punkt 4 beschriebenen Kollisionserzeugung — derselbe +Grund, aus dem `resolveEmailForWrite` im Bereich `ldap` ungebunden bleibt. + +**Gegenrichtung, ebenfalls nachgesehen und in diese Kritikschrift +gehörend:** ein gebundenes Ändern oder Löschen über die Kennung allein +trifft eine fremde Zeile NICHT still im Sinne einer Zeilenschutz-Ablehnung, +sondern schlicht mit null betroffenen Zeilen +(`user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen`, +`user-gebundenes-loeschen-ueber-kennung-allein-trifft-null-zeilen`) — die +vorgeschalteten Besitz- und Rollenprüfungen in `user.controller.ts` bleiben +deshalb Pflicht und werden in Aufgabe 3 nicht durch die Datenbank ersetzt. +Ein gebundenes Einfügen mit fremder Mandantenkennung wird laut abgewiesen +(`user-gebundenes-einfuegen-fremder-mandant-abgelehnt`, SQLSTATE 42501). +Das sind die lauten Stellen, und dieser Abschnitt besteht nicht nur aus +Alarm. + +### (u4) Was dieser Durchlauf bewusst nicht löst + +Die plattformweite Eindeutigkeit von `username` und `email` selbst — die +Ursache, aus der jede Ausnahme dieses Plans folgt. Die ehrliche Reparatur +wäre eine Schemaänderung (eine Eindeutigkeit mit Mandantendimension); das +ist eine Produktentscheidung — darf dieselbe Adresse zwei Mandanten +gehören — und sie ist für Etappe 3 bereits vorgemerkt. Hier wird sie +festgehalten, nicht entschieden, und ausdrücklich nicht durch eine +Migration vorweggenommen (Broken-Windows-Register, Aufgabe 2). + +Die Übergabe in den noch nicht umgestellten Bereich `auth`: `auth.service.ts` +ist aus demselben Grund `gemischt` (Befund E) und NICHT Gegenstand dieses +Plans. Die Grenze ist gemessen, nicht aus Erinnerung gezogen: die Datei hat +drei bereits über `forTenant()` gebundene Schreibzugriffe (Anmeldezeitstempel, +Kennwortwechsel nach Zurücksetzen) und fünf noch ungebundene Zugriffe auf +`user`, die zu `getMe`, `changePassword` und `adminResetPassword` gehören. +Die drei Anmelde-/Zurücksetz-Nachschlagewege laufen über `$queryRaw` auf die +drei SECURITY-DEFINER-Funktionen. Dieser Plan fasst `auth.service.ts` an +KEINER Stelle an. + +Die offene Architekturfrage `req.tenantPrisma` — auch der Bereich `user` +entscheidet sie nicht. Er bindet dienst-intern, wie `ldap`, `groups`, +`tenders` und `dkv` es vormachen. + +### (u5) Was dieser Durchlauf bewusst NICHT anfasst + +- `auth.service.ts` an keiner Stelle (siehe (u4)). +- Die drei SECURITY-DEFINER-Funktionen aus Etappe 1 und ihre Rechte nicht — + der Anmeldeweg bleibt unberührt, belegt durch die Aufrufermessung aus + TEIL 3 oben. +- Schema und Migrationen nicht. +- Die Verdrängung im gemeinsamen Ablagverzeichnis der Profilbilder + (`user-files/avatars/`) nicht — anders als bei den Ausfuhrdateien des + Bereichs `dkv` (Befund F dort) ist die Dateibenennung hier + `{userId}.{ext}` und damit bereits kollisionsfrei über Mandanten hinweg; + es ist ohnehin keine Bindungsfrage. + +Jeweils mit der Feststellung, dass sie geprüft und bewusst gelassen sind — +nicht übersehen. + ## Verweis Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang