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
+78
View File
@@ -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 <name?> ON "<Tabelle>" ... ;`
* -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');