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:
@@ -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
|
||||
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
|
||||
@@ -4015,6 +4590,8 @@ async function main() {
|
||||
await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runTenantAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runAuthAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runFavoritesAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runSettingsAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
|
||||
await runConcurrencyProbe(scratchRoleUrlString, results);
|
||||
} 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
|
||||
Etappe 4, genau wie Befund D des `ldap`-Durchlaufs es für `groups` war —
|
||||
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
|
||||
`tenders` entscheidet sie nicht — er bindet dienst-intern, wie `ldap`
|
||||
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
|
||||
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`
|
||||
entscheidet sie nicht — er bindet dienst-intern, wie `ldap`, `groups` und
|
||||
`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
|
||||
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
|
||||
|
||||
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
||||
|
||||
Reference in New Issue
Block a user