test(quick-260910-exd): Fehlerrichtung fuer module-registry messen, kein Produktivcode

- rls-scratch-check.mjs: achter Abschnitt runModuleRegistryAreaChecks mit 13
  benannten Pruefungen gegen die echten, aus den ausgelieferten Migrationen
  geschnittenen Regeln (Group/GroupMembership/ModuleGrant/TenantModuleActivation),
  neu angelegt nur: Modulkatalog-Tabelle ohne Zeilenschutz, Eindeutigkeitsindex
  auf TenantModuleActivation, zwei Direkt-Freigaben, eine fremde Mitgliedschaft
- Alle 66 Pruefungen bestanden (53 bisherige + 13 neue), 810 Tests gruen,
  Typpruefung sauber
- docs/mandantentrennung-etappe2-fehlerrichtung.md: neuer Abschnitt
  "Bereich module-registry" mit den fuenf Unterabschnitten (m1-m5), inklusive
  Praezisierung aus Befund E (Katalogbindung ist HEUTE wirkungslos, nicht
  katastrophal — die Bedingung wird als Bedingung notiert) und der
  unbeschoenigten Antwort auf die Signalfrage ("keines")

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-10 11:17:07 +02:00
parent a2516a9852
commit 7d45e2fffd
2 changed files with 565 additions and 0 deletions
+356
View File
@@ -1710,6 +1710,361 @@ async function runUserAreaChecks(adminUrl, scratchRoleUrl, results) {
}
}
/**
* Liest die ausgelieferte Migration, die die Tabellen "Module" und
* "TenantModuleActivation" samt dem Eindeutigkeitsindex auf
* ("tenantId","moduleId") anlegt (Dateiname endet auf
* "_add_module_registry").
*/
function readModuleRegistryMigrationSql() {
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.endsWith('_add_module_registry'))
.map((entry) => entry.name);
if (dirs.length !== 1) return null;
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
}
/**
* Liest die ausgelieferte Migration, die (unter anderem) die beiden
* partiellen Eindeutigkeitsindizes auf "ModuleGrant" anlegt (Dateiname endet
* auf "_add_groups_and_module_grants") — dieselbe Datei, aus der
* runGroupsAreaChecks() NICHT liest (jene braucht nur die RLS-Policy-
* Migration), deshalb ein eigenes, unabhaengiges Lesehilfsmittel.
*/
function readAddGroupsAndModuleGrantsMigrationSql() {
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.endsWith('_add_groups_and_module_grants'))
.map((entry) => entry.name);
if (dirs.length !== 1) return null;
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
}
/**
* Schneidet eine `CREATE UNIQUE INDEX "<indexName>" ...;`-Anweisung
* wortgleich aus einem Migrationstext, nach demselben Muster wie
* extractPolicySql() oben.
*/
function extractIndexSql(migrationSql, indexName) {
const re = new RegExp(`CREATE UNIQUE INDEX "${indexName}"[\\s\\S]*?;`);
const match = migrationSql.match(re);
return match ? match[0] : null;
}
/**
* Aufgabe 1 (260910-exd) — misst die dreizehn im Plan genannten
* Verhaltensweisen des Bereichs `module-registry` unter der Rolle ohne
* BYPASSRLS. Setzt auf den Tabellen "Group", "GroupMembership",
* "ModuleGrant" und "TenantModuleActivation" auf, die runGroupsAreaChecks()
* bereits angelegt und mit Policies WORTGLEICH aus den ausgelieferten
* Migrationen versehen hat — dieser Abschnitt legt sie NICHT neu an. Neu
* angelegt werden nur: die Tabelle "Module" OHNE Zeilenschutz (die zu
* messende Eigenschaft selbst), der Eindeutigkeitsindex auf
* ("tenantId","moduleId") fuer "TenantModuleActivation" (bis hierhin fehlte
* er, weil runGroupsAreaChecks() ihn nicht braucht), zwei Direkt-Freigaben
* (eine je Mandant) und die Mitgliedschaft (group-b, user-a), die
* runGroupsAreaChecks() unter gebundenem Kontext bewusst nicht anlegen
* konnte.
*
* Die bereits von runGroupsAreaChecks() eingefuegte Zeile
* `grant-foreign-group` (TENANT-A, mod-1, groupId=group-b) wird
* WIEDERVERWENDET, nicht neu erzeugt.
*
* Muss NACH runUserAreaChecks() und VOR runTransactionShapeMeasurement()
* laufen (siehe Aufrufkette in main()) — Letztere setzt weiterhin auf der
* von runGroupsAreaChecks() angelegten Tabelle "Group" auf, dieser
* Abschnitt aendert daran nichts.
*/
async function runModuleRegistryAreaChecks(adminUrl, scratchRoleUrl, results) {
const moduleRegistryMigrationSql = readModuleRegistryMigrationSql();
const activationUniqueIndexSql = moduleRegistryMigrationSql
? extractIndexSql(
moduleRegistryMigrationSql,
'TenantModuleActivation_tenantId_moduleId_key',
)
: null;
const groupsAndGrantsMigrationSql = readAddGroupsAndModuleGrantsMigrationSql();
const grantGroupUniqueIndexSql = groupsAndGrantsMigrationSql
? extractIndexSql(groupsAndGrantsMigrationSql, 'ModuleGrant_tenant_module_group_unique')
: null;
const grantUserUniqueIndexSql = groupsAndGrantsMigrationSql
? extractIndexSql(groupsAndGrantsMigrationSql, 'ModuleGrant_tenant_module_user_unique')
: null;
if (!activationUniqueIndexSql || !grantGroupUniqueIndexSql || !grantUserUniqueIndexSql) {
report(
results,
'module-registry-regeln-aus-migration-gefunden',
false,
'Eindeutigkeitsindex fuer "TenantModuleActivation" und/oder die beiden partiellen Eindeutigkeitsindizes fuer "ModuleGrant" nicht in den ausgelieferten Migrationen gefunden',
);
return;
}
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
// (a) Katalogtabelle OHNE Zeilenschutz — die zu messende Eigenschaft
// selbst (Befund E), nach dem Muster der Tabelle "Tenant" im
// Abschnitt des Bereichs `user`.
await db.$executeRawUnsafe(`
CREATE TABLE "Module" (
id text PRIMARY KEY,
name text NOT NULL
);
`);
await db.$executeRawUnsafe(`GRANT SELECT, INSERT, UPDATE, DELETE ON "Module" TO ${SCRATCH_ROLE_NAME}`);
await db.$executeRawUnsafe(`
INSERT INTO "Module" (id, name) VALUES ('mod-1', 'Modul Eins'), ('mod-2', 'Modul Zwei');
`);
// (b) Eindeutigkeitsindex auf ("tenantId","moduleId") fuer
// "TenantModuleActivation", wortgleich aus der ausgelieferten Migration
// — ohne ihn liesse sich Pruefung 12 nicht messen.
await db.$executeRawUnsafe(activationUniqueIndexSql);
// Zusaetzliche Aktivierungszeile fuer Pruefung 12: (TENANT-B, mod-2),
// unter TENANT-A unsichtbar, aber physisch vorhanden.
await db.$executeRawUnsafe(`
INSERT INTO "TenantModuleActivation" (id, "tenantId", "moduleId", "isActive") VALUES
('activation-b2', 'TENANT-B', 'mod-2', true);
`);
// (c) je eine Direkt-Freigabezeile pro Mandant (userId, kein groupId).
await db.$executeRawUnsafe(`
INSERT INTO "ModuleGrant" (id, "tenantId", "moduleId", "groupId", "userId") VALUES
('grant-direct-a', 'TENANT-A', 'mod-1', NULL, 'user-a'),
('grant-direct-b', 'TENANT-B', 'mod-1', NULL, 'user-b');
`);
// (d) die Mitgliedschaft (group-b, user-a), die runGroupsAreaChecks()
// unter gebundenem Kontext bewusst NICHT anlegen konnte (die Regel wies
// sie ab) — hier ueber die Verwaltungsrolle gesetzt, weil Pruefung 6
// sonst aus dem falschen Grund bestuende.
await db.$executeRawUnsafe(`
INSERT INTO "GroupMembership" (id, "groupId", "userId", source) VALUES
('membership-cross', 'group-b', 'user-a', 'MANUAL');
`);
});
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
// 1: modulegrant-ungebunden-null-zeilen — die Belegzeile dieses
// Abschnitts.
const actualGrantCount = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const [row] = await db.$queryRaw`SELECT count(*)::int AS n FROM "ModuleGrant"`;
return row.n;
},
);
const unboundGrantRows = await prisma.$queryRaw`SELECT "tenantId" FROM "ModuleGrant"`;
report(
results,
'modulegrant-ungebunden-null-zeilen',
unboundGrantRows.length === 0,
`ungebundener SELECT auf "ModuleGrant" liefert ${unboundGrantRows.length} Zeile(n), tatsaechlich vorhanden sind ${actualGrantCount}`,
);
// 2: tenantmoduleactivation-ungebunden-null-zeilen — dasselbe fuer die
// Aktivierungstabelle, die der Kurzschluss fuer ADMIN/SUPER_ADMIN als
// EINZIGE liest.
const actualActivationCount = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const [row] = await db.$queryRaw`SELECT count(*)::int AS n FROM "TenantModuleActivation"`;
return row.n;
},
);
const unboundActivationRows =
await prisma.$queryRaw`SELECT "tenantId" FROM "TenantModuleActivation"`;
report(
results,
'tenantmoduleactivation-ungebunden-null-zeilen',
unboundActivationRows.length === 0,
`ungebundener SELECT auf "TenantModuleActivation" liefert ${unboundActivationRows.length} Zeile(n), tatsaechlich vorhanden sind ${actualActivationCount}`,
);
// 3: admin-kurzschluss-gebunden-liefert-nur-eigene-aktivierungen
const activeActivationsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT "tenantId" FROM "TenantModuleActivation" WHERE "isActive" = true ORDER BY id`,
);
report(
results,
'admin-kurzschluss-gebunden-liefert-nur-eigene-aktivierungen',
activeActivationsForA.length === 1 && activeActivationsForA[0].tenantId === 'TENANT-A',
`forTenant(TENANT-A) liefert ${activeActivationsForA.length} aktive Aktivierung(en): ${JSON.stringify(activeActivationsForA.map((r) => r.tenantId))}`,
);
// 4: direktfreigabe-gebunden-nur-eigene-zeile
const directGrantsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT id FROM "ModuleGrant" WHERE "userId" = 'user-a' ORDER BY id`,
);
report(
results,
'direktfreigabe-gebunden-nur-eigene-zeile',
directGrantsForA.length === 1 && directGrantsForA[0].id === 'grant-direct-a',
`forTenant(TENANT-A) liefert fuer die Direkt-Freigabe-Abfrage (userId=user-a) ${directGrantsForA.length} Zeile(n): ${JSON.stringify(directGrantsForA.map((r) => r.id))}`,
);
// Der Drei-Tabellen-Weg (ModuleGrant ueber Group ueber GroupMembership),
// Grundlage fuer Pruefung 5 und 6 — EINE Abfrage, zwei Aussagen.
const groupPathForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`
SELECT mg.id FROM "ModuleGrant" mg
JOIN "Group" g ON g.id = mg."groupId"
JOIN "GroupMembership" gm ON gm."groupId" = g.id
WHERE mg."tenantId" = current_tenant_id() AND gm."userId" = 'user-a'
ORDER BY mg.id
`,
);
// 5: gruppenpfad-gebunden-folgt-der-gruppenregel
report(
results,
'gruppenpfad-gebunden-folgt-der-gruppenregel',
groupPathForA.length === 1 && groupPathForA[0].id === 'grant-a',
`forTenant(TENANT-A) liefert ueber den Drei-Tabellen-Weg (Freigabe ueber Gruppe ueber Mitgliedschaft) fuer user-a ${groupPathForA.length} Zeile(n): ${JSON.stringify(groupPathForA.map((r) => r.id))}`,
);
// 6: gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus
const excludesForeignGroup = !groupPathForA.some((r) => r.id === 'grant-foreign-group');
report(
results,
'gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus',
excludesForeignGroup,
`forTenant(TENANT-A) liefert 'grant-foreign-group' ueber denselben Drei-Tabellen-Weg NICHT (Ergebnis: ${JSON.stringify(groupPathForA.map((r) => r.id))}), obwohl die Regel auf "ModuleGrant" diese Zeile nachweislich durchlaesst (T-JTS-03) und die Mitgliedschaft (group-b, user-a) vorhanden ist — diese Verteidigung greift erst nach dem Scharfschalten`,
);
// 7: gruppenpfad-ueber-die-wartungsrolle-liefert-die-fremde-gruppe-mit —
// die Gegenmessung ueber die Verwaltungsrolle mit BYPASSRLS.
const groupPathViaAdmin = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => db.$queryRaw`
SELECT mg.id FROM "ModuleGrant" mg
JOIN "Group" g ON g.id = mg."groupId"
JOIN "GroupMembership" gm ON gm."groupId" = g.id
WHERE mg."tenantId" = 'TENANT-A' AND gm."userId" = 'user-a'
ORDER BY mg.id
`,
);
const adminIncludesForeignGroup = groupPathViaAdmin.some((r) => r.id === 'grant-foreign-group');
report(
results,
'gruppenpfad-ueber-die-wartungsrolle-liefert-die-fremde-gruppe-mit',
adminIncludesForeignGroup,
`dieselbe Abfrage ueber die Verwaltungsrolle (BYPASSRLS) liefert ${JSON.stringify(groupPathViaAdmin.map((r) => r.id))} — der Ausschluss aus Pruefung 6 kommt damit nachweislich von der Bindung, nicht vom Aufbau`,
);
// 8: module-tabelle-traegt-keinen-zeilenschutz
const unboundModuleRows = await prisma.$queryRaw`SELECT id FROM "Module" ORDER BY id`;
const [moduleRlsRow] =
await prisma.$queryRaw`SELECT relrowsecurity FROM pg_class WHERE relname = 'Module'`;
const moduleHasNoRls =
unboundModuleRows.length === 2 && moduleRlsRow?.relrowsecurity === false;
report(
results,
'module-tabelle-traegt-keinen-zeilenschutz',
moduleHasNoRls,
`ungebundenes SELECT auf "Module" liefert ${unboundModuleRows.length} Zeile(n): ${JSON.stringify(unboundModuleRows.map((r) => r.id))}; pg_class.relrowsecurity fuer "Module" = ${JSON.stringify(moduleRlsRow?.relrowsecurity)}`,
);
// 9: katalog-bindung-aendert-heute-nichts-an-der-ergebnismenge
const boundModuleRows = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT id FROM "Module" ORDER BY id`,
);
const catalogUnaffectedByBinding =
boundModuleRows.length === unboundModuleRows.length &&
boundModuleRows.every((r, i) => r.id === unboundModuleRows[i].id);
report(
results,
'katalog-bindung-aendert-heute-nichts-an-der-ergebnismenge',
catalogUnaffectedByBinding,
`forTenant(TENANT-A) liefert ${JSON.stringify(boundModuleRows.map((r) => r.id))}, ungebunden liefert ${JSON.stringify(unboundModuleRows.map((r) => r.id))} — identisch, weil "Module" keine Regel traegt (Befund E: die Nichtbindung des Katalogs ist heute keine Rettung vor Unsichtbarkeit, sondern eine Frage der Wahrhaftigkeit der Aufzeichnung; sie wird erst zur Rettung, WENN Etappe 3 dieser Tabelle eine Regel gibt)`,
);
// 10: gebundener-join-auf-den-katalog-liefert-den-modulnamen
const activationWithModuleName = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`
SELECT tma."moduleId", m.name FROM "TenantModuleActivation" tma
JOIN "Module" m ON m.id = tma."moduleId"
WHERE tma."isActive" = true
ORDER BY tma.id
`,
);
report(
results,
'gebundener-join-auf-den-katalog-liefert-den-modulnamen',
activationWithModuleName.length === 1 &&
activationWithModuleName[0].moduleId === 'mod-1' &&
activationWithModuleName[0].name === 'Modul Eins',
`forTenant(TENANT-A) liefert fuer den Verbund aus Aktivierung und Katalog: ${JSON.stringify(activationWithModuleName)}`,
);
// 11: aktivierung-gebundenes-einfuegen-fremder-mandant-abgelehnt
let foreignActivationInsertRejected = false;
let foreignActivationInsertDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "TenantModuleActivation" (id, "tenantId", "moduleId", "isActive") VALUES ('activation-rejected-foreign', 'TENANT-B', 'mod-2', true)`,
);
foreignActivationInsertDetail =
'gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B ist NICHT fehlgeschlagen';
} catch (err) {
const sqlState = sqlStateOf(err);
foreignActivationInsertRejected = sqlState === '42501';
foreignActivationInsertDetail = `gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE ${sqlState ?? 'unbekannt'} (${err.message.trim()})`;
}
report(
results,
'aktivierung-gebundenes-einfuegen-fremder-mandant-abgelehnt',
foreignActivationInsertRejected,
foreignActivationInsertDetail,
);
// 12: aktivierung-eindeutigkeit-traegt-den-mandanten-keine-unsichtbare-kollision
let ownActivationInsertSucceeded = false;
let ownActivationInsertDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "TenantModuleActivation" (id, "tenantId", "moduleId", "isActive") VALUES ('activation-a2', 'TENANT-A', 'mod-2', true)`,
);
ownActivationInsertSucceeded = true;
ownActivationInsertDetail =
'gebundenes INSERT von (TENANT-A, mod-2) ist GELUNGEN, obwohl (TENANT-B, mod-2) bereits existiert und unter TENANT-A unsichtbar ist — der Eindeutigkeitsindex fuehrt mit der Mandantenkennung, genau die Entlastung, die die Bereiche `tenders` und `user` NICHT hatten (dort: unsichtbare Zeile, falsches "frei", harter Eindeutigkeitsfehler)';
} catch (err) {
ownActivationInsertDetail = `gebundenes INSERT von (TENANT-A, mod-2) unerwartet abgewiesen: ${err.message.trim()}`;
}
report(
results,
'aktivierung-eindeutigkeit-traegt-den-mandanten-keine-unsichtbare-kollision',
ownActivationInsertSucceeded,
ownActivationInsertDetail,
);
// 13: freigabe-eindeutigkeitsindizes-fuehren-mit-der-mandantenkennung —
// Textmessung statt Datenbankmessung.
const groupIndexLeadsWithTenant = /ON "ModuleGrant"\("tenantId",\s*"moduleId",\s*"groupId"\)/.test(
grantGroupUniqueIndexSql,
);
const userIndexLeadsWithTenant = /ON "ModuleGrant"\("tenantId",\s*"moduleId",\s*"userId"\)/.test(
grantUserUniqueIndexSql,
);
report(
results,
'freigabe-eindeutigkeitsindizes-fuehren-mit-der-mandantenkennung',
groupIndexLeadsWithTenant && userIndexLeadsWithTenant,
`aus 20260804130130_add_groups_and_module_grants extrahiert: ${JSON.stringify(grantGroupUniqueIndexSql)} und ${JSON.stringify(grantUserUniqueIndexSql)} — beide partiellen Eindeutigkeitsindizes auf "ModuleGrant" fuehren mit der Mandantenkennung, dieselbe Entlastung wie Pruefung 12, hier fuer die Freigabetabelle`,
);
} finally {
await prisma.$disconnect();
}
}
/**
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
@@ -1943,6 +2298,7 @@ async function main() {
await runTendersAreaChecks(adminUrl, scratchRoleUrlString, results);
await runDkvAreaChecks(adminUrl, scratchRoleUrlString, results);
await runUserAreaChecks(adminUrl, scratchRoleUrlString, results);
await runModuleRegistryAreaChecks(adminUrl, scratchRoleUrlString, results);
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
await runConcurrencyProbe(scratchRoleUrlString, results);
} finally {