diff --git a/apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql b/apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql new file mode 100644 index 0000000..057c7d8 --- /dev/null +++ b/apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql @@ -0,0 +1,124 @@ +-- 260910-jab, Aufgabe 1 — schliesst T-JTS-02, T-JTS-03 und WINDOWS #19: drei +-- Regeln, die kuerzer greifen als sie sollen. +-- +-- Loest drei ausgelieferte Regeln ab. Die betroffenen Dateien +-- (20260804130130_add_groups_and_module_grants fuer die Tabellenform, +-- 20260804130918_groups_rls_policies fuer die alte GroupMembership/ +-- ModuleGrant-Regel, 20260909140000_rls_remaining_tenant_tables fuer die +-- alte TenderRssFeedSource-Regel) bleiben UNVERAENDERT stehen — Prisma +-- fuehrt ihre Pruefsumme, eine Aenderung braechte "prisma migrate deploy" +-- zum Abbruch. Praezedenzfall und Kopfform: 20260909140000_rls_remaining_ +-- tenant_tables. +-- +-- (1) GroupMembership (T-JTS-02): die ausgelieferte Regel prueft +-- ausschliesslich, ob die referenzierte Gruppe zum laufenden Mandanten +-- gehoert ("groupId" IN (...)) — nicht, ob der referenzierte Benutzer es +-- tut. Eine Mitgliedschaft konnte dadurch eine Gruppe des einen Mandanten +-- mit einem Benutzer eines anderen verbinden. Die neue Regel prueft beide +-- Seiten mit UND verknuepft, nach demselben Join-Muster, das +-- PasswordResetToken seit 20260618112133 fuer die Benutzerseite vormacht. +-- +-- (2) ModuleGrant (T-JTS-03): die ausgelieferte Regel prueft ausschliesslich +-- die Mandantenkennung der Zeile selbst — nicht, wohin "groupId"/"userId" +-- zeigen. Eine Freigabe mit korrekter eigener Mandantenkennung, aber +-- fremder Gruppen- ODER fremder Benutzerkennung, wurde durchgelassen. Die +-- neue Regel prueft zusaetzlich beide moeglichen Ziele; die Leer-Zulassung +-- ist zwingend, weil das Modell Gruppe und Benutzer als Entweder-oder fuehrt +-- (D-04, CHECK-Constraint "ModuleGrant_group_xor_user"). WICHTIG: +-- `assertTargetBelongsToTenant` in apps/api/src/groups/module-grants.service.ts +-- bleibt UNVERAENDERT bestehen — diese Datenbankregel ist ein ZWEITES Netz, +-- kein Ersatz dafuer. +-- +-- (3) TenderRssFeedSource (WINDOWS #19): "tenantId" ist nullable — NULL +-- markiert eine plattformweite Zeile (D-06). Die ausgelieferte Regel +-- "tenantId" = current_tenant_id() vergleicht NULL nie gleich; eine +-- plattformweite Zeile waere nach dem Scharfschalten fuer JEDEN Mandanten +-- unsichtbar, nicht nur fuer fremde. Ersetzt durch VIER nach Befehl +-- getrennte Regeln: die Leseregel schliesst die plattformweiten Zeilen +-- ausdruecklich ein, die drei Schreibregeln (Einfuegen/Aendern/Entfernen) +-- verlangen weiterhin ausnahmslos einen Mandanten. Vier ausdrueckliche +-- Regeln statt einer mit stillschweigender Wirkung, weil ein einzelner +-- USING-Ausdruck auch bestimmt, welche Zeilen UPDATE und DELETE ueberhaupt +-- erreichen — eine Regel, die die plattformweiten Zeilen zum Lesen +-- einschliesst, wuerde ohne die Trennung jedem Mandanten auch das Aendern +-- und Entfernen dieser Zeilen erlauben. +-- +-- (4) SearchProvider — bewusst UNVERAENDERT, keine Anweisung in dieser +-- Migration. "tenantId" ist hier ebenfalls nullable, aber die Praemisse von +-- WINDOWS #19 stimmt fuer diese Tabelle nachweislich NICHT: es gibt keinen +-- Codeweg, der eine mandantenlose Zeile erzeugt — der einzige Schreibweg +-- (apps/api/src/dashboard/dashboard.service.ts) verlangt die +-- Mandantenkennung als Pflichtparameter, und die Vorgabe-Suchmaschinen sind +-- Konstanten (Entscheidung 05-02), keine Datenbankzeilen. Lokal gemessen +-- (2026-09-10): null Zeilen insgesamt in "SearchProvider". Eine Lockerung +-- waere hier die falsche Richtung — sie wuerde eine kuenftige mandantenlose +-- Zeile jedem Mandanten zeigen. Die Schliessung von WINDOWS #19 schliesst +-- diese Haelfte deshalb als WIDERLEGTE PRAEMISSE, nicht als geloestes +-- Problem. +-- +-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst, +-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (siehe +-- 20260909130000_rls_app_role und docs/mandantentrennung-datenbankrolle.md). +-- Die Verbindung ist zum Zeitpunkt dieser Migration weiterhin NICHT +-- umgestellt — `DATABASE_URL` zeigt unveraendert auf die Rolle `tessera`. +-- Ohne diesen Satz waere diese Datei genau das, wovor WINDOWS #18 warnt: +-- eine Regel, die Sicherheit vortaeuscht. + +-- (1) GroupMembership — beide Seiten der Beziehung. +DROP POLICY tenant_isolation_policy ON "GroupMembership"; + +CREATE POLICY tenant_isolation_policy ON "GroupMembership" + USING ( + "groupId" IN ( + SELECT "id" FROM "Group" WHERE "tenantId" = current_tenant_id() + ) + AND "userId" IN ( + SELECT "id" FROM "User" WHERE "tenantId" = current_tenant_id() + ) + ); + +-- (2) ModuleGrant — die Zeile selbst UND beide moeglichen Ziele. +DROP POLICY tenant_isolation_policy ON "ModuleGrant"; + +CREATE POLICY tenant_isolation_policy ON "ModuleGrant" + USING ( + "tenantId" = current_tenant_id() + AND ( + "groupId" IS NULL + OR "groupId" IN ( + SELECT "id" FROM "Group" WHERE "tenantId" = current_tenant_id() + ) + ) + AND ( + "userId" IS NULL + OR "userId" IN ( + SELECT "id" FROM "User" WHERE "tenantId" = current_tenant_id() + ) + ) + ); + +-- (3) TenderRssFeedSource — Lesen schliesst die plattformweiten Zeilen ein, +-- Schreiben (Einfuegen/Aendern/Entfernen) verlangt ausnahmslos einen +-- Mandanten. Vier Regeln statt einer, nach Befehl getrennt (Begruendung +-- oben). +DROP POLICY tenant_isolation_policy ON "TenderRssFeedSource"; + +CREATE POLICY tenant_platform_read_policy ON "TenderRssFeedSource" + FOR SELECT + USING ("tenantId" = current_tenant_id() OR "tenantId" IS NULL); + +CREATE POLICY tenant_insert_policy ON "TenderRssFeedSource" + FOR INSERT + WITH CHECK ("tenantId" = current_tenant_id()); + +CREATE POLICY tenant_update_policy ON "TenderRssFeedSource" + FOR UPDATE + USING ("tenantId" = current_tenant_id()) + WITH CHECK ("tenantId" = current_tenant_id()); + +CREATE POLICY tenant_delete_policy ON "TenderRssFeedSource" + FOR DELETE + USING ("tenantId" = current_tenant_id()); + +-- (4) SearchProvider — keine Anweisung. Die ausgelieferte Regel +-- ("tenantId" = current_tenant_id()) bleibt unveraendert bestehen. diff --git a/apps/api/scripts/rls-scratch-check.mjs b/apps/api/scripts/rls-scratch-check.mjs index 8874611..e3f46b1 100644 --- a/apps/api/scripts/rls-scratch-check.mjs +++ b/apps/api/scripts/rls-scratch-check.mjs @@ -389,6 +389,40 @@ function extractPolicySql(migrationSql, tableName) { return match ? match[0] : null; } +/** + * Liest die Migration, die T-JTS-02, T-JTS-03 und WINDOWS #19 schliesst + * (Dateiname endet auf "_rls_widen_membership_grant_and_platform_read", + * 260910-jab). Die beiden abgeloesten Regeln (GroupMembership, ModuleGrant) + * MUESSEN ab hier aus dieser Datei extrahiert werden, nicht mehr aus + * `readGroupsRlsPoliciesMigrationSql()` — sonst misst dieses Werkzeug + * weiter die abgeloeste Regel (Befund D). + */ +function readRlsWidenMigrationSql() { + const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + entry.name.endsWith('_rls_widen_membership_grant_and_platform_read'), + ) + .map((entry) => entry.name); + if (dirs.length !== 1) return null; + return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8'); +} + +/** + * Wie extractPolicySql(), aber liefert ALLE `CREATE POLICY ... ON + * ""`-Anweisungen einer Tabelle statt nur der ersten (Befund D: + * extractPolicySql() hat keinen globalen Flag und kennt nur EINEN + * Policy-Namen — fuer eine Tabelle mit mehreren, nach Befehl getrennten + * Regeln unter verschiedenen Namen reicht das nicht). Policy-Name ist + * absichtlich ein Platzhalter (`\w+`), nicht `tenant_isolation_policy` — + * die vier Regeln auf "TenderRssFeedSource" heissen unterschiedlich. + */ +function extractAllPolicySql(migrationSql, tableName) { + const re = new RegExp(`CREATE POLICY \\w+ ON "${tableName}"[\\s\\S]*?;`, 'g'); + return [...migrationSql.matchAll(re)].map((m) => m[0]); +} + /** * Aufgabe 1 (260909-ipc) — misst die fuenf im Plan genannten Verhaltensweisen * des Bereichs ldap unter der Rolle ohne BYPASSRLS, mit den beiden Policies @@ -565,28 +599,37 @@ function readRemainingTenantTablesMigrationSql() { } /** - * Aufgabe 1 (260909-jts), TEIL 1 — misst die im Plan genannten + * Aufgabe 1 (260909-jts/260910-jab), TEIL 1 — misst die im Plan genannten * Verhaltensweisen des Bereichs groups unter der Rolle ohne BYPASSRLS, mit - * den vier Policies WORTGLEICH aus den beiden ausgelieferten Migrationen - * (nicht im Werkzeug nachgetippt, vgl. runLdapAreaChecks). Findet die - * Extraktion eine der vier nicht, meldet dieser Abschnitt eine - * FEHLGESCHLAGENE Pruefung und bricht ab, statt mit einer geratenen Policy - * weiterzumessen. + * den vier Policies WORTGLEICH aus den ausgelieferten Migrationen (nicht im + * Werkzeug nachgetippt, vgl. runLdapAreaChecks). Findet die Extraktion eine + * der vier nicht, meldet dieser Abschnitt eine FEHLGESCHLAGENE Pruefung und + * bricht ab, statt mit einer geratenen Policy weiterzumessen. + * + * GroupMembership und ModuleGrant werden seit 260910-jab (T-JTS-02, + * T-JTS-03) aus `readRlsWidenMigrationSql()` gelesen, NICHT mehr aus + * `readGroupsRlsPoliciesMigrationSql()` — jene Datei traegt noch die + * abgeloeste, kuerzer greifende Fassung (Befund D). "Group" bleibt + * unveraendert und wird weiterhin aus der urspruenglichen Datei gelesen. * * Legt die Tabelle "Group" (samt je einer Zeile fuer TENANT-A und * TENANT-B) an, auf der runTransactionShapeMeasurement() weiter unten - * aufsetzt — diese Funktion muss deshalb VOR jener aufgerufen werden. + * aufsetzt — diese Funktion muss deshalb VOR jener aufgerufen werden. Setzt + * ausserdem auf der Tabelle "User" auf, die runAuthLookupChecks() vorher + * bereits angelegt hat (Zeilen user-a/TENANT-A, user-b/TENANT-B) — die neue + * GroupMembership/ModuleGrant-Regel prueft ueber diese Tabelle. */ async function runGroupsAreaChecks(adminUrl, scratchRoleUrl, results) { const groupsMigrationSql = readGroupsRlsPoliciesMigrationSql(); const remainingMigrationSql = readRemainingTenantTablesMigrationSql(); + const widenMigrationSql = readRlsWidenMigrationSql(); const groupPolicy = groupsMigrationSql ? extractPolicySql(groupsMigrationSql, 'Group') : null; - const groupMembershipPolicy = groupsMigrationSql - ? extractPolicySql(groupsMigrationSql, 'GroupMembership') + const groupMembershipPolicy = widenMigrationSql + ? extractPolicySql(widenMigrationSql, 'GroupMembership') : null; - const moduleGrantPolicy = groupsMigrationSql - ? extractPolicySql(groupsMigrationSql, 'ModuleGrant') + const moduleGrantPolicy = widenMigrationSql + ? extractPolicySql(widenMigrationSql, 'ModuleGrant') : null; const tenantModuleActivationPolicy = remainingMigrationSql ? extractPolicySql(remainingMigrationSql, 'TenantModuleActivation') @@ -602,7 +645,7 @@ async function runGroupsAreaChecks(adminUrl, scratchRoleUrl, results) { results, 'groups-policies-aus-migration-gefunden', false, - 'CREATE POLICY fuer "Group", "GroupMembership", "ModuleGrant" und/oder "TenantModuleActivation" nicht in den ausgelieferten Migrationen gefunden', + 'CREATE POLICY fuer "Group" (20260804130918_groups_rls_policies), "GroupMembership"/"ModuleGrant" (20260910120000_rls_widen_membership_grant_and_platform_read) und/oder "TenantModuleActivation" (20260909140000_rls_remaining_tenant_tables) nicht in den ausgelieferten Migrationen gefunden', ); return; } @@ -735,31 +778,63 @@ async function runGroupsAreaChecks(adminUrl, scratchRoleUrl, results) { foreignGroupInsertDetail, ); - // groupmembership-schreiben-fremder-benutzer-nicht-verhindert (Befund E): - // das GELINGEN dieses INSERTs ist das bestandene Ergebnis — es belegt, - // dass die Policy nur die Gruppenseite prueft, nicht die Benutzerseite. - let foreignUserInsertSucceeded = false; + // groupmembership-schreiben-fremder-benutzer-abgelehnt (260910-jab, + // T-JTS-02) — Umkehr der bisherigen loch-behauptenden Pruefung + // `groupmembership-schreiben-fremder-benutzer-nicht-verhindert`. Gemessen + // mit `user-b`, den es in TENANT-B TATSAECHLICH gibt (nicht mit einer + // erfundenen Kennung wie vormals `user-nicht-in-a`) — nur so misst diese + // Pruefung den Fall, den T-JTS-02 benannt hat, statt eines + // Fremdschluessel-Nichts. Die ABWEISUNG ist jetzt das bestandene + // Ergebnis: die Regel prueft seit 20260910120000_rls_widen_membership_ + // grant_and_platform_read beide Seiten der Beziehung. + let foreignUserInsertRejected = false; let foreignUserInsertDetail = ''; try { await forTenantQuery( prisma, 'TENANT-A', (tx) => - tx.$executeRaw`INSERT INTO "GroupMembership" (id, "groupId", "userId", source) VALUES ('membership-foreign-user', 'group-a', 'user-nicht-in-a', 'MANUAL')`, + tx.$executeRaw`INSERT INTO "GroupMembership" (id, "groupId", "userId", source) VALUES ('membership-foreign-user', 'group-a', 'user-b', 'MANUAL')`, ); - foreignUserInsertSucceeded = true; foreignUserInsertDetail = - 'INSERT mit A-eigener Gruppe, aber einer Benutzerkennung, die es in A nicht gibt, ist GELUNGEN — die Policy auf GroupMembership prueft nur die Gruppenseite, nicht die Benutzerseite (Befund E); die Anwendung muss die Benutzerseite selbst pruefen'; + 'INSERT mit A-eigener Gruppe, aber einer Benutzerkennung aus TENANT-B (user-b) ist NICHT fehlgeschlagen'; } catch (err) { - foreignUserInsertDetail = `INSERT unerwartet abgewiesen: ${err.message}`; + foreignUserInsertRejected = true; + foreignUserInsertDetail = `INSERT mit A-eigener Gruppe, aber fremder Benutzerkennung (user-b, TENANT-B) abgewiesen: ${err.message} — Umkehr von 'groupmembership-schreiben-fremder-benutzer-nicht-verhindert' (T-JTS-02, Befund E): die Policy auf GroupMembership prueft jetzt zusaetzlich die Benutzerseite, nicht mehr nur die Gruppenseite`; } report( results, - 'groupmembership-schreiben-fremder-benutzer-nicht-verhindert', - foreignUserInsertSucceeded, + 'groupmembership-schreiben-fremder-benutzer-abgelehnt', + foreignUserInsertRejected, foreignUserInsertDetail, ); + // groupmembership-fremder-benutzer-ueber-die-wartungsrolle-weiterhin-moeglich + // — die Gegenmessung: dasselbe Einfuegen ueber die Verwaltungsrolle mit + // Umgehungsrecht GELINGT. Damit steht fest, dass die Abweisung oben von + // der Regel kommt und nicht vom Aufbau. Praezedenzfall: + // `gruppenpfad-ueber-die-wartungsrolle-liefert-die-fremde-gruppe-mit`. + let foreignUserInsertViaAdminSucceeded = false; + let foreignUserInsertViaAdminDetail = ''; + try { + await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), (db) => + db.$executeRawUnsafe( + `INSERT INTO "GroupMembership" (id, "groupId", "userId", source) VALUES ('membership-foreign-user-admin', 'group-a', 'user-b', 'MANUAL')`, + ), + ); + foreignUserInsertViaAdminSucceeded = true; + foreignUserInsertViaAdminDetail = + 'INSERT mit A-eigener Gruppe, aber fremder Benutzerkennung (user-b, TENANT-B) ist ueber die Wartungsrolle (BYPASSRLS) weiterhin GELUNGEN — der Ausschluss aus der vorigen Pruefung kommt damit nachweislich von der Regel, nicht vom Aufbau'; + } catch (err) { + foreignUserInsertViaAdminDetail = `INSERT ueber die Wartungsrolle unerwartet abgewiesen: ${err.message}`; + } + report( + results, + 'groupmembership-fremder-benutzer-ueber-die-wartungsrolle-weiterhin-moeglich', + foreignUserInsertViaAdminSucceeded, + foreignUserInsertViaAdminDetail, + ); + // modulegrant-gebunden-nur-eigene-zeile const grantRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => tx.$queryRaw`SELECT "tenantId" FROM "ModuleGrant" ORDER BY id`, @@ -771,30 +846,88 @@ async function runGroupsAreaChecks(adminUrl, scratchRoleUrl, results) { `forTenant(TENANT-A) liefert ${grantRowsForA.length} Zeile(n): ${JSON.stringify(grantRowsForA.map((r) => r.tenantId))}`, ); - // modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt - // (Befund F): auch hier ist das Durchgehen das bestandene Ergebnis. - let foreignGroupGrantSucceeded = false; + // modulegrant-fremde-gruppe-abgelehnt (260910-jab, T-JTS-03) — Umkehr + // der bisherigen loch-behauptenden Pruefung + // `modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt`. Die + // ABWEISUNG ist jetzt das bestandene Ergebnis. Eigene id + // ('grant-foreign-group-rejected-attempt'), NICHT 'grant-foreign-group' + // — jene Kennung bleibt fuer die Zeile reserviert, die die Gegenmessung + // unten ueber die Wartungsrolle anlegt und auf der zwei Pruefungen des + // Bereichs `module-registry` aufsetzen (Befund C). + let foreignGroupGrantRejected = false; let foreignGroupGrantDetail = ''; try { await forTenantQuery( prisma, 'TENANT-A', (tx) => - tx.$executeRaw`INSERT INTO "ModuleGrant" (id, "tenantId", "moduleId", "groupId", "userId") VALUES ('grant-foreign-group', 'TENANT-A', 'mod-1', 'group-b', NULL)`, + tx.$executeRaw`INSERT INTO "ModuleGrant" (id, "tenantId", "moduleId", "groupId", "userId") VALUES ('grant-foreign-group-rejected-attempt', 'TENANT-A', 'mod-1', 'group-b', NULL)`, ); - foreignGroupGrantSucceeded = true; - foreignGroupGrantDetail = - 'INSERT mit korrekter eigener tenantId, aber fremder groupId ist GELUNGEN — die Policy auf ModuleGrant prueft nur die Mandantenkennung der Zeile, nicht die referenzierte Gruppe (Befund F); assertTargetBelongsToTenant ist der einzige Schutz und darf bei der Umstellung nicht entfallen'; + foreignGroupGrantDetail = 'INSERT mit korrekter eigener tenantId, aber fremder groupId ist NICHT fehlgeschlagen'; } catch (err) { - foreignGroupGrantDetail = `INSERT unerwartet abgewiesen: ${err.message}`; + foreignGroupGrantRejected = true; + foreignGroupGrantDetail = `INSERT mit korrekter eigener tenantId, aber fremder groupId (group-b, TENANT-B) abgewiesen: ${err.message} — Umkehr von 'modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt' (T-JTS-03, Befund F): die Policy auf ModuleGrant prueft jetzt zusaetzlich, ob die referenzierte Gruppe zum Mandanten gehoert. assertTargetBelongsToTenant in module-grants.service.ts bleibt DAVON UNABHAENGIG bestehen — dieses zweite Netz wird durch die Regelaenderung nicht ersetzt`; } report( results, - 'modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt', - foreignGroupGrantSucceeded, + 'modulegrant-fremde-gruppe-abgelehnt', + foreignGroupGrantRejected, foreignGroupGrantDetail, ); + // modulegrant-fremder-benutzer-abgelehnt — NEU, der zweite Zweig des + // Entweder-oder (D-04): eine Freigabe mit eigener Mandantenkennung und + // fremder Benutzerkennung. T-JTS-03 hat nur den Gruppenzweig gemessen; + // ohne diese Pruefung bliebe die Haelfte des Lochs offen. + let foreignUserGrantRejected = false; + let foreignUserGrantDetail = ''; + try { + await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => + tx.$executeRaw`INSERT INTO "ModuleGrant" (id, "tenantId", "moduleId", "groupId", "userId") VALUES ('grant-foreign-user-rejected-attempt', 'TENANT-A', 'mod-1', NULL, 'user-b')`, + ); + foreignUserGrantDetail = 'INSERT mit korrekter eigener tenantId, aber fremder userId ist NICHT fehlgeschlagen'; + } catch (err) { + foreignUserGrantRejected = true; + foreignUserGrantDetail = `INSERT mit korrekter eigener tenantId, aber fremder userId (user-b, TENANT-B) abgewiesen: ${err.message} — T-JTS-03 hatte nur den Gruppenzweig des Entweder-oder (D-04) gemessen; dieser Zweig ist der zweite und schliesst die andere Haelfte des Lochs`; + } + report( + results, + 'modulegrant-fremder-benutzer-abgelehnt', + foreignUserGrantRejected, + foreignUserGrantDetail, + ); + + // modulegrant-fremde-gruppe-ueber-die-wartungsrolle-weiterhin-moeglich — + // Ersatz/Umkehr-Gegenmessung fuer die vormals loch-behauptende Pruefung. + // Legt ZUGLEICH die Zeile 'grant-foreign-group' an, die vormals als + // Nebenwirkung der loch-behauptenden Pruefung entstand und auf der die + // beiden Pruefungen des Bereichs `module-registry` aufsetzen (Befund C) + // — jetzt ueber die Wartungsrolle bereitgestellt, weil das gebundene + // INSERT oben abgewiesen wird. + let foreignGroupGrantViaAdminSucceeded = false; + let foreignGroupGrantViaAdminDetail = ''; + try { + await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), (db) => + db.$executeRawUnsafe( + `INSERT INTO "ModuleGrant" (id, "tenantId", "moduleId", "groupId", "userId") VALUES ('grant-foreign-group', 'TENANT-A', 'mod-1', 'group-b', NULL)`, + ), + ); + foreignGroupGrantViaAdminSucceeded = true; + foreignGroupGrantViaAdminDetail = + "INSERT mit korrekter eigener tenantId, aber fremder groupId ist ueber die Wartungsrolle (BYPASSRLS) weiterhin GELUNGEN — Umkehr von 'modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt' (T-JTS-03): die Regel weist diese Zeile unter gebundenem Kontext nachweislich ab (siehe 'modulegrant-fremde-gruppe-abgelehnt'), die Wartungsrolle umgeht RLS grundsaetzlich; legt zugleich die Zeile 'grant-foreign-group' an, auf der zwei Pruefungen des Bereichs module-registry aufsetzen (Befund C)"; + } catch (err) { + foreignGroupGrantViaAdminDetail = `INSERT ueber die Wartungsrolle unerwartet abgewiesen: ${err.message}`; + } + report( + results, + 'modulegrant-fremde-gruppe-ueber-die-wartungsrolle-weiterhin-moeglich', + foreignGroupGrantViaAdminSucceeded, + foreignGroupGrantViaAdminDetail, + ); + // tenantmoduleactivation-gebunden-nur-eigene-zeile const activationRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => tx.$queryRaw`SELECT "tenantId" FROM "TenantModuleActivation" ORDER BY id`, @@ -811,13 +944,19 @@ 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. + * Aufgabe 1 (260909-laa/260910-jab) — misst die Sonderfaelle des Bereichs + * tenders unter der Rolle ohne BYPASSRLS. Vier der fuenf Policies + * (TenderEmailConfig, TenderNotificationPref, TenderSavedSearch, + * TenderTriage) kommen WORTGLEICH aus der ausgelieferten Migration + * `_rls_remaining_tenant_tables` (dieselbe Datei, die bereits die + * TenantModuleActivation-Policy fuer runGroupsAreaChecks liefert). + * TenderRssFeedSource (WINDOWS #19) kommt seit 260910-jab NICHT mehr aus + * jener Datei — die dortige Fassung ist die abgeloeste, kuerzer greifende + * Regel (Befund D) — sondern als VIER nach Befehl getrennte Policies aus + * `readRlsWidenMigrationSql()`, extrahiert mit `extractAllPolicySql()` + * (mehrere Treffer je Tabelle, Befund D). Findet die Extraktion eine der + * Policies 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 @@ -829,6 +968,7 @@ async function runGroupsAreaChecks(adminUrl, scratchRoleUrl, results) { */ async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) { const remainingMigrationSql = readRemainingTenantTablesMigrationSql(); + const widenMigrationSql = readRlsWidenMigrationSql(); const emailConfigPolicy = remainingMigrationSql ? extractPolicySql(remainingMigrationSql, 'TenderEmailConfig') @@ -836,9 +976,9 @@ async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) { const notificationPrefPolicy = remainingMigrationSql ? extractPolicySql(remainingMigrationSql, 'TenderNotificationPref') : null; - const rssFeedPolicy = remainingMigrationSql - ? extractPolicySql(remainingMigrationSql, 'TenderRssFeedSource') - : null; + const rssFeedPolicies = widenMigrationSql + ? extractAllPolicySql(widenMigrationSql, 'TenderRssFeedSource') + : []; const savedSearchPolicy = remainingMigrationSql ? extractPolicySql(remainingMigrationSql, 'TenderSavedSearch') : null; @@ -849,7 +989,7 @@ async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) { if ( !emailConfigPolicy || !notificationPrefPolicy || - !rssFeedPolicy || + rssFeedPolicies.length !== 4 || !savedSearchPolicy || !triagePolicy ) { @@ -857,7 +997,7 @@ async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) { 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', + `CREATE POLICY fuer "TenderEmailConfig", "TenderNotificationPref", "TenderSavedSearch" und/oder "TenderTriage" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden, oder nicht genau vier Policies fuer "TenderRssFeedSource" in *_rls_widen_membership_grant_and_platform_read (gefunden: ${rssFeedPolicies.length})`, ); return; } @@ -917,7 +1057,9 @@ async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) { } await db.$executeRawUnsafe(emailConfigPolicy); await db.$executeRawUnsafe(notificationPrefPolicy); - await db.$executeRawUnsafe(rssFeedPolicy); + for (const policySql of rssFeedPolicies) { + await db.$executeRawUnsafe(policySql); + } await db.$executeRawUnsafe(savedSearchPolicy); await db.$executeRawUnsafe(triagePolicy); @@ -1038,13 +1180,13 @@ async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) { `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. + // tenderrssfeed-plattformzeile-gebunden-sichtbar (260910-jab, WINDOWS + // #19) — Umkehr von 'tenderrssfeed-plattformzeile-unter-jedem- + // mandanten-unsichtbar': die plattformweite Zeile (userId/tenantId + // NULL) ist jetzt unter BEIDEN Mandantenkontexten SICHTBAR, weil die + // Leseregel der neuen vier Policies (tenant_platform_read_policy, + // 20260910120000_rls_widen_membership_grant_and_platform_read) Zeilen + // ohne Mandant ausdruecklich einschliesst. const rssRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => tx.$queryRaw`SELECT id FROM "TenderRssFeedSource" ORDER BY id`, ); @@ -1055,15 +1197,47 @@ async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) { 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-plattformzeile-gebunden-sichtbar', + platformRowVisibleForA && platformRowVisibleForB, + `forTenant(TENANT-A) sieht die Platform-Zeile: ${platformRowVisibleForA}, forTenant(TENANT-B) sieht sie: ${platformRowVisibleForB} — Umkehr von 'tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar' (WINDOWS #19): eine plattformweite RSS-Quelle ist seit 20260910120000_rls_widen_membership_grant_and_platform_read unter JEDEM Mandantenkontext sichtbar`, + ); + + // tenderrssfeed-eigene-zeile-gebunden-weiterhin-sichtbar — die andere + // Fehlerrichtung derselben Aenderung: der gebundene Lesezugriff liefert + // ZUSAETZLICH weiterhin die eigene Zeile des Mandanten und NICHT die des + // anderen. Ohne diese Pruefung waere eine zu weit gefasste Leseregel + // unbemerkt geblieben. + const ownRowVisibleForA = rssRowsForA.some((r) => r.id === 'rss-a'); + const foreignRowVisibleForA = rssRowsForA.some((r) => r.id === 'rss-b'); + report( + results, + 'tenderrssfeed-eigene-zeile-gebunden-weiterhin-sichtbar', + ownRowVisibleForA && !foreignRowVisibleForA, + `forTenant(TENANT-A) liefert ${JSON.stringify(rssRowsForA.map((r) => r.id))} — eigene Zeile (rss-a) sichtbar: ${ownRowVisibleForA}, fremde Zeile (rss-b, TENANT-B) sichtbar: ${foreignRowVisibleForA}`, + ); + + // tenderrssfeed-ungebunden-nur-die-plattformzeile — die Belegzeile fuer + // Befund F: ohne gesetzten Mandantenkontext liefert der Lesezugriff + // jetzt genau die plattformweiten Zeilen und keine persoenliche. Das ist + // die neue Fehlerrichtung, die Aufgabe 2 traegt (listForUser wird + // gebunden); hier gemessen, nicht behauptet. + const unboundRssRows = await prisma.$queryRaw`SELECT id FROM "TenderRssFeedSource" ORDER BY id`; + const unboundOnlyPlatform = + unboundRssRows.length === 1 && unboundRssRows[0].id === 'rss-platform'; + report( + results, + 'tenderrssfeed-ungebunden-nur-die-plattformzeile', + unboundOnlyPlatform, + `ungebundener SELECT auf "TenderRssFeedSource" liefert ${unboundRssRows.length} Zeile(n): ${JSON.stringify(unboundRssRows.map((r) => r.id))} — Befund F: aus einer schreienden Leere (vor der Reparatur: null Zeilen) wuerde nach dem Scharfschalten eine kurze, glaubhafte Teilantwort (nur die plattformweiten Zeilen); listForUser() wird deshalb in Aufgabe 2 gebunden`, ); // 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. + // nicht gebunden werden. Die Abweisung kommt jetzt von der ausdruecklichen + // WITH-CHECK-Klausel der Einfuegeregel (tenant_insert_policy), nicht mehr + // von der stillschweigenden Zweitverwendung eines einzigen USING- + // Ausdrucks. let platformInsertRejected = false; let platformInsertDetail = ''; try { @@ -1076,7 +1250,7 @@ async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) { 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`; + platformInsertDetail = `gebundenes INSERT mit tenantId=NULL abgewiesen (tenant_insert_policy): ${err.message} — createPlatform darf deshalb nicht gebunden werden`; } report( results, @@ -1085,6 +1259,75 @@ async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) { platformInsertDetail, ); + // tenderrssfeed-gebundenes-aendern-der-plattformzeile-abgelehnt — NEU: + // ein gebundenes UPDATE auf die plattformweite Zeile wird abgewiesen. + // Zusammen mit der folgenden Pruefung die Messung der Richtung "zu + // locker" (Befund K) und damit der eigentliche Grund fuer die Trennung + // nach Befehl: eine einzelne Leseregel, die die plattformweite Zeile + // einschliesst, wuerde ohne diese Trennung auch UPDATE/DELETE erlauben. + let platformUpdateRejected = false; + let platformUpdateDetail = ''; + try { + await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => + tx.$executeRaw`UPDATE "TenderRssFeedSource" SET url = 'https://gebunden-veraendert.example-tenders.invalid/feed' WHERE id = 'rss-platform'`, + ); + // Ein UPDATE, das keine Zeile trifft, wirft in Postgres KEINEN Fehler + // — es muss deshalb explizit nachgesehen werden, ob die Zeile + // tatsaechlich unveraendert blieb (die USING-Klausel der + // Aenderungsregel filtert die Zielzeile heraus, bevor SET greift). + const [afterUpdate] = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + (db) => + db.$queryRaw`SELECT url FROM "TenderRssFeedSource" WHERE id = 'rss-platform'`, + ); + platformUpdateRejected = + afterUpdate.url !== 'https://gebunden-veraendert.example-tenders.invalid/feed'; + platformUpdateDetail = platformUpdateRejected + ? `gebundenes UPDATE auf die plattformweite Zeile traf keine Zeile (tenant_update_policy filtert sie heraus) — url unveraendert: ${JSON.stringify(afterUpdate.url)}` + : `gebundenes UPDATE auf die plattformweite Zeile ist UNERWARTET durchgegangen — url jetzt: ${JSON.stringify(afterUpdate.url)}`; + } catch (err) { + platformUpdateRejected = true; + platformUpdateDetail = `gebundenes UPDATE auf die plattformweite Zeile abgewiesen: ${err.message}`; + } + report( + results, + 'tenderrssfeed-gebundenes-aendern-der-plattformzeile-abgelehnt', + platformUpdateRejected, + platformUpdateDetail, + ); + + // tenderrssfeed-gebundenes-loeschen-der-plattformzeile-abgelehnt — NEU: + // ein gebundenes DELETE auf die plattformweite Zeile wird abgewiesen. + let platformDeleteRejected = false; + let platformDeleteDetail = ''; + try { + const deleteResult = await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => + tx.$executeRaw`DELETE FROM "TenderRssFeedSource" WHERE id = 'rss-platform'`, + ); + // $executeRaw liefert die Anzahl betroffener Zeilen — 0, wenn die + // USING-Klausel der Loeschregel die Zielzeile herausfiltert, bevor + // DELETE greift (kein Fehler, wie bei UPDATE oben). + platformDeleteRejected = deleteResult === 0; + platformDeleteDetail = platformDeleteRejected + ? 'gebundenes DELETE auf die plattformweite Zeile traf 0 Zeilen (tenant_delete_policy filtert sie heraus)' + : `gebundenes DELETE auf die plattformweite Zeile ist UNERWARTET durchgegangen (${deleteResult} Zeile(n) betroffen)`; + } catch (err) { + platformDeleteRejected = true; + platformDeleteDetail = `gebundenes DELETE auf die plattformweite Zeile abgewiesen: ${err.message}`; + } + report( + results, + 'tenderrssfeed-gebundenes-loeschen-der-plattformzeile-abgelehnt', + platformDeleteRejected, + platformDeleteDetail, + ); + // 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 @@ -1119,6 +1362,84 @@ async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) { } } +/** + * Aufgabe 1 (260910-jab) — misst, dass die UNVERAENDERT ausgelieferte Regel + * auf "SearchProvider" (20260909140000_rls_remaining_tenant_tables) eine + * mandantenlose Zeile weiterhin unter JEDEM Kontext unsichtbar laesst. + * Anders als bei TenderRssFeedSource ist das hier KEIN Loch, sondern + * korrektes Verhalten (Befund E): die Praemisse von WINDOWS #19 ist fuer + * diese Tabelle widerlegt — es gibt keinen Codeweg, der eine mandantenlose + * Zeile erzeugt (der einzige Schreibweg, dashboard.service.ts, verlangt die + * Mandantenkennung als Pflichtparameter; die Vorgabe-Suchmaschinen sind + * Konstanten, Entscheidung 05-02, keine Datenbankzeilen). Eine Lockerung + * hier waere die falsche Richtung. Eigene Wegwerf-Tabelle, weil keine + * andere Pruefung dieses Werkzeugs "SearchProvider" beruehrt. + * + * Legt keine Tabelle an, auf der eine andere Pruefung dieses Werkzeugs + * aufsetzt — wie runTendersAreaChecks() ist dieser Abschnitt in der + * Aufrufkette ein Blatt. + */ +async function runSearchProviderAreaChecks(adminUrl, scratchRoleUrl, results) { + const remainingMigrationSql = readRemainingTenantTablesMigrationSql(); + const searchProviderPolicy = remainingMigrationSql + ? extractPolicySql(remainingMigrationSql, 'SearchProvider') + : null; + + if (!searchProviderPolicy) { + report( + results, + 'searchprovider-policy-aus-migration-gefunden', + false, + 'CREATE POLICY fuer "SearchProvider" 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 "SearchProvider" ( + id text PRIMARY KEY, + "userId" text, + "tenantId" text, + name text NOT NULL + ); + `); + await db.$executeRawUnsafe(`ALTER TABLE "SearchProvider" ENABLE ROW LEVEL SECURITY;`); + await db.$executeRawUnsafe(`ALTER TABLE "SearchProvider" FORCE ROW LEVEL SECURITY;`); + await db.$executeRawUnsafe(searchProviderPolicy); + await db.$executeRawUnsafe( + `GRANT SELECT, INSERT, UPDATE, DELETE ON "SearchProvider" TO ${SCRATCH_ROLE_NAME}`, + ); + // Eine mandantenlose Zeile wird ueber die Wartungsrolle angelegt — unter + // der Anwendungsrolle liesse sie sich mit der unveraenderten Regel + // ohnehin nicht schreiben. + await db.$executeRawUnsafe(` + INSERT INTO "SearchProvider" (id, "userId", "tenantId", name) VALUES + ('search-tenantless', NULL, NULL, 'Mandantenlose Suchmaschine'); + `); + }); + + const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); + try { + const rowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw`SELECT id FROM "SearchProvider" ORDER BY id`, + ); + const rowsForB = await forTenantQuery(prisma, 'TENANT-B', (tx) => + tx.$queryRaw`SELECT id FROM "SearchProvider" ORDER BY id`, + ); + const visibleForA = rowsForA.some((r) => r.id === 'search-tenantless'); + const visibleForB = rowsForB.some((r) => r.id === 'search-tenantless'); + report( + results, + 'searchprovider-mandantenlose-zeile-bleibt-unter-jedem-kontext-unsichtbar', + !visibleForA && !visibleForB, + `forTenant(TENANT-A) sieht die mandantenlose Zeile: ${visibleForA}, forTenant(TENANT-B) sieht sie: ${visibleForB} — bewusst UNVERAENDERT (WINDOWS #19, Befund E: die Praemisse einer mandantenlosen SearchProvider-Zeile ist widerlegt, es gibt keinen Codeweg, der eine solche Zeile erzeugt — dashboard.service.ts verlangt die Mandantenkennung als Pflichtparameter, die Vorgabe-Suchmaschinen sind Konstanten, 05-02). Neu zu bewerten, WENN ein Schreibweg entsteht, der "tenantId" absichtlich leer laesst`, + ); + } finally { + await prisma.$disconnect(); + } +} + /** * Aufgabe 1 (260909-mir), TEIL 1 — misst die neun im Plan genannten * Verhaltensweisen des Bereichs dkv unter der Rolle ohne BYPASSRLS, mit @@ -1925,13 +2246,20 @@ async function runModuleRegistryAreaChecks(adminUrl, scratchRoleUrl, results) { `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 + // 6: gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus — Meldetext + // seit 260910-jab RICHTIGGESTELLT: er behauptete vormals, die Regel auf + // "ModuleGrant" lasse 'grant-foreign-group' durch (T-JTS-03). Das ist + // nach der Reparatur unwahr — die Regel weist ein gebundenes Einfuegen + // dieser Zeile jetzt nachweislich ab (siehe 'modulegrant-fremde-gruppe- + // abgelehnt'); die Zeile existiert hier nur, weil + // 'modulegrant-fremde-gruppe-ueber-die-wartungsrolle-weiterhin-moeglich' + // sie ueber die Wartungsrolle (BYPASSRLS) angelegt hat. 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`, + `forTenant(TENANT-A) liefert 'grant-foreign-group' ueber denselben Drei-Tabellen-Weg NICHT (Ergebnis: ${JSON.stringify(groupPathForA.map((r) => r.id))}) — die Zeile wurde ueber die Wartungsrolle angelegt (die Regel auf "ModuleGrant" weist ihr gebundenes Einfuegen seit T-JTS-02/T-JTS-03 nachweislich ab) und die Mitgliedschaft (group-b, user-a) ist vorhanden — diese Verteidigung greift erst nach dem Scharfschalten`, ); // 7: gruppenpfad-ueber-die-wartungsrolle-liefert-die-fremde-gruppe-mit — @@ -2296,6 +2624,7 @@ async function main() { await runLdapAreaChecks(adminUrl, scratchRoleUrlString, results); await runGroupsAreaChecks(adminUrl, scratchRoleUrlString, results); await runTendersAreaChecks(adminUrl, scratchRoleUrlString, results); + await runSearchProviderAreaChecks(adminUrl, scratchRoleUrlString, results); await runDkvAreaChecks(adminUrl, scratchRoleUrlString, results); await runUserAreaChecks(adminUrl, scratchRoleUrlString, results); await runModuleRegistryAreaChecks(adminUrl, scratchRoleUrlString, results); diff --git a/apps/api/src/groups/migration-sql.spec.ts b/apps/api/src/groups/migration-sql.spec.ts index 84443ff..db3516f 100644 --- a/apps/api/src/groups/migration-sql.spec.ts +++ b/apps/api/src/groups/migration-sql.spec.ts @@ -26,6 +26,24 @@ function readMigrationSql(suffix: string): string { return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8'); } +/** + * Schneidet eine einzelne `CREATE POLICY ON "" ... ;` + * -Anweisung aus dem Migrationstext, wortgleich zu `extractPolicySql()` / + * `extractAllPolicySql()` in apps/api/scripts/rls-scratch-check.mjs — reiner + * Textabgleich, keine Datenbank. Ohne `policyName` wird der erste Treffer + * fuer die Tabelle genommen (fuer Tabellen mit genau einer Regel); + * `policyName` waehlt gezielt eine von mehreren (TenderRssFeedSource). + */ +function extractPolicyBlock(sql: string, tableName: string, policyName?: string): string { + const name = policyName ?? '\\w+'; + const re = new RegExp(`CREATE POLICY ${name} ON "${tableName}"[\\s\\S]*?;`); + const match = sql.match(re); + if (!match) { + throw new Error(`CREATE POLICY fuer "${tableName}"${policyName ? ` (${policyName})` : ''} nicht gefunden`); + } + return match[0]; +} + describe('add_groups_and_module_grants migration.sql (D-04, D-06, D-13)', () => { const sql = readMigrationSql('_add_groups_and_module_grants'); @@ -95,6 +113,66 @@ describe('groups_rls_policies migration.sql (T-15-11)', () => { }); }); +describe('rls_widen_membership_grant_and_platform_read migration.sql (T-JTS-02, T-JTS-03, WINDOWS #19)', () => { + const sql = readMigrationSql('_rls_widen_membership_grant_and_platform_read'); + + it('loest die abgeloesten Regeln auf GroupMembership und ModuleGrant ab (DROP + CREATE unter demselben Namen)', () => { + expect(sql).toContain('DROP POLICY tenant_isolation_policy ON "GroupMembership"'); + expect(sql).toContain('DROP POLICY tenant_isolation_policy ON "ModuleGrant"'); + const groupMembershipCreates = ( + sql.match(/CREATE POLICY tenant_isolation_policy ON "GroupMembership"/g) ?? [] + ).length; + const moduleGrantCreates = ( + sql.match(/CREATE POLICY tenant_isolation_policy ON "ModuleGrant"/g) ?? [] + ).length; + expect(groupMembershipCreates).toBe(1); + expect(moduleGrantCreates).toBe(1); + }); + + it('die neue GroupMembership-Regel prueft die Benutzerseite UND (mit UND verknuepft) die Gruppenseite', () => { + const policy = extractPolicyBlock(sql, 'GroupMembership'); + expect(policy).toContain('SELECT "id" FROM "Group" WHERE "tenantId" = current_tenant_id()'); + expect(policy).toContain('SELECT "id" FROM "User" WHERE "tenantId" = current_tenant_id()'); + expect(policy).toMatch(/AND\s+"userId"\s+IN/); + }); + + it('die neue ModuleGrant-Regel prueft die referenzierte Gruppe UND den referenzierten Benutzer, beide mit Leer-Zulassung (D-04)', () => { + const policy = extractPolicyBlock(sql, 'ModuleGrant'); + expect(policy).toContain('"groupId" IS NULL'); + expect(policy).toContain('"userId" IS NULL'); + expect(policy).toContain('SELECT "id" FROM "Group" WHERE "tenantId" = current_tenant_id()'); + expect(policy).toContain('SELECT "id" FROM "User" WHERE "tenantId" = current_tenant_id()'); + }); + + it('loest die abgeloeste Regel auf TenderRssFeedSource ab und legt genau vier nach Befehl getrennte Regeln an', () => { + expect(sql).toContain('DROP POLICY tenant_isolation_policy ON "TenderRssFeedSource"'); + for (const name of [ + 'tenant_platform_read_policy', + 'tenant_insert_policy', + 'tenant_update_policy', + 'tenant_delete_policy', + ]) { + expect(sql).toContain(`CREATE POLICY ${name} ON "TenderRssFeedSource"`); + } + }); + + it('ausschliesslich die Leseregel auf TenderRssFeedSource laesst Zeilen ohne Mandant zu', () => { + const readPolicy = extractPolicyBlock(sql, 'TenderRssFeedSource', 'tenant_platform_read_policy'); + const insertPolicy = extractPolicyBlock(sql, 'TenderRssFeedSource', 'tenant_insert_policy'); + const updatePolicy = extractPolicyBlock(sql, 'TenderRssFeedSource', 'tenant_update_policy'); + const deletePolicy = extractPolicyBlock(sql, 'TenderRssFeedSource', 'tenant_delete_policy'); + + expect(readPolicy).toContain('IS NULL'); + for (const writePolicy of [insertPolicy, updatePolicy, deletePolicy]) { + expect(writePolicy).not.toContain('IS NULL'); + } + }); + + it('fasst SearchProvider nicht an — kein DROP POLICY und kein CREATE POLICY fuer diese Tabelle', () => { + expect(sql).not.toMatch(/(DROP|CREATE) POLICY [\w ]*ON "SearchProvider"/); + }); +}); + describe('add_group_internal_name_and_object_guid migration.sql (D-04)', () => { const sql = readMigrationSql('_add_group_internal_name_and_object_guid');