feat(quick-260911-nke): current_user_id(), Benutzerdimension in den Regeln, forTenant() mit userId — ein Pfad

- Neue Migration 20260911120000_rls_user_dimension_personal_tables: current_user_id()
  (NULLIF-gefaltet), zehn persoenliche Tabellen umgestellt (acht als eine Regel,
  SearchProvider/TenderRssFeedSource als je vier befehlsgetrennte Regeln), vier
  Verwaltungstabellen bewusst unveraendert. Lokal angewendet (migrate deploy,
  Prisma-Binary aus apps/api/node_modules/.bin), schema.prisma unveraendert.
- forTenant(prisma, tenantId, userId?): beide set_config in EINER getaggten
  Anweisung, $transaction-Array bleibt bei zwei Eintraegen (WINDOWS #20),
  Leerstring ohne Benutzer statt Weglassen.
- tender-saved-search.service.ts: alle vier forTenant()-Aufrufe reichen userId
  durch; Detektor-Regex bestaetigt 4 Treffer.
- rls-scratch-check.mjs: current_user_id() aus der neuen Migration geschnitten
  (nicht getippt), drei Funktionsfaelle gemessen, neue runUserDimensionChecks()
  mit generiertem Client fuer TenderSavedSearch (vier Wahrheiten + Spaltenabgleich),
  die alte Loch-Pruefung tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar
  umgedreht (alte Messung unter neuem Namen erhalten, neue Umkehrung MIT Benutzer).
  sqlStateOf() um Message-Fallback ergaenzt (RLS-Ablehnung ueber generierten
  Client traegt den SQLSTATE nur im Fehlertext, nicht in .meta.code).
- Baseline: 1020/62 Tests, Typpruefung sauber, Werkzeug 146/146 bestanden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-11 17:25:45 +02:00
parent 3e57d916a1
commit f0b531b712
7 changed files with 800 additions and 27 deletions
+82
View File
@@ -173,6 +173,88 @@ describe('rls_widen_membership_grant_and_platform_read migration.sql (T-JTS-02,
});
});
describe('rls_user_dimension_personal_tables migration.sql (Etappe 3b, 260911-nke)', () => {
const sql = readMigrationSql('_rls_user_dimension_personal_tables');
const PERSONAL_TABLES = [
'CalendarSource',
'DashboardLayout',
'FavoriteLink',
'SearchProvider',
'TenderEmailConfig',
'TenderNotificationPref',
'TenderRssFeedSource',
'TenderSavedSearch',
'TenderTriage',
'WidgetInstance',
];
const EXCLUDED_TABLES = ['GroupMembership', 'ModuleGrant', 'PasswordResetToken', 'TenderMatch'];
function nonCommentLines(source: string): string {
return source
.split('\n')
.filter((line) => !line.trim().startsWith('--'))
.join('\n');
}
it('legt current_user_id() mit NULLIF an', () => {
expect(sql).toContain('CREATE OR REPLACE FUNCTION current_user_id() RETURNS TEXT AS $$');
expect(sql).toContain("NULLIF(current_setting('app.current_user', true), '')");
});
it('nennt fuer jede der zehn persoenlichen Tabellen mindestens eine CREATE POLICY-Anweisung', () => {
for (const table of PERSONAL_TABLES) {
expect(sql).toMatch(new RegExp(`CREATE POLICY [\\w]+ ON "${table}"`));
}
});
it('jede CREATE-POLICY-Anweisung der zehn Tabellen enthaelt current_user_id() IS NULL OR', () => {
for (const table of PERSONAL_TABLES) {
const re = /CREATE POLICY [\w]+[\s\S]*?ON "([A-Za-z]+)"[\s\S]*?;/g;
let match: RegExpExecArray | null;
let found = 0;
while ((match = re.exec(sql)) !== null) {
if (match[1] !== table) continue;
found += 1;
expect(match[0].replace(/\s+/g, ' ')).toContain('current_user_id() IS NULL OR');
}
expect(found).toBeGreaterThan(0);
}
});
it('legt genau 8 DROP POLICY tenant_isolation_policy auf den NOT-NULL-Tabellen, einen weiteren auf SearchProvider, und 4 DROPs auf TenderRssFeedSource an', () => {
const NOT_NULL_TABLES = [
'CalendarSource',
'DashboardLayout',
'FavoriteLink',
'TenderEmailConfig',
'TenderNotificationPref',
'TenderSavedSearch',
'TenderTriage',
'WidgetInstance',
];
const dropIsolationOnNotNullTables = NOT_NULL_TABLES.filter((table) =>
sql.includes(`DROP POLICY tenant_isolation_policy ON "${table}"`),
).length;
expect(dropIsolationOnNotNullTables).toBe(8);
expect(sql).toContain('DROP POLICY tenant_isolation_policy ON "SearchProvider"');
const dropRssFeed = (sql.match(/DROP POLICY \w+ ON "TenderRssFeedSource"/g) ?? []).length;
expect(dropRssFeed).toBe(4);
});
it('nennt die vier Ausnahmen namentlich im Kopf', () => {
for (const table of EXCLUDED_TABLES) {
expect(sql).toContain(table);
}
});
it('enthaelt KEINE Anweisung auf GroupMembership/ModuleGrant/PasswordResetToken/TenderMatch (ausserhalb von Kommentaren)', () => {
const codeOnly = nonCommentLines(sql);
for (const table of EXCLUDED_TABLES) {
expect(codeOnly).not.toMatch(new RegExp(`(DROP|CREATE) POLICY [\\w ]*ON "${table}"`));
}
});
});
describe('add_group_internal_name_and_object_guid migration.sql (D-04)', () => {
const sql = readMigrationSql('_add_group_internal_name_and_object_guid');