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:
2026-09-09 10:05:15 +02:00
parent 44a90e5bfd
commit efaabc97a2
2 changed files with 293 additions and 0 deletions
+137
View File
@@ -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);
});
});