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:
@@ -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
|
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
|
||||||
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
|
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
|
||||||
@@ -3333,6 +3682,7 @@ async function main() {
|
|||||||
await runModuleRegistryAreaChecks(adminUrl, scratchRoleUrlString, results);
|
await runModuleRegistryAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
await runDashboardAreaChecks(adminUrl, scratchRoleUrlString, results);
|
await runDashboardAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results);
|
await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
|
await runTenantAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
|
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
|
||||||
await runConcurrencyProbe(scratchRoleUrlString, results);
|
await runConcurrencyProbe(scratchRoleUrlString, results);
|
||||||
} finally {
|
} 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 und Migrationen — geprüft und bewusst gelassen, keine
|
||||||
Schemaänderung in dieser Etappe.
|
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
|
## Verweis
|
||||||
|
|
||||||
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
||||||
|
|||||||
Reference in New Issue
Block a user