fix(quick-260909-eor): forTenant() bindet Mandantenkontext auf dieselbe Verbindung

WINDOWS #20: set_config() lief auf einer anderen Postgres-Verbindung als die
eigentliche Abfrage, weil die interaktive Callback-Form von $transaction
verwendet wurde. Ersetzt durch die Array-Form, die set_config und Abfrage als
eine Transaktion auf einer Verbindung ausfuehrt (Prismas empfohlenes Muster
fuer RLS-ueber-Extensions). Injektionsfestigkeit (T-02-05) bleibt ueber ein
getaggtes $executeRaw-Template statt $executeRawUnsafe erhalten.

- prisma-tenant.extension.spec.ts: prueft die Form des Aufrufs (Array mit
  zwei Eintraegen, Rueckgabewert ist der zweite Eintrag) ohne laufende
  Datenbank
- rls-scratch-check.mjs: neues Werkzeug, das eine Wegwerf-Datenbank anlegt
  und live misst — gleiche Backend-Verbindung, gesetzter Kontext, keine
  Fremdmandanten-Zeilen, keine Zeilen ohne Kontext. Alle 5 Pruefungen
  bestehen gegen die lokale Datenbank.

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:50:37 +02:00
parent d0393ac4af
commit bbf179503c
3 changed files with 414 additions and 9 deletions
@@ -0,0 +1,116 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { forTenant } from './prisma-tenant.extension';
/**
* Prueft ohne laufende Datenbank die FORM des Aufrufs, nicht seinen mit
* Produktionscode erzeugten Inhalt (WINDOWS #20, Lehre aus dem
* tautologischen Test in STATE.md — Pitfall "Tautologischer Test"): ein
* vorgetaeuschter Client zeichnet auf, dass `$transaction` mit einem Feld
* aus zwei Eintraegen aufgerufen wird, dass der Rueckgabewert der zweite
* Eintrag ist, und dass `query` waehrend des Aufbaus dieses Feldes genau
* einmal beruehrt wird.
*/
const EXTENSION_SOURCE_PATH = join(__dirname, 'prisma-tenant.extension.ts');
/**
* Der Kopfkommentar der Datei erklaert absichtlich das ALTE, defekte
* Verhalten (inklusive $executeRawUnsafe und der interaktiven
* $transaction(async ...)-Form) als Beleg fuer die Reparatur. Eine
* Volltextpruefung auf den Quelltext wuerde deshalb faelschlich fehlschlagen
* — Block- und Zeilenkommentare muessen vor der Pruefung des tatsaechlichen
* Codes herausgefiltert werden (gleiches Vorgehen wie in
* auth-lookup-functions.spec.ts fuer die Migrations-SQL).
*/
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
}
describe('forTenant() — Array-Form von $transaction (WINDOWS #20)', () => {
it('ruft $transaction mit einem Feld aus genau zwei Eintraegen auf', async () => {
const transactionCalls: unknown[] = [];
const fakeQueryResult = { id: 'row-1' };
const fakePrisma: any = {
$transaction: vi.fn((arg: unknown) => {
transactionCalls.push(arg);
expect(Array.isArray(arg)).toBe(true);
expect((arg as unknown[]).length).toBe(2);
return Promise.resolve(['set_config-result', fakeQueryResult]);
}),
$extends: (config: any) => {
// Reproduziert nur den Teil der echten $extends-API, den
// $allOperations braucht — kein echter Prisma-Client noetig.
return {
async __invoke(args: unknown, query: (args: unknown) => unknown) {
return config.query.$allOperations({ args, query });
},
};
},
$executeRaw: vi.fn((_strings: TemplateStringsArray, ..._values: unknown[]) => {
return 'set-config-promise';
}),
};
const scoped = forTenant(fakePrisma, 'tenant-a') as any;
let queryCallCount = 0;
const query = (args: unknown) => {
queryCallCount += 1;
return fakeQueryResult;
};
const result = await scoped.__invoke({ where: { id: 'row-1' } }, query);
expect(fakePrisma.$transaction).toHaveBeenCalledTimes(1);
expect(transactionCalls).toHaveLength(1);
expect((transactionCalls[0] as unknown[]).length).toBe(2);
// Rueckgabewert ist der ZWEITE Eintrag des Felds (der Query-Aufruf),
// nicht das Ergebnis von set_config.
expect(result).toBe(fakeQueryResult);
// query() wurde beim Aufbau des Felds genau einmal beruehrt.
expect(queryCallCount).toBe(1);
});
it('setzt den Mandantenkontext ueber ein getaggtes $executeRaw-Template, nicht ueber zusammengebauten Text', async () => {
const fakePrisma: any = {
$transaction: vi.fn((arg: unknown) => 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[]) => {
// Ein getaggtes Template liefert ein Array von Textstuecken plus die
// interpolierten Werte getrennt — genau das ist der Beleg dafuer,
// dass der Mandantenwert parametrisiert und nicht in den SQL-Text
// eingebaut wird.
expect(Array.isArray(strings)).toBe(true);
expect(values).toContain('tenant-with-quote-\' OR 1=1');
return 'set-config-promise';
}),
};
const scoped = forTenant(fakePrisma, "tenant-with-quote-' OR 1=1") as any;
await scoped.__invoke({}, () => 'query-result');
expect(fakePrisma.$executeRaw).toHaveBeenCalledTimes(1);
});
it('verwendet im tatsaechlichen Code (ohne Kommentare) kein $executeRawUnsafe mehr', () => {
const source = stripComments(readFileSync(EXTENSION_SOURCE_PATH, 'utf-8'));
expect(source).not.toContain('$executeRawUnsafe');
});
it('nutzt im tatsaechlichen Code die Array-Form von $transaction (kein interaktiver async-Callback)', () => {
const source = stripComments(readFileSync(EXTENSION_SOURCE_PATH, 'utf-8'));
expect(source).toMatch(/\$transaction\(\s*\[/);
expect(source).not.toMatch(/\$transaction\(\s*async/);
});
});
+57 -9
View File
@@ -4,20 +4,68 @@ import { PrismaClient } from '@prisma/client';
* Creates a tenant-scoped Prisma client that sets the app.current_tenant
* PostgreSQL session variable before every query via RLS.
*
* Uses parameterized set_config to prevent SQL injection (T-02-05).
* WARUM DIE ARRAY-FORM VON $transaction PFLICHT IST (WINDOWS #20, gemessen
* 2026-09-09, siehe .planning/quick/260909-eor-.../260909-eor-PLAN.md):
*
* Die vorherige Fassung nutzte die INTERAKTIVE Callback-Form
* (`prisma.$transaction(async (tx) => { await tx.$executeRawUnsafe(...); return query(args); })`)
* und rief `query(args)` — die eigentliche Datenbankoperation — auf dem
* AEUSSEREN `prisma`-Client auf, nicht auf `tx`. Ein Nachbau dieses exakten
* Musters gegen die lokale Datenbank ergab:
*
* inside tx : {"pid":254999,"t":"TENANT-A"}
* actual qry : {"pid":255000,"t":null}
* SAME CONNECTION? false
*
* `set_config('app.current_tenant', ..., true)` mit `local=true` gilt nur
* transaktions- UND verbindungslokal. Die interaktive Callback-Form haelt
* fuer `tx` eine eigene Verbindung; `query(args)` lief auf einer ANDEREN,
* unter Postgres-Poolern austauschbaren Verbindung und sah den Kontext nie.
* Unter einer Rolle ohne BYPASSRLS waere die Folge nicht "zu viele Zeilen",
* sondern NULL Zeilen — die Policy vergleicht gegen NULL.
*
* Die Array-Form (`prisma.$transaction([a, b])`) fuehrt alle Eintraege als
* EINE Transaktion auf EINER Verbindung aus — das ist das von Prisma selbst
* fuer RLS-ueber-Extensions vorgesehene Muster. `query(args)` sieht damit
* denselben Kontext, den `set_config` unmittelbar zuvor auf derselben
* Verbindung gesetzt hat.
*
* GRENZFAELLE — benannter Vorbehalt fuer Etappe 2 (nicht stillschweigend
* uebergangen, siehe 260909-eor-SUMMARY.md):
*
* - Ruft aufrufender Code selbst `$transaction` auf einem mit `forTenant()`
* gebundenen Client auf: `$transaction` ist keine Modell-Operation und
* laeuft NICHT durch `$allOperations`. Der Mandantenkontext wird in einem
* solchen Fall nicht automatisch gesetzt — jede einzelne im
* `$transaction`-Array enthaltene Modell-Operation dispatcht zwar durch
* `$allOperations` (weil sie auf dem extended Client aufgerufen wird) und
* bekommt dadurch ihre EIGENE Ein-Element-Transaktion mit eigenem
* `set_config` — mehrere solche Operationen liefen dann aber auf
* MEHREREN Teiltransaktionen statt einer gemeinsamen, was Atomaritaet
* ueber die gesamte aeussere Transaktion hinweg verletzen kann. Heute
* ruft kein `forTenant()`-Aufrufer eine verschachtelte `$transaction` auf
* (gemessen: alle 9 tatsaechlichen mandantengebundenen Abfragen in
* `ldap.service.ts` sind Einzeloperationen) — vor jedem neuen
* `forTenant()`-Aufruf mit eigener Transaktion in Etappe 2 erneut pruefen.
* - `$queryRaw`/`$executeRaw` DIREKT auf dem gebundenen Client laufen
* weiterhin normal durch `$allOperations` (Prisma behandelt sie wie jede
* andere Operation) und werden daher korrekt an dieselbe Verbindung
* gebunden wie `set_config`.
*
* Parametrisiert ueber ein getaggtes `$executeRaw`-Template (kein
* `$executeRawUnsafe` mit zusammengebautem Text mehr) — die
* Injektionsfestigkeit aus T-02-05 bleibt beim Umbau erhalten.
*/
export function forTenant(prisma: PrismaClient, tenantId: string) {
return prisma.$extends({
query: {
$allOperations({ args, query }: { args: any; query: (args: any) => any }) {
return (prisma as any).$transaction(async (tx: any) => {
// Use parameterized query to avoid SQL injection
await tx.$executeRawUnsafe(
`SELECT set_config('app.current_tenant', $1, true)`,
tenantId,
);
return query(args);
});
const setTenantContext = (prisma as any)
.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`;
return (prisma as any)
.$transaction([setTenantContext, query(args)])
.then((results: any[]) => results[1]);
},
},
});