feat(laa-01): messe die drei Sonderfaelle des Bereichs tenders und schreibe die Fehlerrichtung

rls-scratch-check.mjs bekommt einen fuenften Abschnitt (runTendersAreaChecks)
mit den fuenf Policies von TenderEmailConfig/TenderNotificationPref/
TenderRssFeedSource/TenderSavedSearch/TenderTriage, wortgleich aus der
ausgelieferten Migration extrahiert. Neun neue Pruefungen belegen: die
Policies haben keine Benutzerdimension (Befund E), eine plattformweite
RSS-Zeile ist unter jedem Mandantenkontext unsichtbar und ein gebundenes
Einfuegen ohne Mandant wird abgewiesen (WINDOWS #19), und ein gebundenes
upsert auf eine unsichtbare Zeile verletzt die Eindeutigkeitsbedingung,
nicht die Policy (Befund F). Alle 32 Pruefungen (23 bisherige + 9 neue)
bestehen. Nachmessung bestaetigt Befund A: genau eine $transaction in
diesem Bereich, Array-Form auf der plattformweiten Tabelle Tender,
ausserhalb jeder Mandantenbindung — withTenantTransaction() wird hier
nicht gebraucht.

docs/mandantentrennung-etappe2-fehlerrichtung.md bekommt einen
`## Bereich tenders`-Abschnitt mit der beobachteten Messausgabe, einer
Signaltabelle je umzustellendem Pfad und einem eigenen Unterabschnitt zur
lautlosen Fehlerform der beiden Hintergrunddienste (fuenf benannte
Stellen, keine protokolliert etwas).

743 Tests gruen, Typpruefung sauber, kein Schema-/Migrations-/Compose-/
Umgebungsdatei-Diff.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-09 15:43:35 +02:00
parent b86675b549
commit 349814747e
2 changed files with 527 additions and 0 deletions
+310
View File
@@ -810,6 +810,315 @@ async function runGroupsAreaChecks(adminUrl, scratchRoleUrl, results) {
}
}
/**
* Aufgabe 1 (260909-laa) — misst die drei Sonderfaelle des Bereichs tenders
* unter der Rolle ohne BYPASSRLS, mit den fuenf Policies WORTGLEICH aus der
* ausgelieferten Migration `_rls_remaining_tenant_tables` (dieselbe Datei,
* die bereits die TenantModuleActivation-Policy fuer runGroupsAreaChecks
* liefert). Findet die Extraktion eine der fuenf nicht, meldet dieser
* Abschnitt eine FEHLGESCHLAGENE Pruefung und bricht ab, statt mit einer
* geratenen Policy weiterzumessen.
*
* Legt keine Tabelle an, auf der eine andere Pruefung dieses Werkzeugs
* aufsetzt — anders als runGroupsAreaChecks() (Vorbedingung fuer
* runTransactionShapeMeasurement()) ist dieser Abschnitt in der Aufrufkette
* ein Blatt: er muss NACH runGroupsAreaChecks() und VOR
* runTransactionShapeMeasurement() laufen, weil Letztere weiterhin auf der
* von runGroupsAreaChecks() angelegten Tabelle "Group" aufsetzt — dieser
* Abschnitt aendert daran nichts.
*/
async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) {
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
const emailConfigPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'TenderEmailConfig')
: null;
const notificationPrefPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'TenderNotificationPref')
: null;
const rssFeedPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'TenderRssFeedSource')
: null;
const savedSearchPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'TenderSavedSearch')
: null;
const triagePolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'TenderTriage')
: null;
if (
!emailConfigPolicy ||
!notificationPrefPolicy ||
!rssFeedPolicy ||
!savedSearchPolicy ||
!triagePolicy
) {
report(
results,
'tenders-policies-aus-migration-gefunden',
false,
'CREATE POLICY fuer "TenderEmailConfig", "TenderNotificationPref", "TenderRssFeedSource", "TenderSavedSearch" und/oder "TenderTriage" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden',
);
return;
}
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
await db.$executeRawUnsafe(`
CREATE TABLE "TenderEmailConfig" (
id text PRIMARY KEY,
"userId" text NOT NULL UNIQUE,
"tenantId" text NOT NULL
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE "TenderNotificationPref" (
id text PRIMARY KEY,
"userId" text NOT NULL UNIQUE,
"tenantId" text NOT NULL
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE "TenderRssFeedSource" (
id text PRIMARY KEY,
url text NOT NULL,
"userId" text,
"tenantId" text,
UNIQUE ("userId", url)
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE "TenderSavedSearch" (
id text PRIMARY KEY,
"userId" text NOT NULL,
"tenantId" text NOT NULL,
name text NOT NULL,
UNIQUE ("userId", name)
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE "TenderTriage" (
id text PRIMARY KEY,
"userId" text NOT NULL,
"tenantId" text NOT NULL,
"tenderId" text NOT NULL,
UNIQUE ("userId", "tenderId")
);
`);
for (const table of [
'TenderEmailConfig',
'TenderNotificationPref',
'TenderRssFeedSource',
'TenderSavedSearch',
'TenderTriage',
]) {
await db.$executeRawUnsafe(`ALTER TABLE "${table}" ENABLE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(`ALTER TABLE "${table}" FORCE ROW LEVEL SECURITY;`);
}
await db.$executeRawUnsafe(emailConfigPolicy);
await db.$executeRawUnsafe(notificationPrefPolicy);
await db.$executeRawUnsafe(rssFeedPolicy);
await db.$executeRawUnsafe(savedSearchPolicy);
await db.$executeRawUnsafe(triagePolicy);
for (const table of [
'TenderEmailConfig',
'TenderNotificationPref',
'TenderRssFeedSource',
'TenderSavedSearch',
'TenderTriage',
]) {
await db.$executeRawUnsafe(
`GRANT SELECT, INSERT, UPDATE, DELETE ON "${table}" TO ${SCRATCH_ROLE_NAME}`,
);
}
await db.$executeRawUnsafe(`
INSERT INTO "TenderEmailConfig" (id, "userId", "tenantId") VALUES
('ec-a', 'user-a1', 'TENANT-A'),
('ec-b', 'user-b', 'TENANT-B');
`);
await db.$executeRawUnsafe(`
INSERT INTO "TenderNotificationPref" (id, "userId", "tenantId") VALUES
('np-a', 'user-a1', 'TENANT-A'),
('np-b', 'user-b', 'TENANT-B');
`);
await db.$executeRawUnsafe(`
INSERT INTO "TenderRssFeedSource" (id, url, "userId", "tenantId") VALUES
('rss-a', 'https://a.example-tenders.invalid/feed', 'user-a1', 'TENANT-A'),
('rss-b', 'https://b.example-tenders.invalid/feed', 'user-b', 'TENANT-B'),
('rss-platform', 'https://platform.example-tenders.invalid/feed', NULL, NULL);
`);
await db.$executeRawUnsafe(`
INSERT INTO "TenderSavedSearch" (id, "userId", "tenantId", name) VALUES
('ss-a1', 'user-a1', 'TENANT-A', 'Profil A1'),
('ss-a2', 'user-a2', 'TENANT-A', 'Profil A2'),
('ss-b', 'user-b', 'TENANT-B', 'Profil B');
`);
await db.$executeRawUnsafe(`
INSERT INTO "TenderTriage" (id, "userId", "tenantId", "tenderId") VALUES
('tr-a', 'user-a1', 'TENANT-A', 'tender-x'),
('tr-b', 'user-b', 'TENANT-B', 'tender-y'),
('tr-shared', 'user-shared', 'TENANT-B', 'tender-shared');
`);
});
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
// tendersavedsearch-gebunden-nur-eigener-mandant
const savedSearchRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT "tenantId", "userId" FROM "TenderSavedSearch" ORDER BY id`,
);
report(
results,
'tendersavedsearch-gebunden-nur-eigener-mandant',
savedSearchRowsForA.length === 2 &&
savedSearchRowsForA.every((r) => r.tenantId === 'TENANT-A'),
`forTenant(TENANT-A) liefert ${savedSearchRowsForA.length} Zeile(n): ${JSON.stringify(savedSearchRowsForA.map((r) => r.tenantId))}`,
);
// tendersavedsearch-ungebunden-null-zeilen — die Belegzeile, die diesen
// Abschnitt der Kritikschrift traegt, am echten, ausgelieferten
// Policy-Text gemessen.
const unboundSavedSearchRows = await prisma.$queryRaw`SELECT "tenantId" FROM "TenderSavedSearch"`;
report(
results,
'tendersavedsearch-ungebunden-null-zeilen',
unboundSavedSearchRows.length === 0,
`ungebundener SELECT auf "TenderSavedSearch" liefert ${unboundSavedSearchRows.length} Zeile(n)`,
);
// tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar (Befund
// E): das GELINGEN — beide Nutzer von TENANT-A sind sichtbar — ist das
// bestandene Ergebnis. Es belegt, dass die Policy keine
// Benutzerdimension hat; die anwendungsseitige userId-Filterung bleibt
// deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern
// desselben Mandanten und darf bei der Umstellung nicht entfallen.
const usersVisibleForA = new Set(savedSearchRowsForA.map((r) => r.userId));
const bothUsersVisible = usersVisibleForA.has('user-a1') && usersVisibleForA.has('user-a2');
report(
results,
'tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar',
bothUsersVisible,
bothUsersVisible
? `forTenant(TENANT-A) liefert AUCH die Zeile des zweiten Nutzers (user-a2) — die Policy auf TenderSavedSearch prueft nur die Mandantenkennung, nicht die Benutzerkennung; die anwendungsseitige userId-Filterung bleibt der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben Mandanten und darf nicht entfallen`
: `sichtbare Nutzer unter forTenant(TENANT-A): ${JSON.stringify([...usersVisibleForA])}`,
);
// tenderemailconfig-gebunden-nur-eigener-mandant
const emailConfigRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT "tenantId" FROM "TenderEmailConfig" ORDER BY id`,
);
report(
results,
'tenderemailconfig-gebunden-nur-eigener-mandant',
emailConfigRowsForA.length === 1 && emailConfigRowsForA[0].tenantId === 'TENANT-A',
`forTenant(TENANT-A) liefert ${emailConfigRowsForA.length} Zeile(n): ${JSON.stringify(emailConfigRowsForA.map((r) => r.tenantId))}`,
);
// tendertriage-gebunden-nur-eigener-mandant
const triageRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT "tenantId" FROM "TenderTriage" ORDER BY id`,
);
report(
results,
'tendertriage-gebunden-nur-eigener-mandant',
triageRowsForA.length === 1 && triageRowsForA[0].tenantId === 'TENANT-A',
`forTenant(TENANT-A) liefert ${triageRowsForA.length} Zeile(n): ${JSON.stringify(triageRowsForA.map((r) => r.tenantId))}`,
);
// tendernotificationpref-gebunden-nur-eigener-mandant
const notificationPrefRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT "tenantId" FROM "TenderNotificationPref" ORDER BY id`,
);
report(
results,
'tendernotificationpref-gebunden-nur-eigener-mandant',
notificationPrefRowsForA.length === 1 && notificationPrefRowsForA[0].tenantId === 'TENANT-A',
`forTenant(TENANT-A) liefert ${notificationPrefRowsForA.length} Zeile(n): ${JSON.stringify(notificationPrefRowsForA.map((r) => r.tenantId))}`,
);
// tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar
// (WINDOWS #19): die plattformweite Zeile (userId/tenantId NULL) ist
// unter BEIDEN Mandantenkontexten unsichtbar, weil die ausgelieferte
// Policy "tenantId" = current_tenant_id() NULL nie gleich vergleicht.
// Bestanden, wenn sie unter beiden Kontexten fehlt — die Folge: die
// drei RSS-Pfade, die plattformweite Zeilen beruehren, duerfen nicht
// gebunden werden, solange die Policy-Semantik unveraendert ist.
const rssRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT id FROM "TenderRssFeedSource" ORDER BY id`,
);
const rssRowsForB = await forTenantQuery(prisma, 'TENANT-B', (tx) =>
tx.$queryRaw`SELECT id FROM "TenderRssFeedSource" ORDER BY id`,
);
const platformRowVisibleForA = rssRowsForA.some((r) => r.id === 'rss-platform');
const platformRowVisibleForB = rssRowsForB.some((r) => r.id === 'rss-platform');
report(
results,
'tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar',
!platformRowVisibleForA && !platformRowVisibleForB,
`forTenant(TENANT-A) sieht die Platform-Zeile: ${platformRowVisibleForA}, forTenant(TENANT-B) sieht sie: ${platformRowVisibleForB} — WINDOWS #19: eine plattformweite RSS-Quelle ist unter JEDEM Mandantenkontext unsichtbar; listForUser/createPlatform/remove duerfen deshalb nicht gebunden werden, solange die Policy-Semantik unveraendert ist`,
);
// tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt: ein
// gebundenes INSERT unter TENANT-A mit tenantId=NULL wird abgewiesen —
// die Abweisung IST das bestandene Ergebnis. Folge: createPlatform darf
// nicht gebunden werden.
let platformInsertRejected = false;
let platformInsertDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "TenderRssFeedSource" (id, url, "userId", "tenantId") VALUES ('rss-rejected', 'https://rejected.example-tenders.invalid/feed', NULL, NULL)`,
);
platformInsertDetail = 'gebundenes INSERT mit tenantId=NULL ist NICHT fehlgeschlagen';
} catch (err) {
platformInsertRejected = true;
platformInsertDetail = `gebundenes INSERT mit tenantId=NULL abgewiesen: ${err.message} — createPlatform darf deshalb nicht gebunden werden`;
}
report(
results,
'tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt',
platformInsertRejected,
platformInsertDetail,
);
// tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit
// (Befund F): unter TENANT-A ein INSERT fuer ein Paar (userId,
// tenderId), dessen Zeile existiert, aber zu TENANT-B gehoert und daher
// unsichtbar ist (tr-shared). Bestanden, wenn der Fehler eine
// Verletzung der Eindeutigkeitsbedingung ist (P2002-Familie), nicht
// eine RLS-Policy-Abweisung — Postgres prueft Unique-Indizes gegen die
// physischen Zeilen, unabhaengig von der RLS-Sichtbarkeit. Folge fuer
// Aufgabe 2: aus einem stillen Ueberschreiben wird ein harter Fehler,
// der als verstaendliche Meldung herauskommen muss.
let uniqueViolationOnInvisibleRow = false;
let uniqueViolationDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "TenderTriage" (id, "userId", "tenantId", "tenderId") VALUES ('tr-a-attempt', 'user-shared', 'TENANT-A', 'tender-shared')`,
);
uniqueViolationDetail = 'gebundenes INSERT auf die unsichtbare (userId,tenderId)-Kombination ist NICHT fehlgeschlagen';
} catch (err) {
uniqueViolationOnInvisibleRow = err.code === '23505' || /unique/i.test(err.message);
uniqueViolationDetail = `gebundenes INSERT auf die unsichtbare (userId,tenderId)-Kombination scheitert an der Eindeutigkeitsbedingung, nicht an der Policy: ${err.message} — Befund F: aus einem stillen Ueberschreiben wird bei Aufgabe 2 ein harter, verstaendlich uebersetzter Fehler`;
}
report(
results,
'tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit',
uniqueViolationOnInvisibleRow,
uniqueViolationDetail,
);
} finally {
await prisma.$disconnect();
}
}
/**
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
@@ -1040,6 +1349,7 @@ async function main() {
await runAuthLookupChecks(adminUrl, scratchRoleUrlString, results);
await runLdapAreaChecks(adminUrl, scratchRoleUrlString, results);
await runGroupsAreaChecks(adminUrl, scratchRoleUrlString, results);
await runTendersAreaChecks(adminUrl, scratchRoleUrlString, results);
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
await runConcurrencyProbe(scratchRoleUrlString, results);
} finally {