feat(quick-260909-dgj): Policies fuer die 16 fehlenden Tabellen (Task 4/4)
- Migration 20260909140000_rls_remaining_tenant_tables ergaenzt ENABLE + FORCE ROW LEVEL SECURITY und je eine tenant_isolation_policy fuer alle 16 noch offenen Tabellen mit tenantId - Kopfkommentar korrigiert die zu pauschale D-03-Aussage aus 20260804130918: nur die drei Tender-Tabellen OHNE tenantId sind davon betroffen, die sechs MIT tenantId bekommen jetzt eine Policy — die alte Migrationsdatei bleibt unveraendert - rls-coverage.spec.ts misst die Abdeckung aus Schema und Migrationen statt Text zu vergleichen (rot mit 16 gemeldeten Luecken vor der Migration, jetzt gruen); waechst automatisch mit kuenftigen Modellen und erzwingt bei jedem neuen tenantId-losen Modell eine bewusste Entscheidung Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
This commit is contained in:
@@ -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());
|
||||||
@@ -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<string> {
|
||||||
|
const result = new Set<string>();
|
||||||
|
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<string> {
|
||||||
|
const result = new Set<string>();
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user