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
@@ -132,6 +132,73 @@ describe('forTenant() — Array-Form von $transaction (WINDOWS #20)', () => {
expect(forTenantSource).toMatch(/\$transaction\(\s*\[/);
expect(forTenantSource).not.toMatch(/\$transaction\(\s*async/);
});
// Benutzerdimension (Etappe 3b, 260911-nke): drei neue Tests fuer den
// optionalen dritten Parameter `userId`.
it('ohne userId: die Parameterliste des Templates enthaelt den Leerstring an zweiter Stelle, der Template-Text nennt app.current_user', async () => {
const fakePrisma: any = {
$transaction: vi.fn(() => Promise.resolve(['set-config-result', 'query-result'])),
$extends: (config: any) => ({
async __invoke(args: unknown, query: (args: unknown) => unknown) {
return config.query.$allOperations({ args, query });
},
}),
$executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
expect(strings.join('')).toContain('app.current_user');
expect(values[1]).toBe('');
return 'set-config-promise';
}),
};
const scoped = forTenant(fakePrisma, 'tenant-a') as any;
await scoped.__invoke({}, () => 'query-result');
expect(fakePrisma.$executeRaw).toHaveBeenCalledTimes(1);
});
it('mit userId: der Wert geht als Template-PARAMETER (values), nicht im Text (T-02-05 bleibt gewahrt)', async () => {
const fakePrisma: any = {
$transaction: vi.fn(() => Promise.resolve(['set-config-result', 'query-result'])),
$extends: (config: any) => ({
async __invoke(args: unknown, query: (args: unknown) => unknown) {
return config.query.$allOperations({ args, query });
},
}),
$executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
expect(Array.isArray(strings)).toBe(true);
expect(strings.join('')).not.toContain("user-with-quote-' OR 1=1");
expect(values).toContain("user-with-quote-' OR 1=1");
return 'set-config-promise';
}),
};
const scoped = forTenant(fakePrisma, 'tenant-a', "user-with-quote-' OR 1=1") as any;
await scoped.__invoke({}, () => 'query-result');
expect(fakePrisma.$executeRaw).toHaveBeenCalledTimes(1);
});
it('mit userId: das $transaction-Feld behaelt weiterhin genau zwei Eintraege', async () => {
const transactionCalls: unknown[] = [];
const fakePrisma: any = {
$transaction: vi.fn((arg: unknown) => {
transactionCalls.push(arg);
return Promise.resolve(['set-config-result', 'query-result']);
}),
$extends: (config: any) => ({
async __invoke(args: unknown, query: (args: unknown) => unknown) {
return config.query.$allOperations({ args, query });
},
}),
$executeRaw: vi.fn(() => 'set-config-promise'),
};
const scoped = forTenant(fakePrisma, 'tenant-a', 'user-a') as any;
await scoped.__invoke({}, () => 'query-result');
expect(transactionCalls).toHaveLength(1);
expect((transactionCalls[0] as unknown[]).length).toBe(2);
});
});
describe('withTenantTransaction() — interaktive Callback-Form auf dem UNgebundenen Client (260909-jts, Aufgabe 1)', () => {
+43 -4
View File
@@ -107,16 +107,55 @@ import { PrismaClient } from '@prisma/client';
* Transaktion gilt weiterhin: vor jedem neuen Fall erneut pruefen, nicht
* von hier abschreiben — eine andere Lastform oder ein anderer Pool koennte
* ein anderes Ergebnis liefern.
*
* BENUTZERDIMENSION (Etappe 3b, 260911-nke):
*
* `forTenant()` bekommt einen OPTIONALEN dritten Parameter `userId` statt
* eines Schwesterhelfers (`forTenantAndUser()`). Grund: der Detektor der
* Bestandsaufnahme (`rls-access-inventory.spec.ts`) erkennt gebundene
* Aufrufstellen ueber den Regex `const X = forTenant(` — ein anders
* benannter Schwesterhelfer waere fuer ihn UNSICHTBAR, jeder damit
* gebundene Zugriff wuerde faelschlich als ungebunden gezaehlt. Ein
* dritter Parameter aendert am Match des Regex nichts, weil er nur den
* Funktionsnamen und das oeffnende `(` prueft. Praezedenz fuer "Helfer
* erweitern statt zweiten bauen": `withTenantTransaction()` oben, das
* ebenfalls keinen Zwilling bekam.
*
* Ohne `userId` wird `app.current_user` auf den LEERSTRING gesetzt, nicht
* weggelassen. Grund: `set_config(..., true)` gilt nur transaktionslokal
* (siehe WINDOWS-#20-Herleitung oben) — ein Aufruf ohne Benutzer koennte
* sonst theoretisch einen Benutzer aus einer fruaheren Transaktion
* DERSELBEN Verbindung erben, sollte spaeter jemand `local=false`
* einfuehren. Der Leerstring schliesst das aus. `current_user_id()`
* (neue Migration 20260911120000) faltet den Leerstring per `NULLIF` auf
* NULL — die Regeln der zehn persoenlichen Tabellen behandeln "ungesetzt"
* und "leer" dadurch gleich.
*
* Beide `set_config`-Aufrufe stehen in EINER getaggten Anweisung
* (kommasepariert) — das `$transaction`-Array behaelt weiterhin GENAU ZWEI
* Eintraege (Kontext-Anweisung, eigentliche Abfrage), das WINDOWS-#20-Muster
* bleibt unangetastet.
*
* Wer den Benutzer setzt: NUR Nutzer-CRUD-Aufrufer (die zehn persoenlichen
* Tabellen betreffende Methoden in calendar/dashboard/favorites/tenders).
* Hintergrunddienste (`tender-digest.scheduler.ts`) und Verwaltungswege
* (ldap, groups, user, tenant, auth, dkv, module-registry) rufen weiterhin
* OHNE Benutzer — die `IS NULL OR`-Form der Regeln macht das zu einer
* bewussten Eigenschaft (Admin/Hintergrunddienst sieht den ganzen
* Mandanten), nicht zu einer Luecke. `withTenantTransaction()` bekommt
* KEINEN dritten Parameter: kein Nutzer-CRUD-Aufrufer nutzt diese Funktion
* (nur `groups`, ein Verwaltungsweg) — ein unbenutzter Parameter waere
* Spekulation ohne heutigen Aufrufer.
*/
export function forTenant(prisma: PrismaClient, tenantId: string) {
export function forTenant(prisma: PrismaClient, tenantId: string, userId?: string) {
return prisma.$extends({
query: {
$allOperations({ args, query }: { args: any; query: (args: any) => any }) {
const setTenantContext = (prisma as any)
.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`;
const setContext = (prisma as any)
.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true)`;
return (prisma as any)
.$transaction([setTenantContext, query(args)])
.$transaction([setContext, query(args)])
.then((results: any[]) => results[1]);
},
},