From bf5fc4d4c71229243de6d40a44ec7cd3ce57bcd7 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 11 Sep 2026 09:48:44 +0200 Subject: [PATCH] feat(quick-260911-cwh): Fehlerrichtung Bereich calendar messen (Aufgabe 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rls-scratch-check.mjs: elfter Abschnitt runCalendarAreaChecks mit 13 namentlich benannten Pruefungen gegen die aus 20260909140000_rls_remaining_tenant_tables geschnittene Regel, davon 4 ueber den generierten Client an einer schemagleichen Wegwerf-Tabelle (17 Spalten, gegen schema.prisma laufzeitgeprueft); Laufzeitpruefung, dass 20260910120000 keine eigene CalendarSource-Regel traegt (Messfalle 260910-jab) - Alle 101 Pruefungen bestehen (88 bisherige + 13 neue) - docs/mandantentrennung-etappe2-fehlerrichtung.md: Abschnitt "## Bereich calendar" mit (k1)-(k5) — Messung, Signaltabelle je Pfad, Leere-als-Abwesenheit in Backend UND Frontend samt Fehlerverschluckung, bewusst nicht geloest (Cache-Schluessel-Urteil, Zugangsdaten-Erhaltung, fehlende Benutzerdimension, 403/404), bewusst nicht angefasst - Wettlauf-Fehlerklasse gemessen: PrismaClientKnownRequestError (P2025), NICHT PrismaClientUnknownRequestError wie im Bereich dashboard — Aufgabe 2 braucht deshalb keine neue Fehleruebersetzung fuer die Besitzpruefungen Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR --- apps/api/scripts/rls-scratch-check.mjs | 332 ++++++++++++++++++ ...andantentrennung-etappe2-fehlerrichtung.md | 259 ++++++++++++++ 2 files changed, 591 insertions(+) diff --git a/apps/api/scripts/rls-scratch-check.mjs b/apps/api/scripts/rls-scratch-check.mjs index 2711fcc..4aca9a4 100644 --- a/apps/api/scripts/rls-scratch-check.mjs +++ b/apps/api/scripts/rls-scratch-check.mjs @@ -42,6 +42,7 @@ const SCRATCH_ROLE_NAME = 'tessera_rls_scratch_role'; const SCRATCH_ROLE_PASSWORD = 'scratch_only_local_never_reused'; const MIGRATIONS_DIR = join(__dirname, '../prisma/migrations'); const PRISMA_BIN = join(__dirname, '../node_modules/.bin/prisma'); +const SCHEMA_PRISMA_PATH = join(__dirname, '../prisma/schema.prisma'); /** * Fuehrt ein mehrteiliges SQL-Skript (mehrere Anweisungen, DO $$ ... $$ @@ -2765,6 +2766,336 @@ async function runDashboardAreaChecks(adminUrl, scratchRoleUrl, results) { } } +/** + * Liest die Feldnamen eines Prisma-Modellblocks direkt aus + * `apps/api/prisma/schema.prisma`, statt sie im Werkzeug zu wiederholen + * (Pruefung 8 in `runCalendarAreaChecks`, Lehre aus Pruefung 5b im Bereich + * `dashboard`: der generierte Client waehlt standardmaessig JEDE Spalte des + * Modells aus und scheitert mit P2022 an jeder fehlenden — eine + * Wegwerf-Tabelle mit unvollstaendigem Spaltensatz wuerde das nie zeigen, + * Roh-SQL merkt es ohnehin nie). Feldname = erstes Wort jeder nicht-leeren + * Zeile im Modellblock, die nicht mit `@@` (Modell-Attribute wie + * `@@index`) und nicht mit `//` (Kommentarzeile) beginnt. + */ +function readSchemaModelFieldNames(modelName) { + const schemaSource = readFileSync(SCHEMA_PRISMA_PATH, 'utf-8'); + const re = new RegExp(`model ${modelName} \\{([\\s\\S]*?)\\n\\}`); + const match = schemaSource.match(re); + if (!match) return []; + const fields = []; + for (const rawLine of match[1].split('\n')) { + const line = rawLine.trim(); + if (!line) continue; + if (line.startsWith('@@')) continue; + if (line.startsWith('//')) continue; + const firstWord = line.split(/\s+/)[0]; + if (firstWord) fields.push(firstWord); + } + return fields; +} + +/** + * Aufgabe 1 (260911-cwh) — misst die zwoelf im Plan genannten + * Verhaltensweisen des Bereichs `calendar` unter der Rolle ohne BYPASSRLS, + * an der Regel WORTGLEICH aus der ausgelieferten Migration + * `20260909140000_rls_remaining_tenant_tables` geschnitten — NICHT dem + * Werkzeug nachgetippt (vgl. runDkvAreaChecks/runDashboardAreaChecks). + * + * Prueft zur Laufzeit zusaetzlich die Messfalle aus 260910-jab: die + * `*_rls_widen_membership_grant_and_platform_read`-Migration (260910-jab) + * darf KEINE eigene Regel fuer "CalendarSource" enthalten (Befund G) — + * faende sich dort eine, waere der Regelstand nicht mehr eindeutig auf + * 20260909140000 zurueckzufuehren und dieser Abschnitt braeche ab, statt + * die abgeloeste Regel weiterzumessen. + * + * Legt die Wegwerf-Tabelle "CalendarSource" selbst neu an, mit SAEMTLICHEN + * Spalten des Modells (nicht nur denen, die Roh-SQL braucht — Pruefung 8, + * die dashboard-Lehre aus Pruefung 5b) und setzt auf keiner Tabelle eines + * anderen Abschnitts auf: er ist ein Blatt in der Aufrufkette, muss NACH + * runDashboardAreaChecks() und VOR runTransactionShapeMeasurement() laufen + * (siehe Aufrufkette in main()) — Letztere setzt weiterhin auf der von + * runGroupsAreaChecks() angelegten Tabelle "Group" auf, dieser Abschnitt + * aendert daran nichts, und keine spaetere Pruefung setzt auf der hier + * angelegten Tabelle auf. + */ +async function runCalendarAreaChecks(adminUrl, scratchRoleUrl, results) { + const widenMigrationSql = readRlsWidenMigrationSql(); + const widenHasOwnCalendarSourcePolicy = + widenMigrationSql && Boolean(extractPolicySql(widenMigrationSql, 'CalendarSource')); + report( + results, + 'calendarsource-regelstand-eindeutig', + !widenHasOwnCalendarSourcePolicy, + widenHasOwnCalendarSourcePolicy + ? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt eine EIGENE Regel fuer "CalendarSource" — der Regelstand ist nicht mehr eindeutig auf 20260909140000_rls_remaining_tenant_tables zurueckzufuehren, Messung abgebrochen statt die abgeloeste Regel weiterzumessen' + : 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "CalendarSource" (Befund G) — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand', + ); + if (widenHasOwnCalendarSourcePolicy) { + return; + } + + const remainingMigrationSql = readRemainingTenantTablesMigrationSql(); + const calendarSourcePolicy = remainingMigrationSql + ? extractPolicySql(remainingMigrationSql, 'CalendarSource') + : null; + + if (!calendarSourcePolicy) { + report( + results, + 'calendarsource-policy-aus-migration-gefunden', + false, + 'CREATE POLICY fuer "CalendarSource" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden', + ); + return; + } + + await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => { + await db.$executeRawUnsafe(` + CREATE TABLE "CalendarSource" ( + id text PRIMARY KEY, + "userId" text NOT NULL, + "tenantId" text NOT NULL, + name text NOT NULL, + type text NOT NULL, + "exchangeMode" text, + domain text, + url text NOT NULL, + username text, + "encryptedPassword" text, + color text DEFAULT '#3B82F6', + "isVisible" boolean NOT NULL DEFAULT true, + "syncIntervalMin" integer NOT NULL DEFAULT 15, + "lastSyncAt" timestamp(3), + "lastSyncError" text, + "createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `); + + await db.$executeRawUnsafe(`ALTER TABLE "CalendarSource" ENABLE ROW LEVEL SECURITY;`); + await db.$executeRawUnsafe(`ALTER TABLE "CalendarSource" FORCE ROW LEVEL SECURITY;`); + await db.$executeRawUnsafe(calendarSourcePolicy); + await db.$executeRawUnsafe( + `GRANT SELECT, INSERT, UPDATE, DELETE ON "CalendarSource" TO ${SCRATCH_ROLE_NAME}`, + ); + + // Zwei Zeilen unter TENANT-A mit VERSCHIEDENEN Benutzerkennungen + // (Befund G — die Regel kennt keine Benutzerdimension), je mit + // gesetztem encryptedPassword-Platzhalter, damit Pruefung 3 zeigen + // kann, dass ein Kollege desselben Mandanten die verschluesselten + // Zugangsdaten sieht; eine Zeile unter TENANT-B fuer die + // Mandantengrenze. + await db.$executeRawUnsafe(` + INSERT INTO "CalendarSource" (id, "userId", "tenantId", name, type, url, "encryptedPassword", "isVisible") VALUES + ('source-a1', 'user-a1', 'TENANT-A', 'Quelle A1', 'ics', 'https://example.invalid/a1.ics', 'enc(a1-passwort-platzhalter)', true), + ('source-a2', 'user-a2', 'TENANT-A', 'Quelle A2', 'ics', 'https://example.invalid/a2.ics', 'enc(a2-passwort-platzhalter)', true), + ('source-b1', 'user-b1', 'TENANT-B', 'Quelle B1', 'ics', 'https://example.invalid/b1.ics', 'enc(b1-passwort-platzhalter)', true); + `); + }); + + // Pruefung 8 zuerst — faellt sie durch, sind die Client-Messungen (9-12) + // wertlos, deshalb steht sie vor ihnen und die Funktion bricht ab, wenn + // sie fehlschlaegt. + const schemaFields = readSchemaModelFieldNames('CalendarSource'); + const tableColumns = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => { + const rows = await db.$queryRaw` + SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'CalendarSource' + `; + return rows.map((r) => r.column_name).sort(); + }, + ); + const schemaFieldsSorted = [...schemaFields].sort(); + const columnsMatch = + schemaFieldsSorted.length > 0 && + schemaFieldsSorted.length === tableColumns.length && + schemaFieldsSorted.every((f, i) => f === tableColumns[i]); + report( + results, + 'calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients', + columnsMatch, + `Schema-Felder aus schema.prisma (model CalendarSource, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}`, + ); + if (!columnsMatch) { + return; + } + + const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); + try { + // 1 + 3: calendarsource-gebunden-nur-eigener-mandant UND + // calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar — + // eine Abfrage, zwei Aussagen. Pruefung 3 zu bestehen IST das erwartete + // Ergebnis: die Regel kennt keine Benutzerdimension. + const rowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw`SELECT id, "userId", "tenantId", "encryptedPassword" FROM "CalendarSource" ORDER BY id`, + ); + report( + results, + 'calendarsource-gebunden-nur-eigener-mandant', + rowsForA.length === 2 && rowsForA.every((r) => r.tenantId === 'TENANT-A'), + `forTenant(TENANT-A) liefert ${rowsForA.length} Zeile(n): ${JSON.stringify(rowsForA.map((r) => r.id))}`, + ); + const a2Row = rowsForA.find((r) => r.userId === 'user-a2'); + const a2CredentialsVisible = Boolean(a2Row && a2Row.encryptedPassword); + report( + results, + 'calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar', + a2CredentialsVisible, + `forTenant(TENANT-A) liefert die Quelle von 'user-a2' (anderer Benutzer, gleicher Mandant) mit encryptedPassword=${JSON.stringify(a2Row?.encryptedPassword)} — die Regel auf "CalendarSource" kennt keine Benutzerdimension, die verschluesselten Zugangsdaten eines Kollegen DESSELBEN Mandanten sind auf Datenbankebene lesbar; die anwendungsseitige Filterung ueber die Benutzerkennung bleibt deshalb der einzige Schutz, bis die Etappe-3-Entscheidung (2) die Benutzerdimension nachzieht`, + ); + + // 2: calendarsource-ungebunden-null-zeilen — die tragende Belegzeile. + const unboundRows = await prisma.$queryRaw`SELECT "tenantId" FROM "CalendarSource"`; + report( + results, + 'calendarsource-ungebunden-null-zeilen', + unboundRows.length === 0, + `ungebundener SELECT auf "CalendarSource" liefert ${unboundRows.length} Zeile(n), tatsaechlich vorhanden sind 3`, + ); + + // 4: calendarsource-ungebundene-einzelabfrage-ueber-kennung-liefert-keine-zeile + // — die Datenbankseite der drei Besitzpruefungen (Befund I). + const unboundSingleRow = await prisma.$queryRaw`SELECT id FROM "CalendarSource" WHERE id = 'source-b1'`; + report( + results, + 'calendarsource-ungebundene-einzelabfrage-ueber-kennung-liefert-keine-zeile', + unboundSingleRow.length === 0, + `ungebundenes SELECT ueber die Kennung 'source-b1' (vorhanden) liefert ${unboundSingleRow.length} Zeile(n) — die Datenbankseite der drei Besitzpruefungen: ein ungebundenes Nachschlagen ueber eine vorhandene Kennung liefert null Zeilen, das ist der Weg in NotFoundException`, + ); + + // 5: calendarsource-gebundenes-einfuegen-fremder-mandant-abgelehnt + let foreignInsertRejected = false; + let foreignInsertDetail = ''; + try { + await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => + tx.$executeRaw`INSERT INTO "CalendarSource" (id, "userId", "tenantId", name, type, url) VALUES ('source-rejected', 'user-a1', 'TENANT-B', 'Sollte abgewiesen werden', 'ics', 'https://example.invalid/rejected.ics')`, + ); + foreignInsertDetail = 'gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B ist NICHT fehlgeschlagen'; + } catch (err) { + const sqlState = sqlStateOf(err); + foreignInsertRejected = sqlState === '42501'; + foreignInsertDetail = `gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE ${sqlState ?? 'unbekannt'} (${err.message.trim()})`; + } + report( + results, + 'calendarsource-gebundenes-einfuegen-fremder-mandant-abgelehnt', + foreignInsertRejected, + foreignInsertDetail, + ); + + // 6: calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile + const foreignDeleteAffected = await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => tx.$executeRaw`DELETE FROM "CalendarSource" WHERE id = 'source-b1'`, + ); + const stillThereAfterDelete = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => { + const rows = await db.$queryRaw`SELECT id FROM "CalendarSource" WHERE id = 'source-b1'`; + return rows.length === 1; + }, + ); + report( + results, + 'calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile', + foreignDeleteAffected === 0 && stillThereAfterDelete, + `gebundenes DELETE unter TENANT-A ueber die Kennung 'source-b1' (gehoert TENANT-B) trifft ${foreignDeleteAffected} Zeile(n); ueber die Wartungsrolle ist die Zeile danach noch vorhanden: ${stillThereAfterDelete}`, + ); + + // 7: calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen + const foreignUpdateAffected = await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => tx.$executeRaw`UPDATE "CalendarSource" SET "lastSyncError" = 'x' WHERE id = 'source-b1'`, + ); + report( + results, + 'calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen', + foreignUpdateAffected === 0, + `gebundenes UPDATE ... WHERE id = 'source-b1' (gehoert TENANT-B) unter TENANT-A trifft ${foreignUpdateAffected} Zeile(n), lastSyncError bleibt unveraendert`, + ); + + // 9: calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant + const bound = buildInlineExtendedClient(prisma, 'TENANT-A'); + const clientBoundSources = await bound.calendarSource.findMany({ + where: { userId: 'user-a1', isVisible: true }, + }); + report( + results, + 'calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant', + clientBoundSources.length === 1 && clientBoundSources[0].id === 'source-a1', + `bound.calendarSource.findMany({ where: { userId: 'user-a1', isVisible: true } }) unter TENANT-A liefert ${clientBoundSources.length} Zeile(n): ${JSON.stringify(clientBoundSources.map((s) => s.id))} — die Abfrage, die fetchAndCacheEvents stellt`, + ); + + // 10: calendarsource-generierter-client-ungebundene-quellenliste-null-zeilen + const clientUnboundSources = await prisma.calendarSource.findMany({ + where: { userId: 'user-a1', isVisible: true }, + }); + report( + results, + 'calendarsource-generierter-client-ungebundene-quellenliste-null-zeilen', + clientUnboundSources.length === 0, + `dieselbe Abfrage auf dem UNGEBUNDENEN generierten Client liefert ${clientUnboundSources.length} Zeile(n) ohne Fehler — exakt der Wert, den getSources als "keine Quelle" und fetchAndCacheEvents als "keine Termine" weiterreicht`, + ); + + // 11: calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut + let updateOnInvisibleThrew = false; + let updateOnInvisibleDetail = ''; + try { + await bound.calendarSource.update({ + where: { id: 'source-b1' }, + data: { lastSyncError: 'x' }, + }); + updateOnInvisibleDetail = + 'bound.calendarSource.update unter TENANT-A auf die unter TENANT-B unsichtbare Zeile (source-b1) ist NICHT fehlgeschlagen'; + } catch (err) { + updateOnInvisibleThrew = true; + const ctor = err?.constructor?.name ?? 'unbekannt'; + updateOnInvisibleDetail = `bound.calendarSource.update unter TENANT-A auf die unter TENANT-B unsichtbare Zeile (source-b1) wirft ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()}`; + } + report( + results, + 'calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut', + updateOnInvisibleThrew, + updateOnInvisibleDetail, + ); + + // 12: calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt + let createSucceeded = false; + let createDetail = ''; + try { + const created = await bound.calendarSource.create({ + data: { + userId: 'user-a1', + tenantId: 'TENANT-A', + name: 'Neu angelegte Quelle', + type: 'ics', + url: 'https://example.invalid/neu.ics', + }, + }); + const readBack = await bound.calendarSource.findMany({ where: { id: created.id } }); + createSucceeded = readBack.length === 1; + createDetail = `bound.calendarSource.create unter TENANT-A gelingt (id=${created.id}, createdAt=${JSON.stringify(created.createdAt)}), gebunden lesbar: ${createSucceeded} — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig erzeugten Werte (id, createdAt, updatedAt) annimmt`; + } catch (err) { + createDetail = `bound.calendarSource.create unter TENANT-A ist fehlgeschlagen: ${err.message}`; + } + report( + results, + 'calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt', + createSucceeded, + createDetail, + ); + } finally { + await prisma.$disconnect(); + } +} + /** * Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen * den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte @@ -3001,6 +3332,7 @@ async function main() { await runUserAreaChecks(adminUrl, scratchRoleUrlString, results); await runModuleRegistryAreaChecks(adminUrl, scratchRoleUrlString, results); await runDashboardAreaChecks(adminUrl, scratchRoleUrlString, results); + await runCalendarAreaChecks(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 c33ad8d..f0a5725 100644 --- a/docs/mandantentrennung-etappe2-fehlerrichtung.md +++ b/docs/mandantentrennung-etappe2-fehlerrichtung.md @@ -2000,6 +2000,265 @@ Abweichung von seiner eigenen Auswahl liest, bleibt das unbemerkt. - Schema und Migrationen — geprüft und bewusst gelassen, keine Schemaänderung in dieser Etappe. +## Bereich calendar + +Dieser Abschnitt erweitert die Kritikschrift um den Bereich `calendar` +(Quick-Task 260911-cwh), den neunten Bereich der Etappe und den einzigen +Dienst, der nicht bloß Daten hält, sondern ZUGANGSDATEN zu fremden Servern — +die verschlüsselten Exchange-, CalDAV- und ICS-Anmeldungen eines Nutzers. +Ein Quer-Lesen ist hier nicht Offenlegung eines Termins, sondern Offenlegung +der Anmeldung einer anderen Firma bei ihrem Mailserver. Die umgekehrte +Fehlerrichtung sieht hier nicht wie ein Fehler aus, sondern wie ein leerer +Kalender — und das Frontend verstärkt das, siehe (k3). + +### (k1) Die Messung + +Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen elften +Abschnitt (`runCalendarAreaChecks`) erweitert, unmittelbar nach +`runDashboardAreaChecks` und vor `runTransactionShapeMeasurement` aufgerufen. +Er legt die Wegwerf-Tabelle `CalendarSource` selbst neu an, mit SÄMTLICHEN +17 Spalten des Modells (nicht nur den für Roh-SQL nötigen — die Lehre aus +Prüfung 5b im Bereich `dashboard`: der generierte Client wählt standardmäßig +JEDE Spalte des Modells aus und scheitert mit P2022 an jeder fehlenden), mit +der Regel `extractPolicySql()` WORTGLEICH aus der ausgelieferten Migration +`20260909140000_rls_remaining_tenant_tables` geschnitten, **gemessen am +Regelstand NACH der Migration +`20260910120000_rls_widen_membership_grant_and_platform_read`**. Diese +zweite Migration wird zur LAUFZEIT geprüft (Prüfung +`calendarsource-regelstand-eindeutig`), nicht nur zur Planungszeit behauptet: +`extractPolicySql(readRlsWidenMigrationSql(), 'CalendarSource')` liefert +`null` — jene Migration trägt KEINE eigene Regel für `CalendarSource`, der +Stand aus `20260909140000` ist weiterhin der ausgelieferte, aktuelle +Regelstand. Fände sich dort doch eine Regel, bräche der Abschnitt mit einer +FEHLGESCHLAGENEN Prüfung ab, statt die abgelöste Regel weiterzumessen — die +Messfalle aus 260910-jab. + +Vier der dreizehn neuen Prüfungen laufen über den GENERIERTEN Prisma-Client +(nicht nur an rohem SQL), weil `calendar.service.ts` in Wahrheit +`this.prisma.calendarSource.findMany/update/create()` aufruft, nicht +`$executeRaw` — dieselbe dashboard-Lehre: Prüfung 8 +(`calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients`) +vergleicht die zur Laufzeit aus `schema.prisma` gelesenen 17 Feldnamen des +Modells `CalendarSource` mit den tatsächlichen Spalten der Wegwerf-Tabelle +über `information_schema.columns` — sie steht VOR den Client-Prüfungen 9-12 +und bricht den Abschnitt ab, wenn sie durchfällt, weil alle folgenden +Client-Messungen sonst wertlos wären. + +Tatsächlich beobachtete Ausgabe dieses Laufs (2026-09-11, gegen +`tessera-ctl-db-1`, Adresse `172.19.0.2`, nur die dreizehn neuen Zeilen +dieses Abschnitts sowie die abschließende Summenzeile): + +``` +calendarsource-regelstand-eindeutig: bestanden — die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "CalendarSource" (Befund G) — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand +calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model CalendarSource, 17): ["color","createdAt","domain","encryptedPassword","exchangeMode","id","isVisible","lastSyncAt","lastSyncError","name","syncIntervalMin","tenantId","type","updatedAt","url","userId","username"]; Spalten der Wegwerf-Tabelle (17): ["color","createdAt","domain","encryptedPassword","exchangeMode","id","isVisible","lastSyncAt","lastSyncError","name","syncIntervalMin","tenantId","type","updatedAt","url","userId","username"] +calendarsource-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["source-a1","source-a2"] +calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert die Quelle von 'user-a2' (anderer Benutzer, gleicher Mandant) mit encryptedPassword="enc(a2-passwort-platzhalter)" — die Regel auf "CalendarSource" kennt keine Benutzerdimension, die verschluesselten Zugangsdaten eines Kollegen DESSELBEN Mandanten sind auf Datenbankebene lesbar; die anwendungsseitige Filterung ueber die Benutzerkennung bleibt deshalb der einzige Schutz, bis die Etappe-3-Entscheidung (2) die Benutzerdimension nachzieht +calendarsource-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "CalendarSource" liefert 0 Zeile(n), tatsaechlich vorhanden sind 3 +calendarsource-ungebundene-einzelabfrage-ueber-kennung-liefert-keine-zeile: bestanden — ungebundenes SELECT ueber die Kennung 'source-b1' (vorhanden) liefert 0 Zeile(n) — die Datenbankseite der drei Besitzpruefungen: ein ungebundenes Nachschlagen ueber eine vorhandene Kennung liefert null Zeilen, das ist der Weg in NotFoundException +calendarsource-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE 42501 (Invalid `prisma.$executeRaw()` invocation: Raw query failed. Code: `42501`. Message: `ERROR: new row violates row-level security policy for table "CalendarSource"`) +calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile: bestanden — gebundenes DELETE unter TENANT-A ueber die Kennung 'source-b1' (gehoert TENANT-B) trifft 0 Zeile(n); ueber die Wartungsrolle ist die Zeile danach noch vorhanden: true +calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes UPDATE ... WHERE id = 'source-b1' (gehoert TENANT-B) unter TENANT-A trifft 0 Zeile(n), lastSyncError bleibt unveraendert +calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant: bestanden — bound.calendarSource.findMany({ where: { userId: 'user-a1', isVisible: true } }) unter TENANT-A liefert 1 Zeile(n): ["source-a1"] — die Abfrage, die fetchAndCacheEvents stellt +calendarsource-generierter-client-ungebundene-quellenliste-null-zeilen: bestanden — dieselbe Abfrage auf dem UNGEBUNDENEN generierten Client liefert 0 Zeile(n) ohne Fehler — exakt der Wert, den getSources als "keine Quelle" und fetchAndCacheEvents als "keine Termine" weiterreicht +calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut: bestanden — bound.calendarSource.update unter TENANT-A auf die unter TENANT-B unsichtbare Zeile (source-b1) wirft PrismaClientKnownRequestError (code P2025): Invalid `prisma.calendarSource.update()` invocation: An operation failed because it depends on one or more records that were required but not found. No record was found for an update. +calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt: bestanden — bound.calendarSource.create unter TENANT-A gelingt (id=62b383e2-4768-48f2-997a-cc5251174f8b, createdAt="2026-09-11T07:44:53.869Z"), gebunden lesbar: true — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig erzeugten Werte (id, createdAt, updatedAt) annimmt +Alle 101 Pruefungen bestanden. +``` + +Dreizehn neue Prüfungen (101 = 88 + 13), nicht zwölf wie in der Aufzählung +des Plans namentlich vorgezeichnet — die dreizehnte +(`calendarsource-regelstand-eindeutig`) wurde ergänzt, weil sie die +Messfalle aus 260910-jab zur LAUFZEIT prüft statt sie nur als Planungsprosa +festzuhalten, derselbe Grund, aus dem der Bereich `dashboard` eine +dreizehnte Prüfung ergänzt hatte. + +**Die tragende Belegzeile ist `calendarsource-ungebunden-null-zeilen`:** der +IDENTISCHE `SELECT "tenantId" FROM "CalendarSource"` ohne vorheriges +`set_config` liefert **0 Zeilen**, nicht die 3 tatsächlich vorhandenen — an +der echten, ausgelieferten Policy gemessen. + +**Das Wettlauf-Ergebnis aus Befund H/K ist gemessen, nicht vorweggenommen — +und es ist eine ANDERE Fehlerklasse als im Bereich `dashboard`.** Ein +gebundenes `bound.calendarSource.update({ where: { id: 'source-b1' }, ... })` +unter TENANT-A auf die unter TENANT-B unsichtbare Zeile wirft eine +**`PrismaClientKnownRequestError` mit `.code === 'P2025'`** ("Record to +update not found") — NICHT die `PrismaClientUnknownRequestError`, die +`dashboardLayout.upsert()` im Bereich `dashboard` bei genau diesem +Wettlauf-Fall geworfen hat. Der Unterschied erklärt sich aus der Form des +Zugriffs: `dashboardLayout.upsert()` löst ein `INSERT ... ON CONFLICT` +aus, das die RLS-`USING`-Klausel beim `INSERT`-Zweig verletzt (SQLSTATE +`42501`, von Prisma als unbekannter Fehler durchgereicht); ein einfaches +`calendarSource.update({ where: { id } })` ohne Konfliktbehandlung sieht +unter der gebundenen Regel schlicht KEINE passende Zeile — dasselbe +Verhalten wie ein `UPDATE` über eine nicht existierende Kennung, das Prisma +grundsätzlich als P2025 meldet. **Das bedeutet für Aufgabe 2: keine der drei +Besitzprüfungsschreibpfade (`updateSource`/`deleteSource`/`testConnection`) +braucht eine neue Fehlerübersetzung** — P2025 wäre ohnehin nicht der Pfad, +über den ein Nutzer diese Zeile erreicht (die vorgeschaltete +`findUnique`-Besitzprüfung fängt den Fall vorher über `NotFoundException` +ab), sondern ausschließlich der Wettlauf-Fall der Aggregationsschleife +(Befund K, siehe (k2)). + +### (k2) Signaltabelle je umgestelltem Pfad + +| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort | Frontend lässt Signal durch? | +|---|---|---|---| +| `CalendarService.getSources` | Der gebundene Lesezugriff liefert eine leere Liste statt der vorhandenen Quellen | `GET /calendar/sources` liefert `[]`, Status 200, kein Fehler | Nein — `calendar-widget.tsx` zeigt `emptyNoSources` ("keine Quelle eingerichtet"), `calendar-settings-panel.tsx` zeigt `sourceEmpty`; beide ununterscheidbar vom echten Erstbenutzer-Zustand | +| `CalendarService.addSource` | Kein Leere-Fall in diese Richtung — die Mandantenkennung ist Pflichtparameter | Schlägt ohne Mandant im Controller bereits mit `ForbiddenException` fehl | — | +| `CalendarService.updateSource`, Besitzprüfung | Die gebundene `findUnique`-Abfrage liefert `null` statt der eigenen Zeile — ununterscheidbar vom echten "gehört jemand anderem" | `NotFoundException('Calendar source not found')` — dieselbe Meldung wie beim echten Besitzverstoß, siehe (k4)(d) für 403/404 | `calendar-settings-panel.tsx` fängt Schreibfehler bei `handleVisibilityToggle` (`catch { revert }`) bzw. beim Formular (`saveError`/`editSaveError`) — sichtbar als Fehlermeldung, NICHT als leerer Zustand | +| `CalendarService.deleteSource`, Besitzprüfung | Wie bei `updateSource`: `null` statt der eigenen Zeile | `NotFoundException` — dieselbe Meldung wie beim echten Besitzverstoß | Wie oben — Löschfehler werden sichtbar gemeldet, nicht verschluckt | +| `CalendarService.testConnection`, Besitzprüfung | Wie oben; zusätzlich der Wettlauf-Fall der beiden Rückschreibungen bei Erfolg/Fehler (Befund K) | `NotFoundException` bei fehlender/fremder Zeile; ein Wettlauf zwischen Nachschlagen und Rückschreiben würfe gemessen `PrismaClientKnownRequestError` (P2025, siehe (k1)) — heute strukturell ausgeschlossen, weil derselbe `tenantPrisma`-Klient beide Schritte trägt | `testResults`-State zeigt `'error'` — sichtbar, nicht verschluckt | +| `CalendarService.fetchAndCacheEvents` | Der gebundene Lesezugriff auf die Quellenliste liefert eine leere Liste statt der sichtbaren Quellen — `if (sources.length === 0) return [];` kehrt VOR dem Cache-Eintrag zurück, ein leeres Ergebnis wird also NIE zwischengespeichert, jeder Aufruf misst neu leer. Der Wettlauf-Fall der beiden Synchronstatus-Rückschreibungen (Befund K) ist gemessen: `PrismaClientKnownRequestError` (P2025) — strukturell ausgeschlossen durch EINEN `tenantPrisma`-Klient je Aufruf | `GET /calendar/events` liefert `[]`, Status 200, kein Fehler, keine Protokollzeile | Nein — `calendar-widget.tsx` zeigt bei leerem `events` (aber `hasSources === true`) `emptyNoEvents` ("keine Termine"); ein LAUTER Fehler von `fetchEvents()` ODER `fetchSources()` landet im selben `catch` und setzt ebenfalls `events = []` — die Unterscheidung zwischen "leer" und "Fehler" existiert im State nicht | +| `CalendarService.refreshCacheInBackground` | Ruft `fetchAndCacheEvents` mit der Mandantenkennung der urspünglichen Anfrage auf (kein eigener Kontext, siehe Befund B) — dasselbe Leere-Verhalten wie oben, zusätzlich verschluckt durch `.catch((error) => this.logger.warn(...))`: selbst ein LAUTER Fehler der Hintergrundauffrischung erzeugt nur eine Protokollzeile, kein Signal an den Aufrufer, der die Antwort bereits erhalten hat | Kein HTTP-Signal — die auslösende Anfrage ist bereits beantwortet, bevor die Hintergrundauffrischung beginnt | Kann das Frontend strukturell nicht erreichen — die Anfrage, die es ausgelöst hat, ist längst beantwortet | + +### (k3) Welcher Code Leere als Abwesenheit deutet + +**Backend, zwei Stellen (Befund I):** `CalendarService.getSources` liefert +bei null Treffern eine leere Liste, Status 200 — dieselbe Deutung wie überall +in dieser Etappe. `CalendarService.fetchAndCacheEvents`, +`if (sources.length === 0) return [];`: kehrt VOR dem Cache-Eintrag zurück, +ein leeres Quellenergebnis wird also nicht einmal für die TTL festgehalten, +sondern bei JEDEM Aufruf neu leer gemessen — anders als ein echter +Cache-Treffer, der fünf Minuten stehen bleibt. Die drei +Besitzprüfungspfade (`updateSource`, `deleteSource`, `testConnection`) sind +dagegen LAUT: ein zu kleines Nachschlagen wirft `NotFoundException`. + +**Frontend, drei Dateien, zur Ausführungszeit erneut nachgeprüft (Befund +J), nicht aus dem Plan abgeschrieben — dieser Plan ändert an KEINER der drei +Dateien etwas:** + +1. `apps/web/src/components/dashboard/widgets/calendar-widget.tsx`, Zeile + 37: `if (sources.length === 0)` setzt `hasSources = false`, gerendert als + `emptyNoSources` ("keine Quelle eingerichtet"). Zeile 50: + `catch { // Silent fail — show empty state }` fängt jeden Fehler von + `fetchSources()` ODER `fetchEvents()` — bei einem Fehler bleibt + `hasSources` jedoch beim vorherigen Wert (nicht `false`) und `events` + wird auf `[]` gesetzt, wodurch die Render-Logik in den Zweig + `events.length === 0` fällt und `emptyNoEvents` ("keine Termine") + zeigt — NICHT `emptyNoSources`. Ein LAUTER Fehler (403, 500, + Netzwerkfehler) auf `GET /calendar/sources` oder `GET /calendar/events` + ist damit für den Nutzer vom echten "Quellen vorhanden, aber gerade + keine Termine" nicht zu unterscheiden — beide zeigen `emptyNoEvents`. +2. `apps/web/src/components/settings/calendar-settings-panel.tsx`, Zeile + 49: `.catch(() => { // Silent fail — show empty state })` — hier bleibt + `sources` beim initialen `[]`, unabhängig davon, ob `fetchSources()` eine + echte leere Antwort ODER einen LAUTEN Fehler liefert; Zeile 127: + `sources.length === 0` zeigt `sourceEmpty`. Auf dieser Seite sind "keine + Quelle" und "Fehler beim Laden" damit VOLLSTÄNDIG ununterscheidbar — eine + schärfere Form derselben Verschluckung als im Widget. +3. `apps/web/src/components/settings/calendar-source-form.tsx`, Zeile 149: + `if (password) payload.password = password;` — nicht Leere, sondern + Weglassen; gehört hierhin, weil es die Erhaltungsfrage beantwortet (siehe + (k4)(b)): ein leer gelassenes Passwortfeld wird aus dem Sendeobjekt + WEGGELASSEN, nicht als leere Zeichenkette übertragen. + +**Die Folgekette, abgegrenzt gegen `dashboard` (Befund J):** leerer Kalender +oder leere Quellenliste → der Nutzer liest das als "die Synchronisation ist +kaputt" oder "ich habe keine Quelle eingerichtet" → er legt seine Quelle +NEU an (`addSource`, gebunden, gelingt) → er tippt sein Exchange- oder +CalDAV-Passwort ein ZWEITES MAL in ein System, das gerade aussieht, als wäre +es defekt → die ursprüngliche Zeile bleibt unsichtbar liegen. Anders als bei +`dashboard` wird dabei NICHTS überschrieben (`CalendarSource` hat keinen +automatischen Rückschreibpfad wie `setEditMode` im Dashboard) — die +Zerstörung ist hier keine verlorene Aufzeichnung, sondern eine preisgegebene +Anmeldung: der Nutzer gibt Zugangsdaten zu einem fremden Mailserver in ein +System ein, das er für kaputt hält, und sobald die Ursache behoben ist +(Etappe 4), liegen ZWEI Quellen mit ZWEI Sätzen von Zugangsdaten für +denselben Server vor — Dubletten bei Quellen UND bei Terminen. + +**Die Fehlerverschluckung als eigener Punkt:** selbst ein LAUTER +Backend-Fehler auf `GET /calendar/sources` oder `GET /calendar/events` (403, +500, Netzwerkfehler) ist für den Nutzer vom leeren Kalender nicht zu +unterscheiden — das Signal existiert ausschließlich im Netzwerkprotokoll des +Browsers und im API-Log, an keiner Stelle in der Benutzeroberfläche. + +### (k4) Was dieser Durchlauf bewusst nicht löst + +- **(a) Das Urteil zum Cache-Schlüssel**, vierteilig belegt, jedes Glied + einzeln nachgesehen: `eventCache` (Zeile 109 alt) wird mit + `${userId}:${from}:${to}` beschlüsselt. `userId` kommt aus + `calendar.controller.ts` `extractContext` (`req.user?.id`); das ist laut + `apps/api/src/auth/strategies/jwt.strategy.ts` `validate` (Zeile 27-34) + wörtlich `id: payload.sub`; `payload.sub` ist laut + `apps/api/src/auth/auth.service.ts` (Zeilen 143 und 332, beide + `sub: user.id`) die Datenbankkennung `User.id`; die trägt laut + `apps/api/prisma/schema.prisma` `model User` `@id @default(uuid())`. + **Urteil:** der Schlüssel trägt eine plattformweit eindeutige UUID, kein + Anmeldename — die Etappe-3-Entscheidung (1) des Users (Anmeldenamen + eindeutig PRO MANDANT statt plattformweit) betrifft `User.username` und + `User.email`, nicht `User.id`, und berührt den Schlüssel deshalb NICHT. + Der Schlüssel bleibt unverändert; das Urteil steht zusätzlich als + Kommentar unmittelbar über der `eventCache`-Zuweisung in + `calendar.service.ts` (Aufgabe 2). +- **(b) Der Befund zur Zugangsdaten-Erhaltung (Befund E)**, an vier Stellen + zur Ausführungszeit nachgelesen: `calendar.service.ts` `updateSource` + besitzt KEINEN Lesezugriff, der ein gespeichertes Passwort lädt, um es neu + zu verschlüsseln — die `dkv`-Form (lesen, entschlüsseln, neu + verschlüsseln) existiert hier nicht. Stattdessen: `if (dto.password !== undefined)` + entscheidet, ob überhaupt geschrieben wird — Feld FEHLT im Rumpf → + `encryptedPassword` bleibt im `update`-Aufruf gänzlich unerwähnt und damit + in der Datenbank unverändert; Feld LEER (`''`) → wird explizit auf `null` + gesetzt (Löschen); Feld GESETZT → wird verschlüsselt. Auf der Web-Seite + (`calendar-source-form.tsx`, Zeile 149) wird ein leer gelassenes + Passwortfeld WEGGELASSEN, nicht als leere Zeichenkette gesendet — die + Erhaltung läuft also über das Weglassen des Felds im Rumpf, nicht über + einen Lesezugriff, der nach dem Scharfschalten leerlaufen könnte. Die + beiden lesenden Stellen (`testConnection`, `fetchAndCacheEvents`) + entschlüsseln nur, um den Provider aufzurufen, und schreiben nichts + Entschlüsseltes zurück. Als drei Testfälle in Aufgabe 2 festgenagelt. +- **(c) Die fehlende Benutzerdimension der Regel (Befund G)**, gemessen in + (k1) (`calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`): + die verschlüsselten Exchange-/CalDAV-Zugangsdaten eines Kollegen + DESSELBEN Mandanten sind auf Datenbankebene lesbar, bis die + Etappe-3-Entscheidung (2) des Users die Benutzerdimension in die Regel + aufnimmt (`CalendarSource` steht dort ausdrücklich in der Liste). Bis + dahin bleiben der `userId`-Filter in `getSources`/`fetchAndCacheEvents` + und die drei Besitzprüfungen der EINZIGE Schutz. +- **(d) 403 statt 404 als Existenzpreisgabe zwischen Kollegen (Befund D):** + `updateSource`, `deleteSource` und `testConnection` werfen bei fremdem + Besitz `ForbiddenException('Not your calendar source')` (403), bei + unbekannter Kennung `NotFoundException('Calendar source not found')` + (404) — ein Kollege DESSELBEN Mandanten erfährt über 403 die Existenz + einer fremden Quellenkennung, ein Nutzer eines FREMDEN Mandanten bekommt + nach der Bindung durchgängig 404 (die Zeile ist für ihn unsichtbar). + Kennungen sind UUIDs, nicht erratbar. Die Antwortsemantik wird von diesem + Plan NICHT geändert (wäre eine API-Änderung außerhalb des Auftrags). +- **(e) Die fehlende Unterscheidbarkeit von "keine Quelle" und "Quelle + nicht sichtbar".** Beide liefern identisch eine leere Liste, Status 200, + keinen Protokolleintrag — siehe (k3). Die konkrete Vorabprüfung für + Etappe 4 (`rls-preflight.mjs`): physisch vorhandene + `CalendarSource`-Zeilen je Mandant über die Wartungsrolle zählen und mit + der gebundenen Zählung je Mandant vergleichen — jede Abweichung ist ein + Trennungsfehler, kein Erstbenutzer. Eine Laufzeitwarnung an + `getSources`/`fetchAndCacheEvents` wurde erwogen und VERWORFEN, mit + derselben Begründung wie bei `getAllActiveConfigs` im Bereich `ldap` und + bei `getLayout`/`getWidgets`/`getSearchProviders` im Bereich `dashboard`: + keine Quelle ist auf einer frischen Installation oder für einen neuen + Nutzer der NORMALZUSTAND — eine Warnung an dieser Stelle wäre Dauerlärm + und verlöre ihr Signal, bevor sie gebraucht wird. + +### (k5) Was dieser Durchlauf bewusst nicht anfasst + +- **Das Frontend** — geprüft (Befund I/J, (k3) oben) und bewusst gelassen, + keine Datei dieses Plans. `calendar-widget.tsx`, + `calendar-settings-panel.tsx` und `calendar-source-form.tsx` werden NICHT + geändert. +- **Die drei Provider** (`ics.provider.ts`, `caldav.provider.ts`, + `exchange.provider.ts`) — reden mit echten Servern, werden in Aufgabe 2 + NICHT ausgeübt, nur als Attrappen (`fetchEvents`/`testConnection` als + `vi.fn`) verwendet. +- **`testConnectionFromConfig`** — kein Datenbankzugriff, kein Mandant + (prüft eine Konfiguration, bevor sie gespeichert wird), unverändert. +- **Der Bereich `favorites`** — eigener Bereich mit eigener Umstellung, + nicht Teil dieses Plans. +- **Die Antwortsemantik 403/404** — siehe (k4)(d), bewusst nicht geändert. +- **Der Fremdkommentar in `apps/api/src/ldap/ldap-config.service.ts`** + (Zeile 24, nennt `CalendarSource` als Vorbild der Verschlüsselung) — + zutreffend (`CalendarCryptoService`, siehe Dezision `07-01` in + STATE.md), nicht zu ändern. +- Schema und Migrationen — geprüft und bewusst gelassen, keine + Schemaänderung in dieser Etappe. + ## Verweis Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang