feat(quick-260910-jab): drei zu kurz greifende RLS-Regeln schliessen (T-JTS-02, T-JTS-03, WINDOWS #19)

- Neue, handgeschriebene Migration 20260910120000_rls_widen_membership_grant_and_platform_read:
  GroupMembership prueft jetzt beide Seiten der Beziehung (Gruppe UND Benutzer),
  ModuleGrant prueft zusaetzlich die referenzierte Gruppe/den referenzierten Benutzer
  (mit Leer-Zulassung, D-04), TenderRssFeedSource bekommt vier nach Befehl getrennte
  Regeln statt einer (Lesen schliesst plattformweite Zeilen ein, Schreiben verlangt
  weiterhin einen Mandanten). SearchProvider bewusst unveraendert (Befund E: Praemisse
  widerlegt). Lokal angewandt und gegen den Systemkatalog der lebenden Datenbank
  gemessen. Der Schalter bleibt aus (Rolle tessera).
- rls-scratch-check.mjs: die drei loch-behauptenden Pruefungen umgekehrt (nicht
  geloescht), Gegenmessungen ueber die Wartungsrolle ergaenzt, vier Befehlsrichtungen
  fuer TenderRssFeedSource gemessen, neuer Abschnitt fuer SearchProvider, Extraktion
  auf die neue Migration umgeleitet und um eine mehrfach-treffer-faehige Form ergaenzt
  (extractAllPolicySql).
- migration-sql.spec.ts: neuer Beschreibungsblock fuer die neue Migration.

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 14:29:25 +02:00
parent 93444aa91e
commit f4f3115d5a
3 changed files with 590 additions and 59 deletions
+388 -59
View File
@@ -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
* "<Tabelle>"`-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);