docs(quick-260911-gwh): Fehlerrichtung fuer Bereiche favorites und settings messen und aufschreiben

- runFavoritesAreaChecks (8 Pruefungen) und runSettingsAreaChecks (9 Pruefungen)
  erweitern rls-scratch-check.mjs auf 137 bestandene Pruefungen
- FavoriteLink-Wegwerftabelle traegt den Fremdschluessel auf WidgetInstance
  (Befund C, WINDOWS #27), SmtpConfig-Wegwerftabelle den Eindeutigkeitsindex
- Ergebnis Pruefung 7: der Fremdschluessel prueft am Zeilenschutz vorbei
  (steuert den Besitzriegel in Aufgabe 2); Pruefung 8: ungebundenes upsert
  wirft PrismaClientUnknownRequestError, dieselbe Klasse wie 260910-krx
- docs/mandantentrennung-etappe2-fehlerrichtung.md: Abschnitte
  ## Bereich favorites (f1-f5), ## Bereich settings (s1-s5) und
  ## Etappe 2 -- Abschluss; Nachtraege unter Befund K in (t4) und im
  Uebergaben-Absatz von (d4) -- die Reihenfolgebedingung ist erfuellt

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 13:47:02 +02:00
parent 2a27d96bca
commit 88896d3b43
2 changed files with 1019 additions and 0 deletions
+577
View File
@@ -3776,6 +3776,581 @@ async function runAuthAreaChecks(adminUrl, scratchRoleUrl, results) {
} }
} }
/**
* Aufgabe 1 (260911-gwh) — misst die acht im Plan genannten Verhaltensweisen
* des Bereichs `favorites` unter der Rolle ohne BYPASSRLS, an der Regel
* WORTGLEICH aus der ausgelieferten Migration
* `20260909140000_rls_remaining_tenant_tables` geschnitten — NICHT dem
* Werkzeug nachgetippt (vgl. runCalendarAreaChecks/runDashboardAreaChecks).
*
* Legt die Wegwerf-Tabelle "FavoriteLink" mit SAEMTLICHEN skalaren Spalten
* des Modells an (readSchemaModelScalarFieldNames('FavoriteLink') filtert
* das Relationsfeld `widgetInstance` heraus, sonst misst Pruefung 2 die
* falsche Menge) und traegt DEN Fremdschluessel auf die von
* runDashboardAreaChecks() bereits angelegte Tabelle "WidgetInstance"
* (Befund C, WINDOWS #27: eine Relation ist fuer die Bestandsaufnahme
* unsichtbar — hier deshalb ausdruecklich mitgebaut und gemessen, nicht nur
* behauptet). Muss deshalb NACH runDashboardAreaChecks() laufen. Setzt auf
* keiner Tabelle eines spaeteren Abschnitts auf: er ist ein Blatt in der
* Aufrufkette, muss NACH runAuthAreaChecks() und VOR
* runTransactionShapeMeasurement() laufen (siehe Aufrufkette in main()).
*/
async function runFavoritesAreaChecks(adminUrl, scratchRoleUrl, results) {
const widenMigrationSql = readRlsWidenMigrationSql();
const widenHasOwnFavoriteLinkPolicy =
widenMigrationSql && Boolean(extractPolicySql(widenMigrationSql, 'FavoriteLink'));
report(
results,
'favoritelink-regelstand-eindeutig',
!widenHasOwnFavoriteLinkPolicy,
widenHasOwnFavoriteLinkPolicy
? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt eine EIGENE Regel fuer "FavoriteLink" — 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 "FavoriteLink" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand',
);
if (widenHasOwnFavoriteLinkPolicy) {
return;
}
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
const favoriteLinkPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'FavoriteLink')
: null;
if (!favoriteLinkPolicy) {
report(
results,
'favoritelink-policy-aus-migration-gefunden',
false,
'CREATE POLICY fuer "FavoriteLink" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden',
);
return;
}
// Befund C: vorher ueber die Wartungsrolle MESSEN, welche "WidgetInstance"-
// Zeilen stehen — nicht annehmen. runDashboardAreaChecks() legt diese
// Tabelle bereits mit drei Zeilen an (widget-a1/user-a1/TENANT-A,
// widget-a2/user-a2/TENANT-A, widget-b1/user-b1/TENANT-B).
const widgetInstanceRows = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT id, "userId", "tenantId" FROM "WidgetInstance" ORDER BY id`;
return rows;
},
);
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
// updatedAt bekommt DEFAULT CURRENT_TIMESTAMP als vermerkte Abweichung
// (Prisma setzt den Wert clientseitig, das schmale INSERT unten braucht
// trotzdem einen Wert) — Form von 260911-e2s/fh9.
await db.$executeRawUnsafe(`
CREATE TABLE "FavoriteLink" (
id text PRIMARY KEY,
"userId" text NOT NULL,
"tenantId" text NOT NULL,
"widgetId" text NOT NULL,
title text NOT NULL,
url text NOT NULL,
"iconUrl" text,
position integer NOT NULL DEFAULT 0,
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FavoriteLink_widgetId_fkey" FOREIGN KEY ("widgetId") REFERENCES "WidgetInstance"("id") ON DELETE CASCADE
);
`);
await db.$executeRawUnsafe(`ALTER TABLE "FavoriteLink" ENABLE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(`ALTER TABLE "FavoriteLink" FORCE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(favoriteLinkPolicy);
await db.$executeRawUnsafe(
`GRANT SELECT, INSERT, UPDATE, DELETE ON "FavoriteLink" TO ${SCRATCH_ROLE_NAME}`,
);
// fav-a1-1/fav-a1-2 gehoeren user-a1 (TENANT-A, widget-a1); fav-a2-1
// gehoert dem KOLLEGEN user-a2 (TENANT-A, widget-a2, Befund G-Form);
// fav-b1-1 liegt unter TENANT-B (widget-b1). fav-a1-1 traegt eine
// iconUrl, fav-a1-2 nicht (Pitfall 3 des Bereichs).
await db.$executeRawUnsafe(`
INSERT INTO "FavoriteLink" (id, "userId", "tenantId", "widgetId", title, url, "iconUrl", position) VALUES
('fav-a1-1', 'user-a1', 'TENANT-A', 'widget-a1', 'Favorit A1-1', 'https://example.invalid/a1-1', 'https://icons.invalid/a1-1.png', 0),
('fav-a1-2', 'user-a1', 'TENANT-A', 'widget-a1', 'Favorit A1-2', 'https://example.invalid/a1-2', NULL, 1),
('fav-a2-1', 'user-a2', 'TENANT-A', 'widget-a2', 'Favorit A2-1', 'https://example.invalid/a2-1', NULL, 0),
('fav-b1-1', 'user-b1', 'TENANT-B', 'widget-b1', 'Favorit B1-1', 'https://example.invalid/b1-1', NULL, 0);
`);
});
// Pruefung 2 zuerst — faellt sie durch, sind die Client-Messungen (3-8)
// wertlos, deshalb steht sie vor ihnen und die Funktion bricht ab, wenn
// sie fehlschlaegt (Lehre aus Pruefung 8 im Bereich `calendar`).
const schemaFields = readSchemaModelScalarFieldNames('FavoriteLink');
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 = 'FavoriteLink'
`;
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,
'favoritelink-wegwerftabelle-deckt-alle-spalten-des-generierten-clients',
columnsMatch,
`Schema-Felder aus schema.prisma (model FavoriteLink, skalare Felder ohne Relation, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}; gemessene "WidgetInstance"-Zeilen (Befund C, von runDashboardAreaChecks angelegt): ${JSON.stringify(widgetInstanceRows)}`,
);
if (!columnsMatch) {
return;
}
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
// 3: favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste
// — die tragende Belegzeile dieses Abschnitts.
const unboundList = await prisma.favoriteLink.findMany({
where: { userId: 'user-a1', widgetId: 'widget-a1' },
orderBy: [{ position: 'asc' }, { title: 'asc' }],
});
report(
results,
'favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste',
unboundList.length === 0,
`ungebundenes prisma.favoriteLink.findMany({ where: { userId: 'user-a1', widgetId: 'widget-a1' }, orderBy: [{ position: 'asc' }, { title: 'asc' }] }) (die Form von list) liefert ${unboundList.length} Zeile(n), obwohl 2 tatsaechlich vorhanden sind — das ist der Wert, aus dem favorites-widget.tsx "Noch keine Favoriten." macht`,
);
// 4: favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen
// — zwei Aussagen in einer Messung: die eigenen Zeilen kommen, UND die
// Regel kennt keine Benutzerdimension (die Zeile des Kollegen ist ueber
// ein gebundenes findMany auf DESSEN widgetId ebenfalls sichtbar).
const bound = buildInlineExtendedClient(prisma, 'TENANT-A');
const boundOwnList = await bound.favoriteLink.findMany({
where: { userId: 'user-a1', widgetId: 'widget-a1' },
orderBy: [{ position: 'asc' }, { title: 'asc' }],
});
const ownListOk =
boundOwnList.length === 2 &&
boundOwnList.every((r) => r.userId === 'user-a1' && r.widgetId === 'widget-a1');
const boundColleagueList = await bound.favoriteLink.findMany({ where: { widgetId: 'widget-a2' } });
const colleagueVisible =
boundColleagueList.length === 1 && boundColleagueList[0].userId === 'user-a2';
report(
results,
'favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen',
ownListOk && colleagueVisible,
`bound.favoriteLink.findMany unter TENANT-A liefert fuer (userId='user-a1', widgetId='widget-a1') ${boundOwnList.length} Zeile(n): ${JSON.stringify(boundOwnList.map((r) => r.id))} — die Zeile von user-a2 fehlt (anwendungsseitige Benutzerfilterung); ein gebundenes findMany({ where: { widgetId: 'widget-a2' } }) unter DEMSELBEN Mandanten liefert dagegen ${boundColleagueList.length} Zeile(n) des Kollegen user-a2 (${JSON.stringify(boundColleagueList.map((r) => r.id))}) — die Regel auf "FavoriteLink" kennt keine Benutzerdimension (dieselbe Lehre wie calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar), die anwendungsseitige userId-Filterung bleibt deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten (Etappe-3-Entscheidung (2))`,
);
// 5: favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null
// — die Datenbankseite der Vorpruefung in update/remove/getIconBytes (T-GWH-02).
const boundB = buildInlineExtendedClient(prisma, 'TENANT-B');
const foreignFind = await boundB.favoriteLink.findUnique({ where: { id: 'fav-a1-1' } });
report(
results,
'favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null',
foreignFind === null,
`bound.favoriteLink.findUnique({ where: { id: 'fav-a1-1' } }) unter TENANT-B (die Zeile gehoert TENANT-A) liefert ${JSON.stringify(foreignFind)} — das ist die Datenbankseite der Vorpruefung in update/remove/getIconBytes (T-GWH-02): fremde Zeile -> findUnique liefert null -> NotFoundException`,
);
// 6: favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut
// — der generierte Client meldet null getroffene Zeilen bei delete anders
// als Roh-SQL (dkv Befund G in der Client-Form): Konstruktorname/code
// woertlich, das Ergebnis wird nicht vorweggenommen.
let foreignDeleteThrew = false;
let foreignDeleteDetail = '';
try {
await boundB.favoriteLink.delete({ where: { id: 'fav-a1-1' } });
foreignDeleteDetail =
'bound.favoriteLink.delete unter TENANT-B auf die unter TENANT-A liegende Zeile fav-a1-1 ist NICHT fehlgeschlagen';
} catch (err) {
foreignDeleteThrew = true;
const ctor = err?.constructor?.name ?? 'unbekannt';
foreignDeleteDetail = `bound.favoriteLink.delete unter TENANT-B auf die unter TENANT-A liegende, fuer TENANT-B unsichtbare Zeile fav-a1-1 wirft ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()}`;
}
const stillThereAfterForeignDelete = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT id FROM "FavoriteLink" WHERE id = 'fav-a1-1'`;
return rows.length === 1;
},
);
report(
results,
'favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut',
foreignDeleteThrew && stillThereAfterForeignDelete,
`${foreignDeleteDetail} — die Wartungsrolle liest die Zeile danach noch: ${stillThereAfterForeignDelete}`,
);
// 7: favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei — MISST,
// ob der Fremdschluessel auf "WidgetInstance" die Zeilenschutz-Regel
// dieser Tabelle umgeht (dokumentiertes PostgreSQL-Verhalten:
// referentielle Integritaet prueft AN der Regel vorbei). Das Ergebnis
// steuert Aufgabe 2 (Befund F), es wird NICHT vorweggenommen. Zweite
// Haelfte derselben Pruefung: ein gebundenes create mit einer
// WIRKLICH fehlenden widgetId MUSS an der FK-Verletzung scheitern — der
// Unterschied zwischen beiden Antworten ist das Existenzorakel (T-GWH-05).
const widgetB1UnderA = await bound.widgetInstance.findUnique({
where: { id: 'widget-b1' },
select: { userId: true },
});
let foreignWidgetCreateSucceeded = false;
let foreignWidgetCreateDetail = '';
try {
const created = await bound.favoriteLink.create({
data: {
id: 'fav-a1-fremdes-widget',
userId: 'user-a1',
tenantId: 'TENANT-A',
widgetId: 'widget-b1',
title: 'Fremdes Widget',
url: 'https://example.invalid/fremd',
position: 0,
},
});
foreignWidgetCreateSucceeded = Boolean(created);
foreignWidgetCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-b1' (gehoert TENANT-B, unter TENANT-A per gebundenem widgetInstance.findUnique unsichtbar: ${JSON.stringify(widgetB1UnderA)}) GELINGT (id=${created?.id}) — der Fremdschluessel prueft am Zeilenschutz VORBEI (dokumentiertes PostgreSQL-Verhalten)`;
// Die Zeile ist ein Messartefakt, nicht Teil des Bestands fuer die
// folgenden Pruefungen — ueber die Wartungsrolle wieder entfernen.
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), (db) =>
db.$executeRawUnsafe(`DELETE FROM "FavoriteLink" WHERE id = 'fav-a1-fremdes-widget'`),
);
} catch (err) {
const ctor = err?.constructor?.name ?? 'unbekannt';
foreignWidgetCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-b1' (unter TENANT-A per gebundenem widgetInstance.findUnique unsichtbar: ${JSON.stringify(widgetB1UnderA)}) scheitert mit ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()}`;
}
let missingWidgetCreateRejected = false;
let missingWidgetCreateDetail = '';
try {
await bound.favoriteLink.create({
data: {
id: 'fav-a1-widget-fehlt',
userId: 'user-a1',
tenantId: 'TENANT-A',
widgetId: 'widget-gibt-es-nicht',
title: 'Widget fehlt',
url: 'https://example.invalid/fehlt',
position: 0,
},
});
missingWidgetCreateDetail =
'bound.favoriteLink.create unter TENANT-A mit widgetId="widget-gibt-es-nicht" ist NICHT fehlgeschlagen';
} catch (err) {
missingWidgetCreateRejected = true;
const ctor = err?.constructor?.name ?? 'unbekannt';
missingWidgetCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId="widget-gibt-es-nicht" scheitert mit ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()} — die FK-Verletzung, das Gegenstueck zum Gelingen oben`;
}
report(
results,
'favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei',
missingWidgetCreateRejected,
`${foreignWidgetCreateDetail}; ${missingWidgetCreateDetail} — der Unterschied zwischen beiden Antworten ("gibt es nicht" scheitert, "liegt bei fremdem Mandanten" gelingt${foreignWidgetCreateSucceeded ? '' : ' NICHT, gemessen statt angenommen'}) ist das Existenzorakel (T-GWH-05); das Ergebnis der ersten Haelfte (Gelingen: ${foreignWidgetCreateSucceeded}) steuert, ob Aufgabe 2 einen Besitzriegel in create() baut`,
);
// 8: favoritelink-gebundenes-anlegen-eigener-mandant-gelingt
let ownCreateSucceeded = false;
let ownCreateDetail = '';
try {
const created = await bound.favoriteLink.create({
data: {
id: 'fav-a1-neu',
userId: 'user-a1',
tenantId: 'TENANT-A',
widgetId: 'widget-a1',
title: 'Neu angelegter Favorit',
url: 'https://example.invalid/neu',
position: 2,
},
});
const readBack = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) =>
db.$queryRaw`SELECT "tenantId", "createdAt", "updatedAt" FROM "FavoriteLink" WHERE id = 'fav-a1-neu'`,
);
ownCreateSucceeded =
readBack.length === 1 &&
readBack[0].tenantId === 'TENANT-A' &&
readBack[0].createdAt != null &&
readBack[0].updatedAt != null;
ownCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-a1' gelingt (id=${created.id}); die Wartungsrolle liest danach tenantId=${JSON.stringify(readBack[0]?.tenantId)}, createdAt=${JSON.stringify(readBack[0]?.createdAt)}, updatedAt=${JSON.stringify(readBack[0]?.updatedAt)} — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig erzeugten Werte annimmt`;
} catch (err) {
ownCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-a1' ist fehlgeschlagen: ${err.message}`;
}
report(
results,
'favoritelink-gebundenes-anlegen-eigener-mandant-gelingt',
ownCreateSucceeded,
ownCreateDetail,
);
} finally {
await prisma.$disconnect();
}
}
/**
* Aufgabe 1 (260911-gwh) — misst die neun im Plan genannten Verhaltensweisen
* des Bereichs `settings` unter der Rolle ohne BYPASSRLS, an der Regel
* WORTGLEICH aus der ausgelieferten Migration
* `20260909140000_rls_remaining_tenant_tables` geschnitten. Legt die
* Wegwerf-Tabelle "SmtpConfig" mit SAEMTLICHEN skalaren Spalten des Modells
* an UND mit dem Eindeutigkeitsindex `SmtpConfig_tenantId_key` WORTGLEICH
* aus `20260629130000_add_missing_tables` — ohne diesen Index misst
* Pruefung 8 nichts (der Konfliktweg braucht den physischen Index, nicht
* nur die Regel). Ein Blatt wie `runFavoritesAreaChecks`: muss NACH
* runAuthAreaChecks() und VOR runTransactionShapeMeasurement() laufen.
*/
async function runSettingsAreaChecks(adminUrl, scratchRoleUrl, results) {
const widenMigrationSql = readRlsWidenMigrationSql();
const widenHasOwnSmtpConfigPolicy =
widenMigrationSql && Boolean(extractPolicySql(widenMigrationSql, 'SmtpConfig'));
report(
results,
'smtpconfig-regelstand-eindeutig',
!widenHasOwnSmtpConfigPolicy,
widenHasOwnSmtpConfigPolicy
? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt eine EIGENE Regel fuer "SmtpConfig" — 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 "SmtpConfig" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand',
);
if (widenHasOwnSmtpConfigPolicy) {
return;
}
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
const smtpConfigPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'SmtpConfig')
: null;
if (!smtpConfigPolicy) {
report(
results,
'smtpconfig-policy-aus-migration-gefunden',
false,
'CREATE POLICY fuer "SmtpConfig" 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 "SmtpConfig" (
id text PRIMARY KEY,
"tenantId" text NOT NULL,
host text NOT NULL,
port integer NOT NULL DEFAULT 587,
encryption text NOT NULL DEFAULT 'starttls',
username text,
"encryptedPassword" text,
"fromAddress" text NOT NULL,
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
// WORTGLEICH aus 20260629130000_add_missing_tables — ohne diesen Index
// misst Pruefung 8 nichts.
await db.$executeRawUnsafe(
`CREATE UNIQUE INDEX "SmtpConfig_tenantId_key" ON "SmtpConfig"("tenantId");`,
);
await db.$executeRawUnsafe(`ALTER TABLE "SmtpConfig" ENABLE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(`ALTER TABLE "SmtpConfig" FORCE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(smtpConfigPolicy);
await db.$executeRawUnsafe(
`GRANT SELECT, INSERT, UPDATE, DELETE ON "SmtpConfig" TO ${SCRATCH_ROLE_NAME}`,
);
await db.$executeRawUnsafe(`
INSERT INTO "SmtpConfig" (id, "tenantId", host, "encryptedPassword", "fromAddress") VALUES
('smtp-a', 'TENANT-A', 'smtp-a.example.invalid', 'enc(a-passwort-platzhalter)', 'a@example.invalid'),
('smtp-b', 'TENANT-B', 'smtp-b.example.invalid', 'enc(b-passwort-platzhalter)', 'b@example.invalid');
`);
});
// Pruefung 2 zuerst — dieselbe Reihenfolgeregel wie bei `favorites`.
const schemaFields = readSchemaModelScalarFieldNames('SmtpConfig');
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 = 'SmtpConfig'
`;
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]);
const indexRows = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`
SELECT indexdef FROM pg_indexes WHERE tablename = 'SmtpConfig' AND indexname = 'SmtpConfig_tenantId_key'
`;
return rows;
},
);
const indexOk = indexRows.length === 1;
report(
results,
'smtpconfig-wegwerftabelle-deckt-alle-spalten-des-generierten-clients',
columnsMatch && indexOk,
`Schema-Felder aus schema.prisma (model SmtpConfig, skalare Felder ohne Relation, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}; Eindeutigkeitsindex "SmtpConfig_tenantId_key" ueber pg_indexes: ${JSON.stringify(indexRows.map((r) => r.indexdef))}`,
);
if (!columnsMatch || !indexOk) {
return;
}
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
// 3: smtpconfig-startpfad-generierter-client-ungebunden-liefert-null —
// die Form von loadAnySmtpConfigForStartupTransport(), ohne jede Bedingung.
const unboundStartup = await prisma.smtpConfig.findFirst();
report(
results,
'smtpconfig-startpfad-generierter-client-ungebunden-liefert-null',
unboundStartup === null,
`ungebundenes prisma.smtpConfig.findFirst() (die Form von loadAnySmtpConfigForStartupTransport) liefert ${JSON.stringify(unboundStartup)}, obwohl 2 Zeilen existieren — das ist der Wert, mit dem mail.module.ts nach dem Scharfschalten auf Umgebungsvariablen und zuletzt localhost:1025 zurueckfaellt, ein falscher Transport statt einer Meldung`,
);
// 4: smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile —
// die HEUTIGE Lage: dieselbe Abfrage ueber die Wartungsrolle liefert
// eine beliebige, aber vorhandene Zeile.
const adminStartupRow = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT "tenantId" FROM "SmtpConfig" LIMIT 1`;
return rows[0];
},
);
report(
results,
'smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile',
Boolean(adminStartupRow),
`dieselbe Abfrage (SELECT ... LIMIT 1 ohne jede Bedingung) ueber die Wartungsrolle liefert genau EINE Zeile, tenantId=${JSON.stringify(adminStartupRow?.tenantId)} — nichts in der Abfrage bestimmt, WELCHER Mandant gezogen wird, und dessen Server und Absender tragen ab Start alle Kennwort-Zuruecksetzungs-Mails ALLER Mandanten (T-GWH-03)`,
);
// 5: smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null —
// Befund K, der Versandpfad.
const unboundSendPath = await prisma.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } });
report(
results,
'smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null',
unboundSendPath === null,
`ungebundenes prisma.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } }) (die Form von getDecryptedSmtpConfig) liefert ${JSON.stringify(unboundSendPath)}, waehrend die Wartungsrolle die Zeile liest — Befund K: tender-mail.service.ts protokolliert "No SMTP configuration" und ueberspringt, dkv-mail.service.ts wirft; kein Versand fuer niemanden`,
);
// 6: smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten
const boundA = buildInlineExtendedClient(prisma, 'TENANT-A');
const boundOwn = await boundA.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } });
report(
results,
'smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten',
Boolean(boundOwn) &&
boundOwn.encryptedPassword === 'enc(a-passwort-platzhalter)' &&
boundOwn.host === 'smtp-a.example.invalid' &&
boundOwn.fromAddress === 'a@example.invalid',
`gebunden unter TENANT-A liefert findUnique({ where: { tenantId: 'TENANT-A' } }): host=${JSON.stringify(boundOwn?.host)}, fromAddress=${JSON.stringify(boundOwn?.fromAddress)}, encryptedPassword=${JSON.stringify(boundOwn?.encryptedPassword)}`,
);
// 7: smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null
// — T-GWH-01.
const boundB = buildInlineExtendedClient(prisma, 'TENANT-B');
const foreignSend = await boundB.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } });
report(
results,
'smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null',
foreignSend === null,
`gebunden unter TENANT-B liefert findUnique({ where: { tenantId: 'TENANT-A' } }) (gehoert TENANT-A): ${JSON.stringify(foreignSend)} — die verschluesselten Zugangsdaten von A sind fuer B unsichtbar (T-GWH-01)`,
);
// 8: smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut
// — die Form von saveSmtpConfig, UNGEBUNDEN. Konstruktorname und code
// woertlich, das Ergebnis wird NICHT vorweggenommen; die Belegausgabe
// haelt daneben, was 260910-krx fuer DashboardLayout gemessen hat.
let conflictThrew = false;
let conflictCtor = 'unbekannt';
let conflictCode;
let conflictMessage = '';
try {
await prisma.smtpConfig.upsert({
where: { tenantId: 'TENANT-A' },
create: {
id: 'smtp-a-neu',
tenantId: 'TENANT-A',
host: 'smtp-a-neu.example.invalid',
fromAddress: 'a@example.invalid',
},
update: { host: 'smtp-a-neu.example.invalid' },
});
} catch (err) {
conflictThrew = true;
conflictCtor = err?.constructor?.name ?? 'unbekannt';
conflictCode = err?.code;
conflictMessage = (err.message ?? '').toString().trim();
}
const hostAfterConflict = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT host FROM "SmtpConfig" WHERE id = 'smtp-a'`;
return rows[0]?.host;
},
);
report(
results,
'smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut',
conflictThrew && hostAfterConflict === 'smtp-a.example.invalid',
`ungebundenes prisma.smtpConfig.upsert({ where: { tenantId: 'TENANT-A' }, ... }) (die Form von saveSmtpConfig) wirft ${conflictCtor}${conflictCode ? ` (code ${conflictCode})` : ''}: ${conflictMessage} — zum Vergleich: 260910-krx mass fuer DashboardLayout unter dieser Form PrismaClientUnknownRequestError; die Wartungsrolle liest danach weiterhin host=${JSON.stringify(hostAfterConflict)}`,
);
// 9: smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert
let boundUpsertSucceeded = false;
let boundUpsertDetail = '';
try {
await boundA.smtpConfig.upsert({
where: { tenantId: 'TENANT-A' },
create: {
id: 'smtp-a-neu-2',
tenantId: 'TENANT-A',
host: 'smtp-a-gebunden-neu.example.invalid',
fromAddress: 'a@example.invalid',
},
update: { host: 'smtp-a-gebunden-neu.example.invalid' },
});
const afterBoundUpsert = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT id, host, "updatedAt" FROM "SmtpConfig" WHERE "tenantId" = 'TENANT-A'`;
return rows[0];
},
);
const bUnchanged = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT host FROM "SmtpConfig" WHERE id = 'smtp-b'`;
return rows[0]?.host;
},
);
boundUpsertSucceeded =
afterBoundUpsert?.id === 'smtp-a' &&
afterBoundUpsert?.host === 'smtp-a-gebunden-neu.example.invalid' &&
afterBoundUpsert?.updatedAt != null &&
bUnchanged === 'smtp-b.example.invalid';
boundUpsertDetail = `gebundenes upsert unter TENANT-A trifft die eigene Zeile (id=${afterBoundUpsert?.id}), die Wartungsrolle liest danach host=${JSON.stringify(afterBoundUpsert?.host)}, updatedAt=${JSON.stringify(afterBoundUpsert?.updatedAt)}; smtp-b bleibt unveraendert: ${JSON.stringify(bUnchanged)}`;
} catch (err) {
boundUpsertDetail = `gebundenes upsert unter TENANT-A ist fehlgeschlagen: ${err.message}`;
}
report(
results,
'smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert',
boundUpsertSucceeded,
boundUpsertDetail,
);
} 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
@@ -4015,6 +4590,8 @@ async function main() {
await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results); await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results);
await runTenantAreaChecks(adminUrl, scratchRoleUrlString, results); await runTenantAreaChecks(adminUrl, scratchRoleUrlString, results);
await runAuthAreaChecks(adminUrl, scratchRoleUrlString, results); await runAuthAreaChecks(adminUrl, scratchRoleUrlString, results);
await runFavoritesAreaChecks(adminUrl, scratchRoleUrlString, results);
await runSettingsAreaChecks(adminUrl, scratchRoleUrlString, results);
await runTransactionShapeMeasurement(scratchRoleUrlString, results); await runTransactionShapeMeasurement(scratchRoleUrlString, results);
await runConcurrencyProbe(scratchRoleUrlString, results); await runConcurrencyProbe(scratchRoleUrlString, results);
} finally { } finally {
@@ -632,6 +632,14 @@ Warnung wäre Dauerlärm und verlöre ihr Signal.
(dieselbe Entlastung wie in (t3)). Das ist eine Reihenfolgebedingung für (dieselbe Entlastung wie in (t3)). Das ist eine Reihenfolgebedingung für
Etappe 4, genau wie Befund D des `ldap`-Durchlaufs es für `groups` war — Etappe 4, genau wie Befund D des `ldap`-Durchlaufs es für `groups` war —
hier festgehalten, nicht gelöst. hier festgehalten, nicht gelöst.
**Nachtrag (260911-gwh):** `getDecryptedSmtpConfig(tenantId)` läuft seit
Aufgabe 2 dieses Laufs über `forTenant()` (GENAU EIN Klient `tenantPrisma`
je Aufruf). Die Reihenfolgebedingung ist damit ERFÜLLT — siehe
`docs/mandantentrennung-zugriffsklassifikation.md`, Bestandsaufnahme-Zeile
`settings.service.ts`/`smtpConfig`, und den Hintergrunddienst-Abschnitt
dort. Die Etappe-4-Vorabprüfung muss diese Bedingung ab jetzt NICHT mehr
führen.
- **Die offene Architekturfrage `req.tenantPrisma`.** Auch der Bereich - **Die offene Architekturfrage `req.tenantPrisma`.** Auch der Bereich
`tenders` entscheidet sie nicht — er bindet dienst-intern, wie `ldap` `tenders` entscheidet sie nicht — er bindet dienst-intern, wie `ldap`
und `groups` es vormachen. und `groups` es vormachen.
@@ -943,6 +951,19 @@ keine Zeile mehr — Ergebnis: kein Versand für niemanden, mit Wiederholung
bei jedem Lauf (die Datei bleibt lokal verfügbar, D-16). Reihenfolgebedingung bei jedem Lauf (die Datei bleibt lokal verfügbar, D-16). Reihenfolgebedingung
für Etappe 4, hier festgehalten, nicht gelöst. für Etappe 4, hier festgehalten, nicht gelöst.
**Nachtrag (260911-gwh):** der `settings`-Teil dieser Übergabe ist erfüllt —
`getDecryptedSmtpConfig(tenantId)` läuft seit Aufgabe 2 dieses Laufs über
`forTenant()`, siehe die Bestandsaufnahme-Zeile
`settings.service.ts`/`smtpConfig` in
`docs/mandantentrennung-zugriffsklassifikation.md` und (s4)(b). Erneut
gemessen: `dkv.seed.ts` ruft weiterhin
`ModuleRegistryService.seedModule()` (`module-registry.service.ts:206`,
`this.prisma.module.upsert`) — UNGEBUNDEN, aber bewusst und unverändert seit
260910-exd, weil `Module` der plattformweite Modulkatalog ohne `tenantId`-
Spalte ist (Befund E, `keine-mandantengebundene-tabelle`); "gebunden seit
260910-exd" trifft auf diesen Zugriff NICHT zu, gemessen statt aus dem Plan
abgeschrieben.
**Die offene Architekturfrage `req.tenantPrisma`.** Auch der Bereich `dkv` **Die offene Architekturfrage `req.tenantPrisma`.** Auch der Bereich `dkv`
entscheidet sie nicht — er bindet dienst-intern, wie `ldap`, `groups` und entscheidet sie nicht — er bindet dienst-intern, wie `ldap`, `groups` und
`tenders` es vormachen. `tenders` es vormachen.
@@ -2674,6 +2695,427 @@ Benutzers, unveraendert in diesem Plan.
`ldap.service.spec.ts` verweist — wird in Aufgabe 2 ersetzt, hier nur als `ldap.service.spec.ts` verweist — wird in Aufgabe 2 ersetzt, hier nur als
Befund F genannt. Befund F genannt.
## Bereich favorites
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `favorites`
(Quick-Task 260911-gwh, zusammen mit `settings` der LETZTE Lauf von Etappe 2)
und beschreibt ihn zum Zeitpunkt seiner Umstellung. Die Leitfrage aus
Abschnitt (a) gilt unverändert weiter. Anders als jeder Bereich davor hängt
`FavoriteLink` über einen Fremdschlüssel an einer ZWEITEN mandantengebundenen
Tabelle (`WidgetInstance`), deren Zeilenschutz-Regel der Fremdschlüssel auf
Datenbankebene umgeht — eine Ausprägung von WINDOWS #27, hier zum ersten Mal
ausdrücklich mitgebaut statt nur benannt.
### (f1) Die Messung
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen weiteren
Abschnitt (`runFavoritesAreaChecks`) erweitert, mit der Policy für
`FavoriteLink` (aus der ausgelieferten Migration
`20260909140000_rls_remaining_tenant_tables`) WORTGLEICH extrahiert, nicht im
Werkzeug nachgetippt. Tatsächlich beobachtete Ausgabe dieses Laufs
(2026-09-11, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
```
favoritelink-regelstand-eindeutig: bestanden — die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "FavoriteLink" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand
favoritelink-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model FavoriteLink, skalare Felder ohne Relation, 10): ["createdAt","iconUrl","id","position","tenantId","title","updatedAt","url","userId","widgetId"]; Spalten der Wegwerf-Tabelle (10): [dieselben zehn]; gemessene "WidgetInstance"-Zeilen (Befund C, von runDashboardAreaChecks angelegt): [{"id":"widget-a1","userId":"user-a1","tenantId":"TENANT-A"},{"id":"widget-a2","userId":"user-a2","tenantId":"TENANT-A"},{"id":"widget-b1","userId":"user-b1","tenantId":"TENANT-B"}]
favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste: bestanden — ungebundenes prisma.favoriteLink.findMany({ where: { userId: 'user-a1', widgetId: 'widget-a1' }, orderBy: [{ position: 'asc' }, { title: 'asc' }] }) (die Form von list) liefert 0 Zeile(n), obwohl 2 tatsaechlich vorhanden sind — das ist der Wert, aus dem favorites-widget.tsx "Noch keine Favoriten." macht
favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen: bestanden — bound.favoriteLink.findMany unter TENANT-A liefert fuer (userId='user-a1', widgetId='widget-a1') 2 Zeile(n): ["fav-a1-1","fav-a1-2"] — die Zeile von user-a2 fehlt (anwendungsseitige Benutzerfilterung); ein gebundenes findMany({ where: { widgetId: 'widget-a2' } }) unter DEMSELBEN Mandanten liefert dagegen 1 Zeile(n) des Kollegen user-a2 (["fav-a2-1"]) — die Regel auf "FavoriteLink" kennt keine Benutzerdimension
favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null: bestanden — bound.favoriteLink.findUnique({ where: { id: 'fav-a1-1' } }) unter TENANT-B (die Zeile gehoert TENANT-A) liefert null
favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut: bestanden — bound.favoriteLink.delete unter TENANT-B auf die unter TENANT-A liegende Zeile fav-a1-1 wirft PrismaClientKnownRequestError (code P2025): No record was found for a delete. — die Wartungsrolle liest die Zeile danach noch: true
favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei: bestanden — bound.favoriteLink.create unter TENANT-A mit widgetId='widget-b1' (gehoert TENANT-B, unter TENANT-A per gebundenem widgetInstance.findUnique unsichtbar: null) GELINGT (id=fav-a1-fremdes-widget) — der Fremdschluessel prueft am Zeilenschutz VORBEI (dokumentiertes PostgreSQL-Verhalten); bound.favoriteLink.create unter TENANT-A mit widgetId="widget-gibt-es-nicht" scheitert mit PrismaClientKnownRequestError (code P2003): Foreign key constraint violated — der Unterschied zwischen beiden Antworten ist das Existenzorakel (T-GWH-05)
favoritelink-gebundenes-anlegen-eigener-mandant-gelingt: bestanden — bound.favoriteLink.create unter TENANT-A mit widgetId='widget-a1' gelingt (id=fav-a1-neu); die Wartungsrolle liest danach tenantId="TENANT-A", createdAt und updatedAt gesetzt
Alle 137 Pruefungen bestanden.
```
Die tragende Belegzeile ist `favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste`: der
IDENTISCHE `findMany`, den `list` heute stellt, liefert UNGEBUNDEN `[]`,
während die Wartungsrolle zwei Zeilen sieht — das ist der Wert, aus dem
`favorites-widget.tsx` `Noch keine Favoriten.` macht (siehe (f3)).
Der Fremdschlüssel ist als mitgebaute Relation gemessen, nicht nur behauptet
(Befund C, WINDOWS #27): die Wegwerf-Tabelle `"FavoriteLink"` trägt
`FOREIGN KEY ("widgetId") REFERENCES "WidgetInstance"("id") ON DELETE CASCADE`
auf die von `runDashboardAreaChecks` bereits angelegte Tabelle; vorher wurde
über die Wartungsrolle gemessen, welche `WidgetInstance`-Zeilen tatsächlich
stehen (drei, siehe Belegausgabe von Prüfung 2), statt sie anzunehmen.
Das Ergebnis von Prüfung 7 (`favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei`)
ist zweigeteilt und trägt eine Entscheidung für Aufgabe 2: ein gebundenes
`create` unter TENANT-A mit `widgetId` eines TENANT-B-Widgets GELINGT — der
Fremdschlüssel prüft an der Zeilenschutz-Regel von `WidgetInstance` VORBEI
(dokumentiertes PostgreSQL-Verhalten: referentielle Integrität umgeht Row
Security). Derselbe Aufruf mit einer wirklich fehlenden `widgetId` scheitert
dagegen laut mit einer FK-Verletzung (Code P2003). Der Unterschied zwischen
beiden Antworten — "existiert nicht" (500/Fehler) vs. "gehört einem fremden
Mandanten" (gelingt) — ist ein Existenzorakel über Mandantengrenzen
(T-GWH-05, medium). Aufgabe 2 baut deshalb einen anwendungsseitigen
Besitzriegel in `create`, der beide Fälle auf dieselbe Antwort
(`Widget not found`) abbildet.
### (f2) Signaltabelle je umzustellendem Pfad
| Pfad | Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) | Konkretes Signal | Frontend lässt es durch? |
|---|---|---|---|
| `list` (`GET /favorites?widgetId=`) | Ungebundener `findMany` liefert `[]` statt der eigenen Zeilen | `200 []` | Ja — `fetchFavorites` (`favorites-api.ts:22-29`) gibt `[]` durch, siehe (f3) |
| `create` (`POST /favorites`) | Ungebundenes `create` schreibt eine Zeile, die unter der Mandantenkennung des Aufrufers physisch korrekt liegt, aber der Icon-Suchpfad ist unverändert; der Riegel aus Aufgabe 2 prüft VOR dem Schreiben, ob `widgetId` existiert und dem Aufrufer gehört | `NotFoundException('Widget not found')`, 404, wenn das Widget nicht dem Aufrufer gehört | Ja — `createFavorite` (`favorites-api.ts:33-46`) wirft `Failed to create favorite` bei `!res.ok` |
| `update` (`PATCH /favorites/:id`) | Ungebundener `findUnique` liefert `null` statt der eigenen Zeile, Vorprüfung greift bereits heute (userId-Vergleich) | `NotFoundException('FavoriteLink not found')`, 404 | Ja — `updateFavorite` wirft `Failed to update favorite` |
| `remove` (`DELETE /favorites/:id`) | Dieselbe Form wie `update` | `NotFoundException('FavoriteLink not found')`, 404 | Ja — `deleteFavorite` wirft `Failed to delete favorite` |
| `getIconBytes` (`GET /favorites/:id/icon`) | Dieselbe Vorprüfung wie `update`/`remove` | `NotFoundException('FavoriteLink not found')`, 404 | Ja — `<img>`-Ladefehler, vom Widget nicht gesondert behandelt |
### (f3) Welcher Code Leere als Abwesenheit deutet
Die `list`-Kette, alle Glieder namentlich: `list` liefert `[]` (die Belegzeile
`favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste`) →
`GET /favorites?widgetId=` antwortet `200 []` → `fetchFavorites`
(`favorites-api.ts:22-29`) prüft nur `!res.ok` (bei `200` erfüllt das nicht)
und gibt `[]` durch → `favorites-widget.tsx:212-213` zeigt
`t('favorites.empty')` = **`Noch keine Favoriten.`** (`de.json`, Zeile 219).
"Zeile unsichtbar" und "nie einen gespeichert" sind für das Frontend
derselbe Wert `[]` — das ist NICHT die Familie eines lauten Fehlers, sondern
dieselbe Familie wie WINDOWS #23/#25/#26/#28 (module-registry, dashboard,
calendar, auth).
`update`/`remove`/`getIconBytes` deuten Leere dagegen LAUT: die
Vorprüfung (`findUnique` → `null` oder fremder `userId`) wirft
`NotFoundException('FavoriteLink not found')`, 404 — `favorites-api.ts`
übersetzt das in `Failed to update/delete favorite`, das Widget setzt
`t('favorites.error')` im `catch`. Diese Richtung ist harmlos, weil ein zu
kleines Ergebnis dort bereits heute einen Fehler auslöst, der nicht mit dem
Scharfschalten neu entsteht.
### (f4) Was dieser Durchlauf bewusst nicht löst
- **(a) Die fehlende Benutzerdimension der Regel.** Prüfung 4
(`favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen`)
zeigt zweigeteilt: die eigenen Zeilen kommen korrekt, UND ein gebundenes
`findMany` auf die `widgetId` eines Kollegen DESSELBEN Mandanten liefert
dessen Zeile ebenfalls — die Regel auf `FavoriteLink` kennt keine
Benutzerdimension (dieselbe Lehre wie bei `CalendarSource`,
`DashboardLayout`, `WidgetInstance`). Die anwendungsseitige
`userId`-Filterung bleibt bestehen und ist bis zur Etappe-3-Entscheidung
(2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben
Mandanten.
- **(b) Das Frontend.** Die `list`-Kette aus (f3) wird nicht geändert;
Ledger-Eintrag in Aufgabe 3.
- **(c) Die Mandantenquelle — warum der dashboard-Präzedenzfall und nicht
der auth-Präzedenzfall.** `favorites.controller.ts` `extractContext` liest
`req.tenantId ?? req.user?.tenantId` — WORTGLEICH mit
`dashboard.controller.ts`, unter dessen Bindung `WidgetInstance` liegt.
`FavoriteLink` hängt über `widgetId` an `WidgetInstance`; würde
`favorites` stattdessen an das Claim binden (wie `auth` für
Selbstbedienung), während `dashboard` an der Guard-Kennung bleibt, lägen
Widget und Link unter einem `x-tenant-id`-Wechsel eines SUPER_ADMIN in
verschiedenen Mandanten. `favorites-api.ts` sendet die Kopfzeile heute
nicht (`grep -rn "x-tenant-id" apps/web/src`: nur die vier
Marktplatz-Stellen) — die Entscheidung hängt an der Bauform, nicht am
heutigen Aufrufer.
- **(d) Die Etappe-4-Vorabprüfung.** Für einen bekannten Nutzer/Widget die
Favoritenzahl über die Wartungsrolle und über den gebundenen `findMany`
daneben halten — dieselbe Form wie bei `dashboard`/`calendar`.
### (f5) Was dieser Durchlauf bewusst nicht anfasst
- Der Icon-Proxy (`icon-discovery.service.ts`, Befund G) — erreicht die
Datenbank NICHT und nimmt KEINE Client-URL: `getIconBytes` holt nur die
GESPEICHERTE `iconUrl` einer Zeile, die der Aufrufer besitzt (T-QFIP-01);
`discoverFavoriteIconUrl` nimmt die Nutzer-URL nur für den
SSRF-gesicherten Abruf (T-08-05). Kein Mandantenbezug — unverändert, in
der Testdatei eine Attrappe.
- Die DTOs (`create-favorite.dto.ts`, `update-favorite.dto.ts`) — nur
gelesen, kein Mandantenfeld.
- `dashboard.controller.ts` — nur gelesen (Präzedenzfall für (f4)(c)).
- Das Frontend (`favorites-widget.tsx`, `favorites-api.ts`) — nur
beschrieben, nicht geändert.
- Schema und Migrationen.
## Bereich settings
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `settings`
(Quick-Task 260911-gwh) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
Anders als jeder Bereich davor trägt dieser Bereich die Reihenfolgebedingung
Befund K aus den Läufen `tenders` und `dkv`: `getDecryptedSmtpConfig(tenantId)`
ist der einzige Versandpfad für Ausschreibungs- und DKV-Mails. Zusätzlich
trägt der Startpfad des Mailmoduls den SECHSTEN Fall der
Hintergrunddienst-Falle — anders als bei `dkv` (WINDOWS #21) verdeckt hier
eine Rückfallkette das Verstummen mit einem falschen Transport statt
schlichter Leere.
### (s1) Die Messung
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen weiteren
Abschnitt (`runSettingsAreaChecks`) erweitert, mit der Policy für
`SmtpConfig` (aus der ausgelieferten Migration
`20260909140000_rls_remaining_tenant_tables`) WORTGLEICH extrahiert. Die
Wegwerf-Tabelle trägt zusätzlich den Eindeutigkeitsindex
`SmtpConfig_tenantId_key` WORTGLEICH aus `20260629130000_add_missing_tables`
— ohne ihn misst Prüfung 8 nichts. Tatsächlich beobachtete Ausgabe dieses
Laufs (2026-09-11, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
```
smtpconfig-regelstand-eindeutig: bestanden — die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "SmtpConfig" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand
smtpconfig-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model SmtpConfig, skalare Felder ohne Relation, 10): ["createdAt","encryptedPassword","encryption","fromAddress","host","id","port","tenantId","updatedAt","username"]; Spalten der Wegwerf-Tabelle (10): [dieselben zehn]; Eindeutigkeitsindex "SmtpConfig_tenantId_key" ueber pg_indexes: ["CREATE UNIQUE INDEX \"SmtpConfig_tenantId_key\" ON public.\"SmtpConfig\" USING btree (\"tenantId\")"]
smtpconfig-startpfad-generierter-client-ungebunden-liefert-null: bestanden — ungebundenes prisma.smtpConfig.findFirst() (die Form von loadAnySmtpConfigForStartupTransport) liefert null, obwohl 2 Zeilen existieren — das ist der Wert, mit dem mail.module.ts nach dem Scharfschalten auf Umgebungsvariablen und zuletzt localhost:1025 zurueckfaellt, ein falscher Transport statt einer Meldung
smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile: bestanden — dieselbe Abfrage ueber die Wartungsrolle liefert genau EINE Zeile, tenantId="TENANT-A" — nichts in der Abfrage bestimmt, WELCHER Mandant gezogen wird
smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null: bestanden — ungebundenes prisma.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } }) (die Form von getDecryptedSmtpConfig) liefert null, waehrend die Wartungsrolle die Zeile liest
smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten: bestanden — gebunden unter TENANT-A liefert findUnique: host="smtp-a.example.invalid", fromAddress="a@example.invalid", encryptedPassword="enc(a-passwort-platzhalter)"
smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null: bestanden — gebunden unter TENANT-B liefert findUnique({ where: { tenantId: 'TENANT-A' } }): null
smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut: bestanden — ungebundenes prisma.smtpConfig.upsert(...) (die Form von saveSmtpConfig) wirft PrismaClientUnknownRequestError: ConnectorError ... SQLSTATE 42501, "new row violates row-level security policy for table \"SmtpConfig\"" — die Wartungsrolle liest danach weiterhin host="smtp-a.example.invalid"
smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert: bestanden — gebundenes upsert unter TENANT-A trifft die eigene Zeile (id=smtp-a), die Wartungsrolle liest danach den neuen Host, updatedAt gesetzt; smtp-b bleibt unveraendert
Alle 137 Pruefungen bestanden.
```
Die tragenden Belegzeilen sind drei: `smtpconfig-startpfad-generierter-client-ungebunden-liefert-null`
für den Startpfad, `smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null`
für Befund K, und `smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut`
für den Speicherkonflikt. Prüfung 8 wirft — gemessen, nicht angenommen —
**`PrismaClientUnknownRequestError`**, dieselbe Fehlerklasse, die 260910-krx
für `DashboardLayout` gemessen hat (nicht `PrismaClientKnownRequestError`
mit `P2002`, die Form der Bereiche `tenders`/`user`): die Regel weist den
Schreibzugriff ab, bevor eine Eindeutigkeit überhaupt geprüft wird. Die
zugrundeliegende PostgreSQL-Meldung (SQLSTATE `42501`, "new row violates
row-level security policy") ist in der Belegausgabe wörtlich enthalten.
### (s2) Signaltabelle je Pfad
| Pfad | Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) | Signal | Frontend |
|---|---|---|---|
| `getSmtpConfig` (`GET /settings/smtp`) | Ungebundener `findUnique` liefert `null` statt der eigenen Zeile | Controller gibt `null` → NestJS sendet `200` mit LEEREM Rumpf | Ja — verschluckt, siehe (s3) |
| `saveSmtpConfig` (`PUT /settings/smtp`) | Ungebundenes `upsert` scheitert am Eindeutigkeitsindex, weil die physisch vorhandene Zeile unsichtbar ist | `PrismaClientUnknownRequestError`, 500 | Nein — kein spezifischer Übersetzungspfad im Controller, roher 500 |
| `getDecryptedSmtpConfig` — Aufrufer `tender-mail.service.ts` (`resolveTransport`) | Ungebundener `findUnique` liefert `null` | `logger.warn('No SMTP configuration ... skipping tender mail send (will retry next run)')`, KEIN Wurf, Digest übersprungen | — (Hintergrunddienst, kein Frontend-Pfad) |
| `getDecryptedSmtpConfig` — Aufrufer `dkv-mail.service.ts` (`sendExportEmail`) | Dieselbe Form | `throw new Error('No SMTP configuration found for tenant ...')` | — (Hintergrunddienst) |
| `testSmtpConfig` (`POST /settings/smtp/test`) | Rückgriff auf `getDecryptedSmtpConfig` liefert `null`, `password`/`username` bleiben `undefined` (falls DTO leer) | Verbindungstest scheitert am Postfach, `{ success: false }` — irreführend, zeigt auf das Postfach statt auf die Datenbank | Ja — Testergebnis wird angezeigt |
| Startpfad `loadAnySmtpConfigForStartupTransport()` (`mail.module.ts`, beim Start) | Ungebundenes `findFirst()` liefert `null` statt einer beliebigen Zeile; die Rückfallkette von `mail.module.ts` greift (Priorität 2-4, zuletzt `localhost:1025`) — EIGENE Spalte, siehe (s4)(a) | Kein Fehler, ein FALSCHER, aber vorhandener Transport; `MailService` fängt jeden Transportfehler (T-02-12), Controller antwortet `200` | Ja — doppelt verdeckt |
### (s3) Welcher Code Leere als Abwesenheit deutet
Die `getSmtpConfig`-Kette, alle Glieder namentlich: `getSmtpConfig` liefert
`null` (Belegzeile `smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null`
zeigt dieselbe Form für den Versandpfad) → `settings.controller.ts` gibt
`null` zurück, ohne zu werfen → NestJS' `ExpressAdapter.reply` sendet bei
`isNil(body)` einen LEEREN Rumpf mit Status `200` (dieselbe Adapter-Kette wie
in (h3), 260911-fh9) → `fetchSmtp` (`settings-api.ts:51-57`) prüft nur
`res.status === 404` (nicht erfüllt) und `!res.ok` (bei `200` nicht erfüllt),
dann `res.json()` auf den leeren Rumpf → wirft → `smtp-settings-form.tsx:76-78`
`.catch(() => { /* Silent fail */ })` → leeres Formular: "SMTP nicht
eingerichtet", während die Zugangsdaten physisch noch da sind. "Nicht
eingerichtet" und "Zeile unsichtbar" sind für das Frontend derselbe Zustand
— dieselbe Familie wie WINDOWS #23/#25/#26/#28.
Trägt der Administrator die Zugangsdaten unter diesem Eindruck neu ein, läuft
`saveSmtpConfig` als `upsert({ where: { tenantId } })`: unter der
UNGEBUNDENEN Form ist die Zeile unsichtbar, der Upsert versucht ein `INSERT`
und scheitert am Eindeutigkeitsindex `SmtpConfig_tenantId_key` —
`PrismaClientUnknownRequestError` (Prüfung 8, gemessen statt vorweggenommen;
dieselbe Fehlerklasse wie die `dashboard`-Lehre aus 260910-krx, NICHT `P2002`).
Die beiden Versandpfade reagieren unterschiedlich auf `null`
(`getDecryptedSmtpConfig`): `tender-mail.service.ts` protokolliert eine
Warnung und überspringt den Versand (Wiederholung beim nächsten Lauf),
`dkv-mail.service.ts` wirft einen Fehler, der die aufrufende Pipeline
abbricht — beide Formen sind bereits vor diesem Lauf so verdrahtet, ändern
sich hier nicht.
### (s4) Was dieser Durchlauf bewusst nicht löst
**(a) Der Startpfad — der sechste Fall der Hintergrunddienst-Falle,
ausgeschrieben statt still getroffen.** `mail.module.ts`'s
`MailerModule.forRootAsync({ useFactory: async ... })` ruft
`SettingsService.loadAnySmtpConfigForStartupTransport()`
(vormals `getStartupSmtpConfig()`) BEIM START, vor jedem Anfragekontext.
Zwei Zustände, beide gehören benannt:
- **Heute** ist die Abfrage bereits FALSCH, nicht nur ungenau: bei mehreren
Mandanten trägt der SMTP-Server und die Absenderadresse EINES beliebigen
Mandanten die Kennwort-Zurücksetzungs- und Willkommensmails ALLER
Mandanten (T-GWH-03, Nutzung fremder Zugangsdaten, nicht nur Sichtbarkeit).
- **Nach dem Scharfschalten** liefert `findFirst()` `null` →
`mail.module.ts` fällt auf Priorität 2 (`MAIL_*`), 3 (`TESSERA_SMTP_*`),
zuletzt 4 (`localhost:1025`, Mailhog) zurück → `MailService` fängt jeden
Transportfehler (`mail.service.ts:69-81`, T-02-12) und der Controller
antwortet `200`. Das Verstummen ist damit DOPPELT verdeckt: erst durch die
Rückfallkette (ein falscher, aber vorhandener Transport statt Leere), dann
durch das absichtliche Verschlucken im Versand.
Drei geprüfte Formen: **(a) An einen konkret aufgelösten Mandanten binden**
— nicht möglich, `useFactory` hat beim Start keinen Anfragekontext.
**(b) Umbau auf Transport je Versand** — abgelehnt als Funktion für DIESEN
Lauf, mit Grund: die Vorlage steht bereits in `DkvMailService`/
`TenderMailService` (Transport je Versand aus
`getDecryptedSmtpConfig(tenantId)`), `MailService` müsste dafür nur den
Mandanten entgegennehmen, den `requestPasswordReset` aus der Funktionszeile
bereits hat — ein Umbau des Mailmoduls, kein Bindungsumbau, NICHT dieser
Auftrag (siehe `<hard_constraints>`). **(c) Als benannte Altlast
weiterführen, mit Markierung** — GEWÄHLT: Methode umbenannt
(`loadAnySmtpConfigForStartupTransport()`, dkv-Präzedenzfall — ein Name, den
niemand für einen Anfrageweg hält), Kopfkommentar mit beiden Zuständen,
Modulkommentar in `mail.module.ts`, eigener Ledger-Eintrag.
**Die Unsymmetrie zu BEIDEN Präzedenzfällen:** `getAllActiveConfigs` (ldap)
ist heute korrekt und verstummt erst später; `loadAnyActiveConfigForScheduler`
(dkv, WINDOWS #21) ist heute bereits falsch und verstummt zusätzlich später,
ABER mit einer Protokollzeile ("no active config found — cron job not
registered"). Der Mail-Startpfad ist heute bereits falsch UND verstummt
später OHNE Protokollzeile, weil die Rückfallkette ihn überdeckt — das ist
eine DRITTE Ausprägung, keine der beiden Vorlagen deckt sie vollständig.
**Entscheidung: EIGENER Ledger-Eintrag statt Anschluss an #21.** Andere
Datei (`mail.module.ts`/`settings.service.ts` statt
`dkv-scheduler.service.ts`), andere Reparatur (Transport je Versand statt
Mehrmandanten-Planung), andere Verdeckungsform (Rückfallkette statt bloßer
Leere) — drei eigenständige Unterschiede, kein Wiederholungsfall von #21.
**(b) Befund K ist erfüllt.** Die Reihenfolgebedingung aus (t4) Befund K und
(d4) Übergaben-Absatz — `getDecryptedSmtpConfig(tenantId)` müsse gebunden
sein, bevor Etappe 4 scharfschaltet — ist mit Aufgabe 2 dieses Laufs
ERFÜLLT: die Methode läuft seither über GENAU EINEN Klienten `tenantPrisma`.
Beide Stellen bekommen in Aufgabe 3 einen Nachtrag (siehe (t4), (d4) unten in
diesem Dokument sowie den Hintergrunddienst-Abschnitt in
`docs/mandantentrennung-zugriffsklassifikation.md`). Die Etappe-4-Vorabprüfung
muss diese Bedingung ab jetzt NICHT mehr führen.
**(c) Das Frontend.** Die `getSmtpConfig`-Kette aus (s3) wird nicht
geändert; Ledger-Eintrag in Aufgabe 3 (Familie #28 — dieselbe
200-leerer-Rumpf-Kette wie `auth`).
**(d) Die Mandantenquelle `req.tenantId` im Controller.** Bewusst NICHT auf
das Claim umgestellt (anders als die Selbstbedienungs-Begründung aus
`auth`): `settings.controller.ts` ist eine ADMIN-Konfigurationsseite, für
die `req.tenantId` (per `x-tenant-id` für SUPER_ADMIN umschaltbar) die
RICHTIGE Quelle ist (D-10) — ein SUPER_ADMIN konfiguriert damit gezielt die
SMTP-Zugangsdaten eines ANDEREN Mandanten. Der Controller bleibt deshalb
unverändert und steht nicht in der Erlaubnisliste.
**(e) Die Etappe-4-Vorabprüfung.** Für einen bekannten Mandanten die
`SmtpConfig`-Zeile über die Wartungsrolle lesen und den gebundenen
`findUnique` daneben halten — dieselbe Form wie bei `dashboard`/`calendar`.
### (s5) Was dieser Durchlauf bewusst nicht anfasst
- `settings.controller.ts` — nur gelesen (siehe (s4)(d)).
- `apps/api/src/settings/dto/smtp-config.dto.ts` — nur gelesen, kein
Mandantenfeld.
- `tender-mail.service.ts` (`resolveTransport`) — nur gelesen, ruft
weiterhin `getDecryptedSmtpConfig(tenantId)` unverändert auf.
- `dkv-mail.service.ts` (`sendExportEmail`) — dieselbe Form.
- `mail.service.ts` — nur gelesen; `mail.module.ts` wird für die
Umbenennung des Startpfad-Aufrufs angefasst (Aufgabe 2), hier nur
angekündigt.
- Das Frontend (`smtp-settings-form.tsx`, `settings-api.ts`) — nur
beschrieben, nicht geändert.
- Schema und Migrationen.
- `nodemailer` — kein echter Transport in irgendeinem Test dieses Laufs
(lokal gibt es keinen `mailhog`); in der neuen Testdatei per
`vi.mock('nodemailer')` ersetzt.
## Etappe 2 — Abschluss
Etappe 2 der Mandantentrennung ist mit diesem Lauf (260911-gwh) vollständig:
jede klassifizierte Fundstelle in `apps/api/src` ist entweder gebunden oder
mit geschriebenem Grund an der Stelle ungebunden. Die Zahlen unten sind aus
den eigenen Messanweisungen des Klassifikationsdokuments abgeleitet
(Übersichtszeilen, Summenzeile, Klassen-Verteilung), nicht neu geschätzt.
**Läufe.** Zwölf Bereichs-/Regel-Läufe von 260909-ipc bis 260911-gwh (gezählt
aus `.planning/STATE.md`, Reihenfolge): `ldap` (260909-ipc), `groups`
(260909-jts), `tenders` (260909-laa), `dkv` (260909-mir), `user`
(260910-das), `module-registry` (260910-exd), `dashboard` (260910-krx), das
Regelschluss-Plan `tenders`/`SearchProvider` (260910-jab), `tenant`
(260911-e2s), `calendar` (260911-cwh), `auth` (260911-fh9), `favorites`/
`settings` (260911-gwh, dieser Lauf).
**Summenzeile der Übersichtstabelle, vorher/nachher.** Zum Kopf des
Klassifikationsdokuments (Stand 260909-eor): 227 Rohtreffer über 59
Datei-Modell-Paare. Nach Aufgabe 3 dieses Laufs (siehe Summenzeile in
`docs/mandantentrennung-zugriffsklassifikation.md`, DERIVIERT aus den
Bereichszeilen, nicht abgeschrieben): siehe dortige Summenzeile für die
Endzahl.
**Klassen-Verteilung.** Siehe `docs/mandantentrennung-zugriffsklassifikation.md`,
Abschnitt "Klassen-Verteilung", Stand 260911-gwh (Aufgabe 3) für die
Paarzahl und die Verteilung nach den vier Klassen.
**Bewusst ungebundene Reste je Bereich, mit Grund:**
- `tenders`: der D-03-Katalog (platform-global, `Tender`/`TenderSource`/
`TenderSourcePollConfig`), die zwei Fan-out-Adapter (E-Mail/RSS, ein Tick
pro Postfach bzw. Feed über alle Mandanten), die übergreifenden Hälften
der beiden Hintergrunddienste (Etappe-3-Übergabe), `createPlatform`/
`remove` in `tender-rss-feed.service.ts` (WINDOWS #24).
- `ldap`: `getAllActiveConfigs`/`onApplicationBootstrap` (Befund B),
`resolveEmailForWrite` (plattformweit eindeutiger Schlüssel, T-IPC-04).
- `dkv`: der Planer-Startpfad `loadAnyActiveConfigForScheduler()`
(WINDOWS #21).
- `user`: `findByUsername` (plattformweit eindeutiger Schlüssel), die
Erstanlage-Prüfung und beide `tenant`-Zugriffe in `admin-seed.service.ts`,
der Schleifentreiber `tenant.findMany` der Plattform-Administratorsicht.
- `module-registry`/`dashboard`: der Modulkatalog (`Module`, keine Regel
heute — Befund E).
- `auth`: die drei Anmeldefunktionen (`validateUser`,
`requestPasswordReset`, `resetPassword`) über die SECURITY-DEFINER-Wege.
- `tenant`: die Mandantentabelle selbst (`Tenant`, keine eigene
`tenantId`-Spalte, keine Regel in irgendeiner Migration).
- `settings`: der sechste Fall der Hintergrunddienst-Falle,
`loadAnySmtpConfigForStartupTransport()` (dieser Lauf, siehe (s4)(a)).
**Offene Ledger-Einträge dieser Etappe** (Nummern siehe `.planning/WINDOWS.md`,
Kopfzähler geprüft): #18 (Schalter aus), #20 (Verbindungsfehler, an dieselbe
Bedingung gebunden wie #18), #21 (dkv-Planer-Startpfad), #22
(plattformweite Eindeutigkeit von `username`/`email`), #23
(module-registry, unterscheidbares Signal fehlt), #24 (plattformweite
RSS-Verwaltung unter der Anwendungsrolle), #25 (dashboard, beweisvernichtende
Fehlerrichtung), #26 (calendar, verschluckte Leere), #27
(Relationszugriffe für die Bestandsaufnahme unsichtbar), #28 (auth,
verschluckte Leere), plus die drei neuen Einträge dieses Laufs (Startpfad
des Mailmoduls, verschluckte Leere `favorites`, verschluckte Leere
`settings` — Nummern siehe Aufgabe 3 dieses Plans).
**Prüfungen und Tests.** Werkzeug: siehe die Zeile `Alle N Prüfungen
bestanden.` des letzten Laufs von `rls-scratch-check.mjs` in dieser Aufgabe.
Tests: siehe die letzte Testausgabe von `npm --prefix apps/api run test` in
dieser Aufgabe.
**Was für Etappe 3 bleibt:**
- Der Anmeldeweg unter je Mandant eindeutigen Namen (`username`/`email`,
Etappe-3-Entscheidung (1)) — siehe (h4)(a).
- Die Benutzerdimension der Regeln (Etappe-3-Entscheidung (2)) — siehe
(k4)/(f4)(a) und die übrigen Bereiche mit derselben Beobachtung.
- Die Modulkatalog-Regel für `Module` (Befund E, `module-registry`) — sobald
eine Regel eingeführt wird, müssen die heute bewusst ungebundenen
Katalogzugriffe nachgezogen werden.
- Die Kennzeichnung der `bewusst-uebergreifend`-Stellen (Systemkontext,
siehe "Die drei Klassen" im Klassifikationsdokument).
- Der Mandantenwechsel im Ausschreibungs-Digest ((t4), der Sonderfall eines
Nutzers mit Treffern unter zwei verschiedenen Mandanten).
**Was Etappe 4 (`rls-preflight.mjs`) VOR dem Scharfschalten prüfen muss** —
die in den Bereichsabschnitten benannten Vorabprüfungen, als Liste (OHNE die
jetzt erfüllte Befund-K-Bedingung, die entfällt):
- `ldap`: das Verstummen von `getAllActiveConfigs` (Befund B).
- `dkv`: das Verstummen des Planer-Startpfads (WINDOWS #21).
- `tenders`: wachsende Zahl von `TenderMatch`-Zeilen mit `notifiedAt IS NULL`
ohne Versandprotokoll (t3).
- `module-registry`: aktive Aktivierungszeilen vorhanden, aber die
Auflösung liefert für einen bekannten Administrator eine leere Menge
(WINDOWS #23).
- `dashboard`: eine physisch vorhandene `DashboardLayout`-Zeile für einen
bekannten Benutzer, aber der gebundene Lesezugriff liefert `null`
(WINDOWS #25).
- `calendar`: physisch vorhandene `CalendarSource`-Zeilen je Mandant über
die Wartungsrolle zählen und mit der gebundenen Zählung vergleichen
(k4)(e).
- `auth`: einen bekannten Benutzer über die Wartungsrolle lesen und den
gebundenen `findUnique` unter seinem Claim-Mandanten daneben halten
(WINDOWS #28).
- `favorites`: für einen bekannten Nutzer/Widget die Favoritenzahl über die
Wartungsrolle und über den gebundenen `findMany` daneben halten (f4)(d).
- `settings`: für einen bekannten Mandanten die `SmtpConfig`-Zeile über die
Wartungsrolle lesen und den gebundenen `findUnique` daneben halten
(s4)(e).
- Das Verstummen des Mail-Startpfads (dieser Lauf, (s4)(a)) — EIGENES
Signal, NICHT an #21 angeschlossen.
## Verweis ## Verweis
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang