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 {
@@ -2259,6 +2259,217 @@ Browsers und im API-Log, an keiner Stelle in der Benutzeroberfläche.
- Schema und Migrationen — geprüft und bewusst gelassen, keine
Schemaänderung in dieser Etappe.
## Bereich tenant
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `tenant`
(Quick-Task 260911-e2s) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
Anders als jeder Bereich davor betrifft er nicht mandantengebundene Tabellen,
sondern die Mandantentabelle SELBST — `Tenant` hat per Definition keine
`tenantId`-Spalte und ist deshalb die einzige Tabelle, auf der es nichts zu
binden gibt. Der Bereich traegt trotzdem zwei Dinge, die alle neun Bereiche
davor vertagt oder uebersehen haben: die seit Etappe 1 offene Entscheidung
zum gebundenen Klienten auf dem Anfrageobjekt (Aufgabe 2), und einen Befund,
den die Erwartung "null Umstellungsarbeit" verdeckt haette — drei der acht
Zugriffe zaehlen ueber eine Relationseinbindung in die GESCHUETZTE Tabelle
`User` hinein (Aufgabe 3).
### (n1) Die Messung
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen zwoelften
Abschnitt (`runTenantAreaChecks`) erweitert. Sechs der neun neuen Pruefungen
(4, 5, 6, 7, 8, 9) laufen ueber den GENERIERTEN CLIENT
(`prisma.tenant.findMany`/`findUnique`/`delete`, `bound.tenant.findMany`,
`bound.user.count`) statt ueber Roh-SQL — bewusst, weil der Relationszaehler
(`include: { _count: { select: { users } } }`), den `findAll`/`findOne`
tatsaechlich benutzen, eine Client-Form ist: Roh-SQL sieht ihn strukturell
nicht (Fehler 7 des Vorhabens — "Roh-SQL ist nicht der generierte Client").
Pruefung 1 liest zur Laufzeit jede der 34 ausgelieferten
`migration.sql`-Dateien und prueft auf `CREATE POLICY ... ON "Tenant"` sowie
`ALTER TABLE "Tenant"` — ausdruecklich EINSCHLIESSLICH
`20260910120000_rls_widen_membership_grant_and_platform_read`, die "Tenant"
nirgends nennt. Tatsaechlich beobachtete Ausgabe dieses Laufs (2026-09-11,
gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
```
tenant-keine-regel-in-allen-ausgelieferten-migrationen: bestanden — 34 Migrationsverzeichnisse gelesen, darunter "20260910120000_rls_widen_membership_grant_and_platform_read" — "Tenant" kommt darin nicht vor; 0 Verzeichnis(se) mit CREATE POLICY/ALTER TABLE auf "Tenant": []
tenant-gebunden-und-ungebunden-liefern-dieselben-zeilen: bestanden — Roh-SQL gebunden (TENANT-A): ["TENANT-A","TENANT-B","TENANT-C"]; ungebunden: ["TENANT-A","TENANT-B","TENANT-C"]; Wartungsrolle: ["TENANT-A","TENANT-B","TENANT-C"]
tenant-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Spalten aus CREATE TABLE "Tenant" in 20260618112124_auth_multi_tenancy (6): ["createdAt","id","isActive","name","slug","updatedAt"]; Spalten der Wegwerf-Tabelle (6): ["createdAt","id","isActive","name","slug","updatedAt"]
tenant-generierter-client-zeilen-gebunden-und-ungebunden-identisch: bestanden — generierter Client gebunden (TENANT-A): ["TENANT-A","TENANT-B","TENANT-C"]; ungebunden: ["TENANT-A","TENANT-B","TENANT-C"]; Roh-SQL-Vergleichswert (Pruefung 2): ["TENANT-A","TENANT-B","TENANT-C"]
tenant-generierter-client-benutzerzaehler-ungebunden-null-fuer-jeden-mandanten: bestanden — das ist die Zahl, die admin/tenants/page.tsx als Benutzeranzahl anzeigen wuerde — ungebunden: [{"id":"TENANT-A","userCount":0},{"id":"TENANT-B","userCount":0},{"id":"TENANT-C","userCount":0}]; Wartungszahl je Mandant: {"TENANT-B":2,"TENANT-A":2}
tenant-generierter-client-benutzerzaehler-gebunden-nur-eigener-mandant: bestanden — gebunden unter TENANT-A: A=2 (Wartungszahl=2), B=0 (Wartungszahl=2), C=0
tenant-loeschriegel-ungebunden-vakuum-fremdschluessel-faengt-laut: bestanden — ungebundener Relationszaehler ueber aktive Benutzer fuer TENANT-A=0, Wartungszahl=2 — der Riegel T-02-09 liesse das Loeschen durch; ungebundenes prisma.tenant.delete ueber den generierten Client wirft PrismaClientKnownRequestError (code P2003): Invalid `prisma.tenant.delete()` invocation: Foreign key constraint violated on the constraint: `User_tenantId_fkey` — die Zeile existiert ueber die Wartungsrolle danach noch: true; die referentielle Pruefung des Fremdschluessels umgeht den Zeilenschutz und faengt das Loeschen trotzdem ab
tenant-loeschen-ohne-benutzer-gelingt-wie-heute: bestanden — ungebundenes prisma.tenant.delete fuer TENANT-C (ohne Benutzer) ist NICHT fehlgeschlagen; ueber die Wartungsrolle danach noch vorhanden: false
tenant-fan-out-je-mandant-gebundene-zaehlung-stimmt: bestanden — Fan-out je verbliebenem Mandanten (2): [{"id":"TENANT-A","gebunden":2,"wartung":2},{"id":"TENANT-B","gebunden":2,"wartung":2}]
Alle 110 Pruefungen bestanden.
```
Die Belegzeile, die diesen Abschnitt traegt, ist
`tenant-generierter-client-benutzerzaehler-ungebunden-null-fuer-jeden-mandanten`
(Pruefung 5): dieselbe Abfrage, die `findAll` heute stellt, liefert
UNGEBUNDEN fuer JEDEN Mandanten `userCount: 0`, waehrend die Wartungsrolle
fuer TENANT-A und TENANT-B je 2 aktive Benutzer zaehlt — das ist exakt die
Zahl, die `admin/tenants/page.tsx` nach dem Scharfschalten anzeigen wuerde.
Der Fremdschluessel `User_tenantId_fkey` (Pruefung 7, wortgleich aus Zeile
105 von `20260618112124_auth_multi_tenancy` geschnitten) faengt das daraus
folgende Loeschen zwar ab — aber laut (SQLSTATE 23503, Prisma-Code `P2003`),
nicht mit der verstaendlichen 400-Meldung des Riegels T-02-09.
### (n2) Signaltabelle je Pfad
| Pfad | Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) | Konkretes Signal | Frontend laesst es durch? |
|---|---|---|---|
| `TenantController.findAll` | Der Relationszaehler (`include: { _count: { select: { users } } }`) laeuft ungebunden auf dem generierten Client (Pruefung 5): `userCount` ist 0 fuer JEDEN Mandanten, die Mandantenzeilen selbst bleiben vollstaendig (`Tenant` ohne Regel) | Die Mandantenliste zeigt jeden Mandanten mit 0 Benutzern — eine falsche Zahl, keine leere Liste | Ja — `admin/tenants/page.tsx` zeigt `tenant.userCount` ungeprueft an |
| `TenantController.findOne` | Dieselbe Form wie `findAll`, fuer eine einzelne Kennung | `userCount: 0` fuer den betrachteten Mandanten | Ja — dieselbe Anzeige (falls einzeln abgefragt) |
| `TenantController.create` | Kein Datenbankzugriff des Controllers selbst — delegiert an `TenantService.create` | Keins (Controller-Ebene) | — |
| `TenantController.update` | Kein Datenbankzugriff des Controllers selbst — delegiert an `TenantService.update`, nach ungebundenem `findById` (Tenant ohne Regel, unveraendert) | Keins | — |
| `TenantController.remove` | Der Relationszaehler ueber AKTIVE Benutzer laeuft ungebunden: `_count.users` ist 0 (Pruefung 7), der Riegel T-02-09 passiert, `tenant.delete` laeuft — und trifft den Fremdschluessel `User_tenantId_fkey` (`ON DELETE RESTRICT`): SQLSTATE 23503, Prisma-Code `P2003`, HTTP 500 mit generischer Meldung statt der verstaendlichen 400 | Ein Loeschversuch schlaegt laut fehl statt mit "Cannot delete tenant with active users" | Teilweise — `handleDelete` in `admin/tenants/page.tsx` prueft `res.ok`, tut bei nicht-OK aber NICHTS sichtbares (siehe (n3)) |
| `TenantService.findAll` | Ungebunden, `Tenant` ohne Regel — unveraendert korrekt. Hat heute KEINEN Aufrufer (Befund L) | Keins (totes Codeglied) | — |
| `TenantService.findById` | Ungebunden, `Tenant` ohne Regel — unveraendert korrekt, wird von `TenantController.update` als Existenzpruefung benutzt | Keins | — |
| `TenantService.create` | Ungebunden, `Tenant` ohne Regel; ruft danach `groupsService.ensureDefaultGroup(tenant.id)` auf — dieser Aufruf laeuft seit 260909-jts vollstaendig gebunden ueber `forTenant()`/`withTenantTransaction()`, gebunden an den soeben angelegten Mandanten | Keins — die Standardgruppen-Anlage funktioniert nach dem Scharfschalten unveraendert | — |
| `TenantService.update` | Ungebunden, `Tenant` ohne Regel — unveraendert korrekt | Keins | — |
| `TenantGuard` | Kein Datenbankzugriff (Aufgabe 2 entfernt den letzten, ungenutzten Prisma-Aufruf) | Keins | — |
### (n3) Welcher Code eine falsche Zahl als Wahrheit deutet
Anders als bei jedem Bereich davor ist die gefaehrliche Form hier nicht
LEERE, sondern eine FALSCHE ZAHL, die sich als Wahrheit ausgibt.
**Backend:** `TenantController.findAll`/`findOne` liefern `userCount: 0` ohne
jedes Signal — kein Fehler, kein leeres Feld, eine plausibel aussehende Zahl,
die schlicht falsch ist. `TenantController.remove` laesst den Riegel T-02-09
passieren (Zaehler 0, obwohl aktive Benutzer existieren) und der
Fremdschluessel antwortet laut mit der falschen Botschaft (500 statt 400,
siehe (n1)/(n2)).
**Frontend**, namentlich mit Stelle:
- `apps/web/src/app/(portal)/admin/tenants/page.tsx` zeigt `tenant.userCount`
ungeprueft in der Tabellenzeile an (Zeile 209: `{tenant.userCount}`).
`fetchTenants` (Zeilen 44-55) prueft zwar `res.ok`, aber bei nicht-OK
passiert NICHTS sichtbares — kein Fehlertext, keine Markierung, die Liste
bleibt leer oder veraltet stehen (`catch { // silently fail }`).
`handleDelete` (Zeilen 122-133) prueft ebenfalls `res.ok`, aber bei
nicht-OK (der 500er aus dem Fremdschluessel) passiert wieder NICHTS: der
Bestaetigungsdialog (`deleteConfirm`) bleibt offen, `fetchTenants()` wird
nicht erneut aufgerufen — fuer den Administrator sieht das aus wie ein
Knopf, der nicht reagiert, nicht wie ein Fehler.
- `apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx`
faengt jede nicht-OK-Antwort in eine LEERE Liste
(`.then((res) => (res.ok ? res.json() : []))`) und jeden Netzwerkfehler in
ein stilles Nichts (`.catch(() => {})`) — der SUPER_ADMIN sieht im
Mandanten-Wechsel-Dropdown des Marktplatzes schlicht keine Mandanten, ohne
Hinweis, dass eine Abfrage fehlgeschlagen ist statt "es gibt keine".
Zur Ausfuehrungszeit an den genannten Dateien und Zeilen erneut zu pruefen —
Zeilennummern koennen sich verschieben.
### (n4) Was dieser Durchlauf bewusst nicht löst
**(a) Die Entscheidung zur Anfrageobjekt-Eigenschaft.** Gemessen (Befund B/C
der Planung, in Aufgabe 1 wiederholt): `apps/api/src/tenant/tenant.guard.ts`
und `apps/api/src/tenant/tenant.middleware.ts` setzen
`req.tenantPrisma = forTenant(this.prisma, tenantId)`, aber eine Volltextsuche
ueber `apps/api/src` (`grep -rn '\.tenantPrisma'`) findet ausserhalb dieser
beiden Dateien KEINEN Lesezugriff — nur drei Kommentare, die die Middleware
nennen. Die Middleware selbst ist NIRGENDS verdrahtet: weder `apps/api/src`
noch `apps/api/src/main.ts` enthalten ein `MiddlewareConsumer`, ein
`configure(` oder einen `.apply(...).forRoutes(...)`-Aufruf auf
`TenantMiddleware` — `app.module.ts` implementiert kein `NestModule`.
ENTSCHIEDEN (260911-e2s, Aufgabe 2): der Guard setzt nur noch
`req.tenantId`; `tenant.middleware.ts` ist GELOESCHT (eine nie aufgerufene
Kopie des Guards mit identischer Logik). GRUND: neun umgestellte Bereiche vor
diesem binden ausnahmslos dienst-intern, ein Klient je Methode
(`forTenant(this.prisma, tenantId)` in der jeweiligen Service-Methode) — die
Konvention ist durch neunfache Praxis entschieden, nicht durch diesen Plan
neu erfunden. Eine tote Verdrahtung, die wie ein Sicherheitsmechanismus
AUSSIEHT (ein gebundener Klient, scheinbar bereit zur Benutzung), ist
schlimmer als gar keine — sie suggeriert einem spaeteren Leser einen Schutz,
den es nicht gibt.
**(b) Die Erkennungsluecke der Bestandsaufnahme (Befund G).**
`rls-access-inventory.spec.ts` sammelt (Datei, Modell)-Paare ausschliesslich
ueber `this.prisma.<Modell>` und `<gebundener Client>.<Modell>` — eine
Relationseinbindung (`include:`, Relationszaehler `_count`) in eine ZWEITE
Tabelle erzeugt kein Paar und ist fuer das Werkzeug unsichtbar. In Aufgabe 1
erneut vermessen:
*Alle `_count`-Stellen ausserhalb von `tenant/`* (`grep -rn "_count"
apps/api/src --include=*.ts | grep -v spec`): `groups.service.ts:66` (auf
bereits GEBUNDENEM Klienten — harmlos, die Bindung schuetzt bereits) und
`tenders.controller.ts:405` (`groupBy` auf der plattformweiten, ungeschuetzten
`Tender`, kein Relationszugriff in eine zweite Tabelle — harmlos).
*Alle `include:`-Stellen* (`grep -rn "include:" apps/api/src --include=*.ts
| grep -v spec`, 19 Treffer in 8 Dateien): jede Stelle einzeln beurteilt —
aeusserer Aufruf gebunden oder nicht, aeussere Tabelle geschuetzt oder nicht,
eingebundene Tabelle geschuetzt oder nicht:
| Datei | Aeusserer Aufruf | Aeussere Tabelle geschuetzt | Eingebundene Tabelle geschuetzt | Urteil |
|---|---|---|---|---|
| `tenant.controller.ts` (3 Stellen: `findAll`/`findOne`/`remove`, vor Aufgabe 3) | ungebunden | Nein (`Tenant`) | JA (`User`) | GEFAEHRLICH — die einzige Auspraegung, in Aufgabe 3 behoben |
| `ldap-config.service.ts:309` (`getAllActiveConfigs`) | ungebunden (bewusst uebergreifend) | JA (`LdapConfig`) | eingebunden: `tenant`, `fieldMappings` | harmlos — die AEUSSERE Tabelle ist bereits geschuetzt, der bekannte Etappe-3-Fall (Benutzerdimension) bekommt dadurch nichts Neues |
| `tenders.controller.ts:612` (`sources`) | ungebunden | Nein (`Tender`, D-03 plattformweit) | Nein (`TenderSource`, ebenfalls plattformweit) | harmlos — beide Seiten plattformweit |
| übrige 14 Stellen (`groups.service.ts`, `module-grants.service.ts`, `dashboard.service.ts`, `dkv.service.ts`, `tender-*.service.ts`, `user.service.ts`) | ueberwiegend gebunden oder auf bereits geschuetzten/plattformweiten Tabellen | — | — | harmlos, einzeln nachgesehen |
Die gefaehrliche Auspraegung (ungebundener aeusserer Aufruf auf einer
UNGESCHUETZTEN Tabelle, Einbindung in eine GESCHUETZTE Tabelle) existierte im
gesamten API-Quelltext genau EINMAL: in diesem Bereich, vor Aufgabe 3.
ENTSCHEIDUNG gegen einen Ledger-Eintrag: die einzige Auspraegung wird in
diesem Plan behoben; die Wiederholung beider Messungen zur Ausfuehrungszeit
fand keine zweite — faende eine spaetere Wiederholung eine zweite
Auspraegung, waere DANN ein `gsd-tools windows append`-Eintrag anzulegen, mit
Verweis auf diesen Absatz.
**(c) Der Fremdschluessel als Rueckhalt, mit einer Luecke.**
`User_tenantId_fkey` faengt auch INAKTIVE Benutzer, waehrend der Riegel
T-02-09 nur AKTIVE zaehlt — ein Mandant mit ausschliesslich inaktiven
Benutzern ist heute wie nach diesem Plan nicht loeschbar (500 statt der
verstaendlichen 400). Bestehendes Verhalten, gemessen (Pruefung 7/8 zeigen
die Mechanik, nicht diesen Spezialfall direkt), nicht Gegenstand dieses
Auftrags.
**(d) `TenantService.findAll` ohne Aufrufer** (Befund L) — bleibt totes
Codeglied, nicht entfernt (Scope).
**(e) Der unerreichbare `null`-Zweig des Guards** — SUPER_ADMIN ohne
`tenantId` und ohne `x-tenant-id`-Header ist mit dem heutigen
Sitzungsnachweis unerreichbar (`User.tenantId` ist `String`, nicht nullbar),
bleibt aber unveraendert und wird in Aufgabe 2 als heutiges Verhalten
getestet, nicht umgebaut.
**(f) Der Header-Wert wird nicht gegen vorhandene Mandanten geprueft.** Ein
SUPER_ADMIN kann per `x-tenant-id` eine erfundene Kennung schicken (D-10 wie
entworfen) — sie bindet an einen leeren Kontext, null Zeilen, kein Leck.
**(g) Die Mehrkosten des Fan-outs.** Nach Aufgabe 3 kostet `findAll` eine
gebundene Zaehlabfrage je Mandant statt eines Joins — bei einstelliger
Mandantenzahl belanglos, dieselbe Form wie
`UserService.findAllForPlatformAdmin`.
**(h) Die Etappe-4-Vorabpruefung.** Die Benutzerzahl je Mandant ueber die
Wartungsrolle gegen die gebundene Fan-out-Zaehlung ist dieselbe Pruefung wie
im Bereich `user` — kein eigener Eintrag noetig.
### (n5) Was dieser Durchlauf bewusst nicht anfasst
- Der direkte Prisma-Zugriff im Controller (Muster wie `user.controller.ts`)
— bleibt, Wartbarkeitsvermerk, keine Verschiebung in den Dienst.
- Die redundante `@UseGuards(RolesGuard)`-Klassenregistrierung neben der
globalen `APP_GUARD`-Registrierung von `RolesGuard`.
- Das Frontend — in (n3) beschrieben, nicht geaendert.
- `tenant.service.ts` — unveraendert.
- Die veraltete Tabellenliste im Abschnitt `## Mandantentrennung` von
`docs/anleitung-entwicklung.md` ("aktuell nur auf User,
PasswordResetToken, ..." — seit `20260909140000` sind es 23 Tabellen);
Aufgabe 3 aendert in jener Datei NUR die Absaetze zu Guard und Middleware,
diese Liste bleibt stehen und ist hier als bekannte Ungenauigkeit
festgehalten.
- Die historische Nennung der Middleware in
`docs/mandantentrennung-datenbankrolle.md:124` — beschreibt den Stand VOR
Etappe 1 korrekt, nicht zu aendern.
- Schema und Migrationen — geprueft und bewusst gelassen, `Tenant` bekommt
KEINE Regel.
## Verweis
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang