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:
@@ -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');
|
||||
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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]);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TenderSavedSearchService } from './tender-saved-search.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
|
||||
/**
|
||||
* TenderSavedSearchService.spec — RED-first (TDD) proof for FILTER-06
|
||||
@@ -311,4 +312,48 @@ describe('TenderSavedSearchService', () => {
|
||||
expectBoundCall(prisma, 't1', 'delete');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Benutzerdimension (Etappe 3b, 260911-nke): forTenant() bekommt den
|
||||
// Benutzer als drittes Argument — je Methode mindestens ein dreistelliger
|
||||
// Aufruf festgenagelt, damit ein vergessenes drittes Argument den Test
|
||||
// bricht statt still zu verschwinden.
|
||||
describe('Benutzerdimension: forTenant() bekommt userId als drittes Argument (260911-nke)', () => {
|
||||
it('list() ruft forTenant(prisma, tenantId, userId) auf', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderSavedSearchService(prisma as any);
|
||||
|
||||
await service.list('u1', 't1');
|
||||
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1');
|
||||
});
|
||||
|
||||
it('create() ruft forTenant(prisma, tenantId, userId) auf', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderSavedSearchService(prisma as any);
|
||||
|
||||
await service.create('u1', 't1', { name: 'A', filters: {} });
|
||||
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1');
|
||||
});
|
||||
|
||||
it('update() ruft forTenant(prisma, tenantId, userId) auf', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderSavedSearchService(prisma as any);
|
||||
const created = await service.create('u1', 't1', { name: 'A', filters: {} });
|
||||
|
||||
await service.update(created.id, 'u1', 't1', { name: 'B' });
|
||||
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1');
|
||||
});
|
||||
|
||||
it('remove() ruft forTenant(prisma, tenantId, userId) auf', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderSavedSearchService(prisma as any);
|
||||
const created = await service.create('u1', 't1', { name: 'A', filters: {} });
|
||||
|
||||
await service.remove(created.id, 'u1', 't1');
|
||||
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,12 @@ import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.d
|
||||
* method call, never shared across methods (same convention as
|
||||
* `groups.service.ts`).
|
||||
*
|
||||
* Benutzerdimension seit 20260911120000 (Etappe 3b, 260911-nke): every
|
||||
* `forTenant()` call above also passes `userId` as the third argument, so
|
||||
* the database-level `tenant_isolation_policy` on TenderSavedSearch now
|
||||
* ALSO enforces `userId = current_user_id()` — a second net alongside the
|
||||
* application-level scoping above, which stays exactly as it was.
|
||||
*
|
||||
* @@unique([userId, name]) (T-11-14): a second profile with the same name
|
||||
* for the same user is rejected by Postgres (P2002) — this service
|
||||
* translates that into a 409 ConflictException so the frontend can show a
|
||||
@@ -38,7 +44,7 @@ export class TenderSavedSearchService {
|
||||
* strictly by userId (V4/IDOR) — a foreign userId sees nothing.
|
||||
*/
|
||||
async list(userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
return tenantPrisma.tenderSavedSearch.findMany({
|
||||
where: { userId },
|
||||
orderBy: { name: 'asc' },
|
||||
@@ -52,7 +58,7 @@ export class TenderSavedSearchService {
|
||||
* users, since the uniqueness is scoped per-user.
|
||||
*/
|
||||
async create(userId: string, tenantId: string, dto: CreateSavedSearchDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
try {
|
||||
return await tenantPrisma.tenderSavedSearch.create({
|
||||
data: {
|
||||
@@ -81,7 +87,7 @@ export class TenderSavedSearchService {
|
||||
* leaking whether another user's profile exists).
|
||||
*/
|
||||
async update(id: string, userId: string, tenantId: string, dto: UpdateSavedSearchDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const existing = await tenantPrisma.tenderSavedSearch.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
@@ -118,7 +124,7 @@ export class TenderSavedSearchService {
|
||||
* update().
|
||||
*/
|
||||
async remove(id: string, userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const existing = await tenantPrisma.tenderSavedSearch.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user