feat(260911-e2s): Fehlerrichtung fuer Bereich tenant messen — Relationszaehler laeuft unter User

runTenantAreaChecks (9 neue Pruefungen, 6 davon ueber den generierten
Client) belegt: auf "Tenant" ist nichts zu binden (keine Regel in allen
34 Migrationen einschliesslich 20260910120000), aber der
Relationszaehler in findAll/findOne/remove liefert nach dem
Scharfschalten userCount=0 fuer jeden Mandanten und laesst den
Loeschriegel T-02-09 vakuum werden — der Fremdschluessel faengt das
nur laut (500) statt mit der verstaendlichen 400-Meldung ab.

docs/mandantentrennung-etappe2-fehlerrichtung.md bekommt den Abschnitt
"## Bereich tenant" (n1-n5) mit der tatsaechlich beobachteten
Werkzeugausgabe, der Signaltabelle je Pfad, den Frontend-Stellen, die
die falsche Zahl unkommentiert durchlassen, und der Entscheidung zur
Anfrageobjekt-Eigenschaft (Vorbereitung fuer Aufgabe 2).

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 10:46:04 +02:00
parent 6426b18630
commit 652e762ad4
2 changed files with 561 additions and 0 deletions
+350
View File
@@ -3096,6 +3096,355 @@ async function runCalendarAreaChecks(adminUrl, scratchRoleUrl, results) {
}
}
/**
* Liest die Migration `20260618112124_auth_multi_tenancy` (Dateiname endet
* auf "_auth_multi_tenancy") — die einzige, die `CREATE TABLE "Tenant"` und
* den Fremdschluessel `User_tenantId_fkey` enthaelt.
*/
function readAuthMultiTenancyMigrationSql() {
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.endsWith('_auth_multi_tenancy'))
.map((entry) => entry.name);
if (dirs.length !== 1) return null;
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
}
/**
* Schneidet die Spaltennamen aus dem `CREATE TABLE "Tenant" ( ... );`-Block
* der Migration — NICHT aus `readSchemaModelFieldNames('Tenant')` (Befund M):
* `schema.prisma` fuehrt bei `Tenant` vier Relationsfelder (`users`,
* `ldapConfig`, `groups`, `moduleGrants`), die keine Spalten sind und die
* Client-Vergleichspruefung faelschlich durchfallen liessen.
*/
function readTenantCreateTableColumns(migrationSql) {
const match = migrationSql.match(/CREATE TABLE "Tenant" \(([\s\S]*?)\n\);/);
if (!match) return [];
const columns = [];
for (const rawLine of match[1].split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('CONSTRAINT')) continue;
const m = line.match(/^"([a-zA-Z]+)"/);
if (m) columns.push(m[1]);
}
return columns;
}
/**
* Schneidet `ALTER TABLE "User" ADD CONSTRAINT "User_tenantId_fkey" ...;`
* wortgleich aus der Migration — nicht getippt (Aufgabe 1, TEIL 1).
*/
function readUserTenantForeignKeySql(migrationSql) {
const match = migrationSql.match(
/ALTER TABLE "User" ADD CONSTRAINT "User_tenantId_fkey"[\s\S]*?;/,
);
return match ? match[0] : null;
}
/**
* Aufgabe 1 (260911-e2s) — misst die neun im Plan genannten
* Verhaltensweisen des Bereichs `tenant` unter der Rolle ohne BYPASSRLS. Auf
* `Tenant` selbst ist nichts zu binden (keine Regel in irgendeiner
* ausgelieferten Migration, einschliesslich `20260910120000_...` — Pruefung
* 1) — dieser Abschnitt hat trotzdem neun Pruefungen, weil drei der acht
* Zugriffsstellen des Controllers ueber eine Relationseinbindung
* (`include: { _count: { select: { users } } }`) in die GESCHUETZTE Tabelle
* "User" hineinzaehlen (Befund F).
*
* Setzt auf den bereits vorhandenen Wegwerf-Tabellen "Tenant" (aus
* `runUserAreaChecks`, dort nur `id`/`slug`) und "User" (aus
* `runAuthLookupChecks`, mit Zeilenschutz und wortgleicher Regel) auf und
* erweitert "Tenant" um die vier fehlenden Spalten sowie den Fremdschluessel
* `User_tenantId_fkey` — keine spaetere Pruefung setzt auf diesen
* Erweiterungen auf (Befund M: `runTransactionShapeMeasurement` und
* `runConcurrencyProbe` fassen weder "User" noch "Tenant" an). Muss deshalb
* NACH `runCalendarAreaChecks()` und VOR `runTransactionShapeMeasurement()`
* laufen (siehe Aufrufkette in main()).
*/
async function runTenantAreaChecks(adminUrl, scratchRoleUrl, results) {
// Pruefung 1: keine Regel auf "Tenant" in irgendeiner ausgelieferten
// Migration — liest jede Datei zur Laufzeit, statt der Dokumentation zu
// glauben.
const migrationDirNames = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
const violatingMigrations = [];
for (const dirName of migrationDirNames) {
const sql = readFileSync(join(MIGRATIONS_DIR, dirName, 'migration.sql'), 'utf-8');
if (
/CREATE POLICY \w+ ON "Tenant"/.test(sql) ||
/ALTER TABLE "Tenant"/.test(sql)
) {
violatingMigrations.push(dirName);
}
}
const widenMigrationDirName = migrationDirNames.find((d) =>
d.endsWith('_rls_widen_membership_grant_and_platform_read'),
);
report(
results,
'tenant-keine-regel-in-allen-ausgelieferten-migrationen',
violatingMigrations.length === 0 && Boolean(widenMigrationDirName),
`${migrationDirNames.length} Migrationsverzeichnisse gelesen, darunter "${widenMigrationDirName ?? 'NICHT GEFUNDEN'}" — "Tenant" kommt darin nicht vor; ${violatingMigrations.length} Verzeichnis(se) mit CREATE POLICY/ALTER TABLE auf "Tenant": ${JSON.stringify(violatingMigrations)}`,
);
const authMultiTenancySql = readAuthMultiTenancyMigrationSql();
const tenantColumnsFromMigration = authMultiTenancySql
? readTenantCreateTableColumns(authMultiTenancySql)
: [];
const userTenantFkSql = authMultiTenancySql
? readUserTenantForeignKeySql(authMultiTenancySql)
: null;
if (!authMultiTenancySql || tenantColumnsFromMigration.length === 0 || !userTenantFkSql) {
report(
results,
'tenant-migration-auth-multi-tenancy-und-fremdschluessel-gefunden',
false,
`Migration *_auth_multi_tenancy=${Boolean(authMultiTenancySql)}, CREATE TABLE "Tenant"-Spalten=${tenantColumnsFromMigration.length}, User_tenantId_fkey gefunden=${Boolean(userTenantFkSql)}`,
);
return;
}
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
// (a) Vier fehlende Spalten, Typen aus dem ausgelieferten
// `CREATE TABLE "Tenant"` (20260618112124_auth_multi_tenancy).
// ABWEICHUNG: fuer "name" und "updatedAt" braucht das Nachruesten gegen
// die beiden bereits vorhandenen Zeilen (TENANT-A/TENANT-B, angelegt von
// runUserAreaChecks) einen DEFAULT, den die ausgelieferte Migration
// selbst nicht hat (dort NOT NULL ohne DEFAULT) — betrifft nur dieses
// Nachruesten hier, keine Aussage ueber den ausgelieferten Stand.
await db.$executeRawUnsafe(
`ALTER TABLE "Tenant" ADD COLUMN "name" TEXT NOT NULL DEFAULT 'Platzhalter';`,
);
await db.$executeRawUnsafe(
`ALTER TABLE "Tenant" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true;`,
);
await db.$executeRawUnsafe(
`ALTER TABLE "Tenant" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;`,
);
await db.$executeRawUnsafe(
`ALTER TABLE "Tenant" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;`,
);
// (b) Fremdschluessel wortgleich aus der Migration geschnitten (oben),
// nicht getippt — die vier vorhandenen "User"-Zeilen referenzieren
// ausschliesslich TENANT-A/TENANT-B, beide existieren bereits.
await db.$executeRawUnsafe(userTenantFkSql);
// (c) Dritte Mandantenzeile ohne Benutzer.
await db.$executeRawUnsafe(
`INSERT INTO "Tenant" (id, slug, name) VALUES ('TENANT-C', 'tenant-c', 'Tenant C');`,
);
});
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
// Pruefung 2: gebunden (TENANT-A), ungebunden und ueber die Wartungsrolle
// liefern DIESELBEN drei Kennungen — der Beleg "nichts zu binden".
const boundIdsRaw = (
await forTenantQuery(prisma, 'TENANT-A', (tx) => tx.$queryRaw`SELECT id FROM "Tenant" ORDER BY id`)
).map((r) => r.id);
const unboundIdsRaw = (await prisma.$queryRaw`SELECT id FROM "Tenant" ORDER BY id`).map(
(r) => r.id,
);
const adminIdsRaw = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => (await db.$queryRaw`SELECT id FROM "Tenant" ORDER BY id`).map((r) => r.id),
);
const allIdenticalRaw =
JSON.stringify(boundIdsRaw) === JSON.stringify(unboundIdsRaw) &&
JSON.stringify(unboundIdsRaw) === JSON.stringify(adminIdsRaw);
report(
results,
'tenant-gebunden-und-ungebunden-liefern-dieselben-zeilen',
allIdenticalRaw,
`Roh-SQL gebunden (TENANT-A): ${JSON.stringify(boundIdsRaw)}; ungebunden: ${JSON.stringify(unboundIdsRaw)}; Wartungsrolle: ${JSON.stringify(adminIdsRaw)}`,
);
// Pruefung 3 — steht VOR den Client-Pruefungen (4-9); faellt sie durch,
// bricht der Abschnitt ab (Lehre aus Pruefung 8 im Bereich `calendar`).
const migrationColumnsSorted = [...tenantColumnsFromMigration].sort();
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 = 'Tenant'
`;
return rows.map((r) => r.column_name).sort();
},
);
const columnsMatch =
migrationColumnsSorted.length > 0 &&
migrationColumnsSorted.length === tableColumns.length &&
migrationColumnsSorted.every((f, i) => f === tableColumns[i]);
report(
results,
'tenant-wegwerftabelle-deckt-alle-spalten-des-generierten-clients',
columnsMatch,
`Spalten aus CREATE TABLE "Tenant" in 20260618112124_auth_multi_tenancy (${migrationColumnsSorted.length}): ${JSON.stringify(migrationColumnsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}`,
);
if (!columnsMatch) {
return;
}
// Pruefung 4: derselbe Vergleich ueber den generierten Client.
const bound = buildInlineExtendedClient(prisma, 'TENANT-A');
const clientBoundIds = (await bound.tenant.findMany({ orderBy: { id: 'asc' } })).map(
(t) => t.id,
);
const clientUnboundIds = (await prisma.tenant.findMany({ orderBy: { id: 'asc' } })).map(
(t) => t.id,
);
const clientIdsMatch =
JSON.stringify(clientBoundIds) === JSON.stringify(boundIdsRaw) &&
JSON.stringify(clientUnboundIds) === JSON.stringify(boundIdsRaw);
report(
results,
'tenant-generierter-client-zeilen-gebunden-und-ungebunden-identisch',
clientIdsMatch,
`generierter Client gebunden (TENANT-A): ${JSON.stringify(clientBoundIds)}; ungebunden: ${JSON.stringify(clientUnboundIds)}; Roh-SQL-Vergleichswert (Pruefung 2): ${JSON.stringify(boundIdsRaw)}`,
);
// Wartungszahl je Mandant, fuer Pruefung 5/6/9.
const adminUserCountRows = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) =>
db.$queryRaw`SELECT "tenantId", count(*)::int AS c FROM "User" GROUP BY "tenantId"`,
);
const adminUserCounts = new Map(adminUserCountRows.map((r) => [r.tenantId, r.c]));
// Pruefung 5 — die tragende Belegzeile: die Abfrage, die `findAll`
// heute stellt, UNGEBUNDEN auf dem generierten Client: jeder Zaehler ist
// 0, waehrend die Wartungsrolle je Mandant mehr als 0 zaehlt.
const clientUnboundWithCounts = await prisma.tenant.findMany({
include: { _count: { select: { users: true } } },
orderBy: { id: 'asc' },
});
const allUnboundCountsZero = clientUnboundWithCounts.every((t) => t._count.users === 0);
const groundTruthHasPositiveCounts = ['TENANT-A', 'TENANT-B'].every(
(id) => (adminUserCounts.get(id) ?? 0) > 0,
);
report(
results,
'tenant-generierter-client-benutzerzaehler-ungebunden-null-fuer-jeden-mandanten',
allUnboundCountsZero && groundTruthHasPositiveCounts,
`das ist die Zahl, die admin/tenants/page.tsx als Benutzeranzahl anzeigen wuerde — ungebunden: ${JSON.stringify(clientUnboundWithCounts.map((t) => ({ id: t.id, userCount: t._count.users })))}; Wartungszahl je Mandant: ${JSON.stringify(Object.fromEntries(adminUserCounts))}`,
);
// Pruefung 6: dieselbe Abfrage gebunden unter TENANT-A.
const clientBoundWithCounts = await bound.tenant.findMany({
include: { _count: { select: { users: true } } },
orderBy: { id: 'asc' },
});
const countByIdBound = new Map(clientBoundWithCounts.map((t) => [t.id, t._count.users]));
const boundCountsMatchExpectation =
countByIdBound.get('TENANT-A') === adminUserCounts.get('TENANT-A') &&
countByIdBound.get('TENANT-B') === 0 &&
countByIdBound.get('TENANT-C') === 0 &&
(adminUserCounts.get('TENANT-B') ?? 0) > 0;
report(
results,
'tenant-generierter-client-benutzerzaehler-gebunden-nur-eigener-mandant',
boundCountsMatchExpectation,
`gebunden unter TENANT-A: A=${countByIdBound.get('TENANT-A')} (Wartungszahl=${adminUserCounts.get('TENANT-A')}), B=${countByIdBound.get('TENANT-B')} (Wartungszahl=${adminUserCounts.get('TENANT-B') ?? 0}), C=${countByIdBound.get('TENANT-C')}`,
);
// Pruefung 7 — die Abfrage, die `remove` heute stellt, ungebunden:
// Zaehler 0 trotz aktiver Benutzer bei der Wartungsrolle, der Riegel
// T-02-09 liesse das Loeschen durch; das anschliessende ungebundene
// `delete` ueber den generierten Client scheitert LAUT am
// Fremdschluessel, der den Zeilenschutz umgeht.
const removeQueryUnbound = await prisma.tenant.findUnique({
where: { id: 'TENANT-A' },
include: { _count: { select: { users: { where: { isActive: true } } } } },
});
const unboundActiveCount = removeQueryUnbound?._count.users;
const adminActiveCountA = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows =
await db.$queryRaw`SELECT count(*)::int AS c FROM "User" WHERE "tenantId" = 'TENANT-A' AND "isActive" = true`;
return rows[0].c;
},
);
const gateWouldPassThrough = unboundActiveCount === 0 && adminActiveCountA > 0;
let deleteThrew = false;
let deleteErrCtor = 'unbekannt';
let deleteErrCode;
let deleteErrMessage = '';
try {
await prisma.tenant.delete({ where: { id: 'TENANT-A' } });
} catch (err) {
deleteThrew = true;
deleteErrCtor = err?.constructor?.name ?? 'unbekannt';
deleteErrCode = err?.code;
deleteErrMessage = (err.message ?? '').toString().trim();
}
const stillExistsAfterDelete = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT id FROM "Tenant" WHERE id = 'TENANT-A'`;
return rows.length === 1;
},
);
report(
results,
'tenant-loeschriegel-ungebunden-vakuum-fremdschluessel-faengt-laut',
gateWouldPassThrough && deleteThrew && stillExistsAfterDelete,
`ungebundener Relationszaehler ueber aktive Benutzer fuer TENANT-A=${unboundActiveCount}, Wartungszahl=${adminActiveCountA} — der Riegel T-02-09 liesse das Loeschen durch; ungebundenes prisma.tenant.delete ueber den generierten Client wirft ${deleteErrCtor}${deleteErrCode ? ` (code ${deleteErrCode})` : ''}: ${deleteErrMessage} — die Zeile existiert ueber die Wartungsrolle danach noch: ${stillExistsAfterDelete}; die referentielle Pruefung des Fremdschluessels umgeht den Zeilenschutz und faengt das Loeschen trotzdem ab`,
);
// Pruefung 8 — dasselbe Loeschen fuer TENANT-C (ohne Benutzer) gelingt:
// falsifiziert "Loeschen scheitert immer".
let deleteCSucceeded = false;
let deleteCDetail = '';
try {
await prisma.tenant.delete({ where: { id: 'TENANT-C' } });
deleteCSucceeded = true;
deleteCDetail =
'ungebundenes prisma.tenant.delete fuer TENANT-C (ohne Benutzer) ist NICHT fehlgeschlagen';
} catch (err) {
deleteCDetail = `ungebundenes prisma.tenant.delete fuer TENANT-C ist unerwartet fehlgeschlagen: ${err.message}`;
}
const goneAfterDeleteC = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const rows = await db.$queryRaw`SELECT id FROM "Tenant" WHERE id = 'TENANT-C'`;
return rows.length === 0;
},
);
report(
results,
'tenant-loeschen-ohne-benutzer-gelingt-wie-heute',
deleteCSucceeded && goneAfterDeleteC,
`${deleteCDetail}; ueber die Wartungsrolle danach noch vorhanden: ${!goneAfterDeleteC}`,
);
// Pruefung 9 — die Form, die Aufgabe 3 einbaut: `prisma.tenant.findMany`
// ungebunden als Treiber, dann je VERBLIEBENEM Mandanten (TENANT-C ist
// seit Pruefung 8 geloescht) ein gebundener Zaehlaufruf.
const remainingTenants = await prisma.tenant.findMany({ orderBy: { id: 'asc' } });
const fanOutResults = [];
let fanOutAllMatch = remainingTenants.length > 0;
for (const t of remainingTenants) {
const boundForT = buildInlineExtendedClient(prisma, t.id);
const count = await boundForT.user.count({ where: { tenantId: t.id } });
const adminCount = adminUserCounts.get(t.id) ?? 0;
fanOutResults.push({ id: t.id, gebunden: count, wartung: adminCount });
if (count !== adminCount) fanOutAllMatch = false;
}
report(
results,
'tenant-fan-out-je-mandant-gebundene-zaehlung-stimmt',
fanOutAllMatch,
`Fan-out je verbliebenem Mandanten (${remainingTenants.length}): ${JSON.stringify(fanOutResults)}`,
);
} finally {
await prisma.$disconnect();
}
}
/**
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
@@ -3333,6 +3682,7 @@ async function main() {
await runModuleRegistryAreaChecks(adminUrl, scratchRoleUrlString, results);
await runDashboardAreaChecks(adminUrl, scratchRoleUrlString, results);
await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results);
await runTenantAreaChecks(adminUrl, scratchRoleUrlString, results);
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
await runConcurrencyProbe(scratchRoleUrlString, results);
} finally {