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
@@ -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 },
});