diff --git a/apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql b/apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql new file mode 100644 index 0000000..ba63644 --- /dev/null +++ b/apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql @@ -0,0 +1,156 @@ +-- WINDOWS #18 — vollstaendige RLS-Abdeckung fuer alle Tabellen mit +-- tenantId (T-DGJ-02). Zweite von zwei Migrationen zu diesem Fund; die +-- erste (20260909130000_rls_app_role) legt die Anwendungsrolle +-- tessera_app ohne Superuser-/BYPASSRLS-Recht an. +-- +-- Zaehlung im Schema (Stand 2026-09-09): 28 Modelle insgesamt, davon 20 mit +-- direkter tenantId-Spalte. Von diesen 20 trugen bislang 4 eine Policy +-- (User, LdapConfig, Group, ModuleGrant — aus 20260618112133 und +-- 20260804130918). Diese Migration ergaenzt die restlichen 16. +-- +-- Die 8 Modelle OHNE tenantId zerfallen in zwei Gruppen: +-- +-- (a) Drei ueber einen Join geschuetzt, keine eigene tenantId-Spalte, +-- bereits mit Policy versehen: PasswordResetToken (userId -> User), +-- LdapFieldMapping (ldapConfigId -> LdapConfig), GroupMembership +-- (groupId -> Group). +-- +-- (b) Fuenf bewusst OHNE Policy, weil sie keine tenantId-Spalte tragen +-- und auch keine tragen sollen: +-- - Tenant: die Mandantentabelle selbst. Eine Regel darauf wuerde +-- die Aufloesung des Mandanten verhindern, auf der jede andere +-- Regel beruht. +-- - Module: der Modulkatalog ist plattformweit; die +-- mandantenbezogene Zuordnung liegt in TenantModuleActivation, +-- und die bekommt hier eine Policy. +-- - Tender, TenderSource, TenderSourcePollConfig: der +-- Ausschreibungskatalog ist plattformweite Bezugsdaten +-- (Entscheidung D-03 aus Phase 10, siehe +-- .planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-01-PLAN.md:93: +-- kein tenantId, weil global). Eine Policy darauf wuerde einem +-- zweiten Mandanten den gemeinsamen Katalog verbergen. +-- +-- KORREKTUR einer Aussage aus dem Bestand — die alte Datei bleibt dabei +-- unveraendert, weil Prisma ihre Pruefsumme fuehrt und eine Aenderung +-- "prisma migrate deploy" zum Abbruch braechte: +-- Der Kopf von 20260804130918_groups_rls_policies sagt pauschal, "die +-- Tender*-Tabellen bleiben bewusst ohne RLS (D-03)". Nachgemessen trifft +-- das nur auf die drei oben genannten Tabellen OHNE tenantId zu. Die +-- sechs Tender-Tabellen MIT tenantId (TenderEmailConfig, TenderMatch, +-- TenderNotificationPref, TenderRssFeedSource, TenderSavedSearch, +-- TenderTriage) enthalten keine Katalogdaten, sondern Zeilen einzelner +-- Nutzer und Mandanten — Suchprofile, Treffer, +-- Benachrichtigungseinstellungen, Postfachanbindungen. D-03 betrifft sie +-- nicht; sie bekommen unten allesamt eine Policy. +-- +-- WICHTIG: Diese Policies wirken erst, wenn die Anwendung als Rolle ohne +-- Umgehungsrecht verbindet — siehe Migration 20260909130000_rls_app_role +-- und docs/mandantentrennung-datenbankrolle.md. Die Verbindung ist zum +-- Zeitpunkt dieser Migration noch NICHT umgestellt. Ohne diesen Satz waere +-- diese Datei genau das, wovor WINDOWS #18 warnt: eine Regel, die +-- Sicherheit vortaeuscht. +-- +-- Keine getrennte WITH CHECK-Klausel: laesst man sie weg, verwendet +-- PostgreSQL denselben USING-Ausdruck auch fuer neu geschriebene Zeilen — +-- genau das ist gewollt, damit unter der neuen Rolle niemand eine Zeile +-- mit fremder Mandantenkennung einfuegen kann. + +-- CalendarSource — Kalenderquellen (CalDAV/ICS/Exchange) eines Nutzers +ALTER TABLE "CalendarSource" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "CalendarSource" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "CalendarSource" + USING ("tenantId" = current_tenant_id()); + +-- DashboardLayout — Widget-Anordnung eines Nutzers auf dem Dashboard +ALTER TABLE "DashboardLayout" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "DashboardLayout" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "DashboardLayout" + USING ("tenantId" = current_tenant_id()); + +-- DkvInvoiceHistory — DKV-Rechnungshistorie samt Exportstatus +ALTER TABLE "DkvInvoiceHistory" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "DkvInvoiceHistory" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "DkvInvoiceHistory" + USING ("tenantId" = current_tenant_id()); + +-- DkvModuleConfig — Postfach-/Zugangsdaten des DKV-Moduls je Mandant +ALTER TABLE "DkvModuleConfig" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "DkvModuleConfig" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "DkvModuleConfig" + USING ("tenantId" = current_tenant_id()); + +-- DkvVehicleMaster — Fahrzeugstammdaten des DKV-Moduls +ALTER TABLE "DkvVehicleMaster" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "DkvVehicleMaster" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "DkvVehicleMaster" + USING ("tenantId" = current_tenant_id()); + +-- FavoriteLink — Favoriten-Links eines Nutzers +ALTER TABLE "FavoriteLink" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "FavoriteLink" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "FavoriteLink" + USING ("tenantId" = current_tenant_id()); + +-- SearchProvider — vom Nutzer angelegte Suchmaschinen fuer das Such-Widget +-- (tenantId ist nullable — Vorgabe-Anbieter sind ueber Konstanten geloest, +-- 05-02; eine mandantenlose Zeile wird von dieser Policy nicht ausgeliefert) +ALTER TABLE "SearchProvider" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "SearchProvider" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "SearchProvider" + USING ("tenantId" = current_tenant_id()); + +-- SmtpConfig — SMTP-Zugangsdaten je Mandant +ALTER TABLE "SmtpConfig" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "SmtpConfig" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "SmtpConfig" + USING ("tenantId" = current_tenant_id()); + +-- TenantModuleActivation — welche Module ein Mandant aktiviert hat +ALTER TABLE "TenantModuleActivation" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "TenantModuleActivation" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "TenantModuleActivation" + USING ("tenantId" = current_tenant_id()); + +-- TenderEmailConfig — Postfachanbindung eines Nutzers im Ausschreibungs-Radar +ALTER TABLE "TenderEmailConfig" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "TenderEmailConfig" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "TenderEmailConfig" + USING ("tenantId" = current_tenant_id()); + +-- TenderMatch — Treffer eines gespeicherten Suchprofils +ALTER TABLE "TenderMatch" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "TenderMatch" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "TenderMatch" + USING ("tenantId" = current_tenant_id()); + +-- TenderNotificationPref — Benachrichtigungseinstellungen eines Nutzers +ALTER TABLE "TenderNotificationPref" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "TenderNotificationPref" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "TenderNotificationPref" + USING ("tenantId" = current_tenant_id()); + +-- TenderRssFeedSource — RSS-Quellen im Ausschreibungs-Radar +-- (tenantId ist nullable — null markiert eine plattformweite Quelle, D-06; +-- eine solche Zeile wird von dieser Policy nicht ausgeliefert) +ALTER TABLE "TenderRssFeedSource" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "TenderRssFeedSource" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "TenderRssFeedSource" + USING ("tenantId" = current_tenant_id()); + +-- TenderSavedSearch — gespeicherte Suchprofile eines Nutzers +ALTER TABLE "TenderSavedSearch" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "TenderSavedSearch" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "TenderSavedSearch" + USING ("tenantId" = current_tenant_id()); + +-- TenderTriage — Favorisierungs-/Ablehnungsstatus eines Nutzers je Treffer +ALTER TABLE "TenderTriage" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "TenderTriage" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "TenderTriage" + USING ("tenantId" = current_tenant_id()); + +-- WidgetInstance — platzierte Dashboard-Widgets eines Nutzers +ALTER TABLE "WidgetInstance" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "WidgetInstance" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "WidgetInstance" + USING ("tenantId" = current_tenant_id()); diff --git a/apps/api/src/prisma/rls-coverage.spec.ts b/apps/api/src/prisma/rls-coverage.spec.ts new file mode 100644 index 0000000..590d2b0 --- /dev/null +++ b/apps/api/src/prisma/rls-coverage.spec.ts @@ -0,0 +1,137 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * Misst die RLS-Abdeckung aus dem tatsaechlichen Schema und den + * tatsaechlichen Migrationen, statt Text zu vergleichen — bleibt dadurch + * auch fuer kuenftige Modelle gueltig (Task 4, WINDOWS #18). + */ + +const SCHEMA_PATH = join(__dirname, '../../prisma/schema.prisma'); +const MIGRATIONS_DIR = join(__dirname, '../../prisma/migrations'); + +interface ModelInfo { + name: string; + hasTenantId: boolean; +} + +function parseModels(): ModelInfo[] { + const schema = readFileSync(SCHEMA_PATH, 'utf-8'); + const modelRegex = /model\s+(\w+)\s*\{([^}]*)\}/gs; + const models: ModelInfo[] = []; + let match: RegExpExecArray | null; + + while ((match = modelRegex.exec(schema))) { + const [, name, body] = match; + const hasTenantId = /^\s*tenantId\s+String/m.test(body); + models.push({ name, hasTenantId }); + } + + return models; +} + +function readAllMigrationSql(): string { + const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + + return dirs + .map((dir) => { + try { + return readFileSync(join(MIGRATIONS_DIR, dir, 'migration.sql'), 'utf-8'); + } catch { + return ''; + } + }) + .join('\n'); +} + +function readMigrationSql(suffix: string): string { + const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.endsWith(suffix)) + .map((entry) => entry.name); + + if (dirs.length !== 1) { + throw new Error( + `Expected exactly one migration directory ending in "${suffix}", found ${dirs.length}: ${dirs.join(', ')}`, + ); + } + + return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8'); +} + +function tablesWithRlsEnabled(sql: string): Set { + const result = new Set(); + const re = /ALTER TABLE "(\w+)" ENABLE ROW LEVEL SECURITY/g; + let m: RegExpExecArray | null; + while ((m = re.exec(sql))) result.add(m[1]); + return result; +} + +function tablesWithPolicy(sql: string): Set { + const result = new Set(); + const re = /CREATE POLICY \w+ ON "(\w+)"/g; + let m: RegExpExecArray | null; + while ((m = re.exec(sql))) result.add(m[1]); + return result; +} + +// Test 3: die feste Ausnahmeliste der Modelle ohne tenantId, mit Begruendung. +const JOIN_PATTERN_TABLES: Record = { + PasswordResetToken: 'geschuetzt ueber Join userId -> User -> tenantId', + LdapFieldMapping: 'geschuetzt ueber Join ldapConfigId -> LdapConfig -> tenantId', + GroupMembership: 'geschuetzt ueber Join groupId -> Group -> tenantId', +}; + +const DELIBERATELY_EXCLUDED_TABLES: Record = { + Tenant: 'die Mandantentabelle selbst — eine Regel wuerde die Aufloesung des Mandanten verhindern', + Module: 'plattformweiter Modulkatalog — mandantenbezogene Zuordnung liegt in TenantModuleActivation', + Tender: 'plattformweite Ausschreibungs-Bezugsdaten (D-03, Phase 10)', + TenderSource: 'plattformweite Ausschreibungs-Bezugsdaten (D-03, Phase 10)', + TenderSourcePollConfig: 'plattformweite Ausschreibungs-Bezugsdaten (D-03, Phase 10)', +}; + +describe('RLS-Abdeckung aller Modelle mit tenantId (WINDOWS #18, T-DGJ-02)', () => { + const models = parseModels(); + const allSql = readAllMigrationSql(); + const enabledTables = tablesWithRlsEnabled(allSql); + const policyTables = tablesWithPolicy(allSql); + + it('Test 1: jedes Modell mit tenantId hat RLS eingeschaltet', () => { + const tenantModels = models.filter((m) => m.hasTenantId).map((m) => m.name); + const missing = tenantModels.filter((name) => !enabledTables.has(name)).sort(); + expect(missing, `Fehlende RLS-Aktivierung: ${missing.join(', ')}`).toEqual([]); + }); + + it('Test 2: jede Tabelle mit eingeschaltetem RLS hat mindestens eine Policy', () => { + const missing = [...enabledTables].filter((name) => !policyTables.has(name)).sort(); + expect(missing, `RLS ohne Policy (sperrt jede Zeile aus): ${missing.join(', ')}`).toEqual([]); + }); + + it('Test 3: Modelle ohne tenantId zerfallen genau in Join-Muster (3) und bewusste Ausnahmen (5)', () => { + const nonTenantModels = models.filter((m) => !m.hasTenantId).map((m) => m.name).sort(); + const expected = [...Object.keys(JOIN_PATTERN_TABLES), ...Object.keys(DELIBERATELY_EXCLUDED_TABLES)].sort(); + expect(nonTenantModels, 'Neues Modell ohne tenantId gefunden — erzwingt eine bewusste Entscheidung').toEqual( + expected, + ); + }); + + it('Test 4: die neue Migration nennt jede der fuenf Ausnahmen namentlich in ihrem Kopf', () => { + const sql = readMigrationSql('_rls_remaining_tenant_tables'); + for (const name of Object.keys(DELIBERATELY_EXCLUDED_TABLES)) { + expect(sql, `Ausnahme ${name} fehlt im Migrationskopf`).toContain(name); + } + }); + + it('Test 5: die neue Migration schaltet fuer alle 16 Tabellen ENABLE und FORCE ein und legt genau 16 Policies an', () => { + const sql = readMigrationSql('_rls_remaining_tenant_tables'); + const enableCount = (sql.match(/ENABLE ROW LEVEL SECURITY/g) ?? []).length; + const forceCount = (sql.match(/FORCE ROW LEVEL SECURITY/g) ?? []).length; + const policyCount = (sql.match(/CREATE POLICY/g) ?? []).length; + + expect(enableCount).toBe(16); + expect(forceCount).toBe(16); + expect(policyCount).toBe(16); + }); +});