feat(quick-260911-cwh): Fehlerrichtung Bereich calendar messen (Aufgabe 1)

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-11 09:48:44 +02:00
parent 508d9e4301
commit bf5fc4d4c7
2 changed files with 591 additions and 0 deletions
+332
View File
@@ -42,6 +42,7 @@ const SCRATCH_ROLE_NAME = 'tessera_rls_scratch_role';
const SCRATCH_ROLE_PASSWORD = 'scratch_only_local_never_reused'; const SCRATCH_ROLE_PASSWORD = 'scratch_only_local_never_reused';
const MIGRATIONS_DIR = join(__dirname, '../prisma/migrations'); const MIGRATIONS_DIR = join(__dirname, '../prisma/migrations');
const PRISMA_BIN = join(__dirname, '../node_modules/.bin/prisma'); 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 $$ ... $$ * 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 * Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte * den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
@@ -3001,6 +3332,7 @@ async function main() {
await runUserAreaChecks(adminUrl, scratchRoleUrlString, results); await runUserAreaChecks(adminUrl, scratchRoleUrlString, results);
await runModuleRegistryAreaChecks(adminUrl, scratchRoleUrlString, results); await runModuleRegistryAreaChecks(adminUrl, scratchRoleUrlString, results);
await runDashboardAreaChecks(adminUrl, scratchRoleUrlString, results); await runDashboardAreaChecks(adminUrl, scratchRoleUrlString, results);
await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results);
await runTransactionShapeMeasurement(scratchRoleUrlString, results); await runTransactionShapeMeasurement(scratchRoleUrlString, results);
await runConcurrencyProbe(scratchRoleUrlString, results); await runConcurrencyProbe(scratchRoleUrlString, results);
} finally { } finally {
@@ -2000,6 +2000,265 @@ Abweichung von seiner eigenen Auswahl liest, bleibt das unbemerkt.
- Schema und Migrationen — geprüft und bewusst gelassen, keine - Schema und Migrationen — geprüft und bewusst gelassen, keine
Schemaänderung in dieser Etappe. 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 ## Verweis
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang