From 9c518238f5f089f314328266039445db022c4b77 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 23 Sep 2026 07:56:38 +0200 Subject: [PATCH] feat(quick-260923-ad9): Datenmodell, Migration und Reiter-Grundlage - Task 1 Neues Modell Dashboard (D-01/D-02/D-09): position statt Standard-Feld, kein Unique auf (userId, position) - Umsortieren schreibt spaeter alle Positionen einer Transaktion neu. WidgetInstance/DashboardLayout haengen jetzt am Reiter statt am Benutzer (DashboardLayout.dashboardId @unique ersetzt userId @unique). Migration 20260923120000_dashboard_tabs: Zeilenschutz mit Mandant- UND Benutzerdimension (Form 20260911120000/20260921120000), Bestands- uebernahme fuer jeden Benutzer mit Kacheln oder Anordnung VOR den Fremdschluesseln (D-03) - gemessen: 0 Kacheln/Anordnungen ohne Reiter, genau 2 Reiter auf Position 0. dashboard.service.ts: listDashboards() (Transaktionssperre gegen doppelte Erstanlage, T-AD9-07), Riegel assertOwnedDashboard() (fail- closed gegen fremde Reiter, T-AD9-01/02/03) - getLayout/saveLayout/ getWidgets/addWidget laufen jetzt ueber dashboardId statt userId. GET /dashboard/tabs neu; die vier bestehenden Wege reichen die Reiter- Kennung durch. Verhalten fuer den Benutzer unveraendert (ein Reiter, wie bisher) - Task 2 ergaenzt Anlegen/Umbenennen/Loeschen/Umsortieren. dashboard.service.spec.ts: 43 Tests (31 alte unveraendert + 12 neue fuer Reiter-Anlage, -Reihenfolge und den Fremdreiter-Riegel bei allen vier Wegen). Zugriffsklassifikation nachgerechnet: 75 Paare (+1), Bereich dashboard 21->24 gebunden. Co-Authored-By: Claude Sonnet 5 --- .../migration.sql | 113 ++++++ apps/api/prisma/schema.prisma | 48 ++- .../api/src/dashboard/dashboard.controller.ts | 36 +- .../src/dashboard/dashboard.service.spec.ts | 337 +++++++++++++++--- apps/api/src/dashboard/dashboard.service.ts | 137 +++++-- .../src/dashboard/dto/create-widget.dto.ts | 8 +- apps/api/src/dashboard/dto/save-layout.dto.ts | 9 +- .../src/dashboard/widget-module-map.spec.ts | 5 +- ...andantentrennung-zugriffsklassifikation.md | 28 +- 9 files changed, 627 insertions(+), 94 deletions(-) create mode 100644 apps/api/prisma/migrations/20260923120000_dashboard_tabs/migration.sql diff --git a/apps/api/prisma/migrations/20260923120000_dashboard_tabs/migration.sql b/apps/api/prisma/migrations/20260923120000_dashboard_tabs/migration.sql new file mode 100644 index 0000000..e8dbc77 --- /dev/null +++ b/apps/api/prisma/migrations/20260923120000_dashboard_tabs/migration.sql @@ -0,0 +1,113 @@ +-- 260923-ad9 — Dashboard-Reiter: mehrere Dashboards je Benutzer. +-- +-- Zweck: das Dashboard traegt heute genau eine Kachelflaeche je Benutzer. +-- Diese Migration gibt jedem Benutzer mehrere Dashboards ("Reiter"), die +-- oben nebeneinander stehen: jeder Reiter mit eigenen Kacheln und eigener +-- Anordnung, per Ziehen umsortierbar. +-- +-- D-01: `position` (Integer) traegt die Reihenfolge, aufsteigend sortiert. +-- KEIN Unique auf (userId, position) — beim Umsortieren werden alle +-- Positionen eines Benutzers in EINER Transaktion neu geschrieben +-- (dashboard.service.ts, reorderDashboards, Muster FavoritesService.reorder); +-- ein Unique waere dabei nur im Weg. +-- +-- D-03: niemand verliert etwas. Fuer jeden Benutzer, der heute Kacheln ODER +-- eine gespeicherte Anordnung hat, entsteht genau EIN Dashboard mit +-- position = 0 und dem Namen "Dashboard"; vorhandene Kacheln und die +-- vorhandene Anordnung werden darauf umgehaengt. Diese Bestandsuebernahme +-- MUSS vor den Fremdschluesseln laufen, sonst scheitert sie an genau diesen +-- — deshalb steht sie unten vor den ALTER-TABLE-Schritten fuer +-- WidgetInstance/DashboardLayout. +-- +-- D-04: Zeilenschutz ist Pflicht. Die neue Tabelle traegt `tenantId` und +-- dieselbe Regel wie ihre Nachbarn — Mandant UND Benutzerdimension von +-- Anfang an (Form aus 20260911120000_rls_user_dimension_personal_tables, +-- uebernommen aus 20260921120000_dashboard_image). +-- +-- Rechte fuer die Anwendungsrolle tessera_app kommen ueber ALTER DEFAULT +-- PRIVILEGES aus 20260909130000_rls_app_role automatisch — hier nichts zu +-- tun. +-- +-- WICHTIG: wie alle bisherigen RLS-Migrationen wirkt die Regel erst, wenn +-- die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS, +-- siehe docs/mandantentrennung-datenbankrolle.md). + +-- 1) Tabelle Dashboard anlegen, Indizes auf userId und tenantId. +CREATE TABLE "Dashboard" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "position" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Dashboard_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "Dashboard_userId_idx" ON "Dashboard"("userId"); +CREATE INDEX "Dashboard_tenantId_idx" ON "Dashboard"("tenantId"); + +-- 2) Zeilenschutz: Mandant UND Benutzer (Muster 20260911120000/20260921120000). +ALTER TABLE "Dashboard" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "Dashboard" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "Dashboard" + USING ( + "tenantId" = current_tenant_id() + AND (current_user_id() IS NULL OR "userId" = current_user_id()) + ); + +-- 3) Bestandsuebernahme (D-03): je Benutzer aus der Vereinigung der +-- Benutzer mit Kacheln und der Benutzer mit gespeicherter Anordnung genau +-- EINE Zeile einfuegen. DISTINCT ON sichert "je Benutzer genau eine Zeile" +-- auch fuer den theoretischen Fall "derselbe Benutzer mit zwei +-- Mandantenkennungen" ab (deterministische Wahl ueber die Sortierung nach +-- tenantId als zweitem Kriterium). +INSERT INTO "Dashboard" ("id", "userId", "tenantId", "name", "position", "createdAt", "updatedAt") +SELECT gen_random_uuid(), bestand."userId", bestand."tenantId", 'Dashboard', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM ( + SELECT DISTINCT ON ("userId") "userId", "tenantId" + FROM ( + SELECT "userId", "tenantId" FROM "WidgetInstance" + UNION ALL + SELECT "userId", "tenantId" FROM "DashboardLayout" + ) AS vereinigung + ORDER BY "userId", "tenantId" +) AS bestand; + +-- 4) WidgetInstance.dashboardId: zunaechst NULLbar ergaenzen, aus der neuen +-- Tabelle ueber die Benutzerkennung befuellen (fuer jeden Benutzer mit +-- Kacheln existiert nach Schritt 3 GENAU ein Dashboard), dann NOT NULL, +-- Index, Fremdschluessel mit Loeschweitergabe. +ALTER TABLE "WidgetInstance" ADD COLUMN "dashboardId" TEXT; + +UPDATE "WidgetInstance" wi +SET "dashboardId" = d."id" +FROM "Dashboard" d +WHERE d."userId" = wi."userId"; + +ALTER TABLE "WidgetInstance" ALTER COLUMN "dashboardId" SET NOT NULL; +CREATE INDEX "WidgetInstance_dashboardId_idx" ON "WidgetInstance"("dashboardId"); +ALTER TABLE "WidgetInstance" ADD CONSTRAINT "WidgetInstance_dashboardId_fkey" + FOREIGN KEY ("dashboardId") REFERENCES "Dashboard"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- 5) DashboardLayout.dashboardId: dieselbe Uebernahme; zusaetzlich die +-- Eindeutigkeit auf userId entfernen (mehrere Reiter je Benutzer sind jetzt +-- erlaubt), dort einen gewoehnlichen Index anlegen, und die Eindeutigkeit +-- auf dashboardId anlegen (ein Reiter hat hoechstens eine gespeicherte +-- Anordnung). +ALTER TABLE "DashboardLayout" ADD COLUMN "dashboardId" TEXT; + +UPDATE "DashboardLayout" dl +SET "dashboardId" = d."id" +FROM "Dashboard" d +WHERE d."userId" = dl."userId"; + +ALTER TABLE "DashboardLayout" ALTER COLUMN "dashboardId" SET NOT NULL; + +DROP INDEX "DashboardLayout_userId_key"; +CREATE INDEX "DashboardLayout_userId_idx" ON "DashboardLayout"("userId"); + +CREATE UNIQUE INDEX "DashboardLayout_dashboardId_key" ON "DashboardLayout"("dashboardId"); +ALTER TABLE "DashboardLayout" ADD CONSTRAINT "DashboardLayout_dashboardId_fkey" + FOREIGN KEY ("dashboardId") REFERENCES "Dashboard"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index b2f2b1b..22bb910 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -187,14 +187,45 @@ model ModuleGrant { @@index([moduleId]) } -model DashboardLayout { - id String @id @default(uuid()) - userId String @unique +// Dashboard-Reiter (quick-260923-ad9, D-01/D-02/D-09): mehrere Dashboards je +// Benutzer, ueber `position` (Integer) aufsteigend sortiert. KEIN Unique auf +// (userId, position) — `reorderDashboards` (Muster FavoritesService.reorder) +// schreibt beim Umsortieren ALLE Positionen eines Benutzers in EINER +// Transaktion neu; ein Unique waere dabei nur im Weg (kollidiert waehrend +// des Umschreibens mit sich selbst). KEIN eigenes Standard-Feld: "als +// Favorit festlegen" IST das Nach-vorn-Ziehen (D-09) — Position 0 ist der +// Standard, es gibt keine zweite Wahrheit daneben. Keine Relation zu +// User/Tenant — Form der Nachbarmodelle WidgetInstance/DashboardImage (eine +// Relation zu User wuerde an Bestandszeilen verwaister Benutzer scheitern). +model Dashboard { + id String @id @default(uuid()) + userId String tenantId String - layouts Json @default("{}") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + name String + position Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + widgets WidgetInstance[] + layout DashboardLayout? + @@index([userId]) + @@index([tenantId]) +} + +model DashboardLayout { + id String @id @default(uuid()) + userId String + tenantId String + // quick-260923-ad9 (D-02): haengt jetzt am Dashboard statt am Benutzer — + // die Eindeutigkeit wandert von userId auf dashboardId, userId/tenantId + // bleiben fuer Besitz- und Mandantenpruefung erhalten. + dashboardId String @unique + dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade) + layouts Json @default("{}") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) @@index([tenantId]) } @@ -202,6 +233,10 @@ model WidgetInstance { id String @id @default(uuid()) userId String tenantId String + // quick-260923-ad9 (D-02): Kacheln haengen ab jetzt am Reiter, nicht mehr + // nur am Benutzer. + dashboardId String + dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade) widgetType String config Json @default("{}") createdAt DateTime @default(now()) @@ -210,6 +245,7 @@ model WidgetInstance { @@index([userId]) @@index([tenantId]) + @@index([dashboardId]) } // Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers. diff --git a/apps/api/src/dashboard/dashboard.controller.ts b/apps/api/src/dashboard/dashboard.controller.ts index 12185e4..3eedcbd 100644 --- a/apps/api/src/dashboard/dashboard.controller.ts +++ b/apps/api/src/dashboard/dashboard.controller.ts @@ -8,6 +8,7 @@ import { Patch, Post, Put, + Query, Req, } from '@nestjs/common'; import type { AuthenticatedRequest } from '../auth/types/auth-user'; @@ -25,10 +26,11 @@ import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto'; * and scopes all operations to the calling user (T-05-01, T-05-02). * * Routes: - * - GET /dashboard/layout — get user's saved layout - * - PUT /dashboard/layout — upsert user's layout - * - GET /dashboard/widgets — list user's widget instances - * - POST /dashboard/widgets — create a new widget instance + * - GET /dashboard/tabs — list the user's dashboard tabs (quick-260923-ad9) + * - GET /dashboard/layout — get the saved layout of one tab + * - PUT /dashboard/layout — upsert the layout of one tab + * - GET /dashboard/widgets — list the widget instances of one tab + * - POST /dashboard/widgets — create a new widget instance on one tab * - PATCH /dashboard/widgets/:id/config — update widget config * - DELETE /dashboard/widgets/:id — remove a widget instance * - GET /dashboard/search-providers — list default + user's custom providers @@ -66,10 +68,23 @@ export class DashboardController { return { userId: user.id, tenantId, role: user.role }; } - @Get('layout') - async getLayout(@Req() req: AuthenticatedRequest) { + /** + * Reiter des Benutzers (quick-260923-ad9), nach Position aufsteigend; + * legt beim ersten Aufruf genau einen an. + */ + @Get('tabs') + async listDashboards(@Req() req: AuthenticatedRequest) { const { userId, tenantId } = this.extractContext(req); - return this.dashboardService.getLayout(userId, tenantId); + return this.dashboardService.listDashboards(userId, tenantId); + } + + @Get('layout') + async getLayout( + @Req() req: AuthenticatedRequest, + @Query('dashboardId') dashboardId: string, + ) { + const { userId, tenantId } = this.extractContext(req); + return this.dashboardService.getLayout(userId, tenantId, dashboardId); } @Put('layout') @@ -79,9 +94,12 @@ export class DashboardController { } @Get('widgets') - async getWidgets(@Req() req: AuthenticatedRequest) { + async getWidgets( + @Req() req: AuthenticatedRequest, + @Query('dashboardId') dashboardId: string, + ) { const { userId, tenantId, role } = this.extractContext(req); - return this.dashboardService.getWidgets(userId, tenantId, role); + return this.dashboardService.getWidgets(userId, tenantId, role, dashboardId); } @Post('widgets') diff --git a/apps/api/src/dashboard/dashboard.service.spec.ts b/apps/api/src/dashboard/dashboard.service.spec.ts index d5c7874..48e1407 100644 --- a/apps/api/src/dashboard/dashboard.service.spec.ts +++ b/apps/api/src/dashboard/dashboard.service.spec.ts @@ -30,12 +30,20 @@ vi.mock('./widget-module-map', () => ({ * ungebundenen Klienten (Aufgabe 1, Befund E/H übernommen aus * `module-registry`): die Tabelle trägt heute keinen Zeilenschutz, eine * Bindung wäre heute wirkungslos. + * + * quick-260923-ad9 (Task 1): `withTenantTransaction` kommt zum Mock hinzu + * (Muster favorites.service.spec.ts) — sie reicht den gebundenen Klienten + * als `tx` durch und protokolliert den Aufruf. `listDashboards` nutzt sie + * fürs Anlegen des ersten Reiters unter einer Transaktionssperre. */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), + withTenantTransaction: vi.fn((prisma: any, tenantId: string, fn: any) => + prisma.__withTenantTransaction(tenantId, fn), + ), })); -import { ConflictException } from '@nestjs/common'; +import { ConflictException, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { DashboardService } from './dashboard.service'; @@ -43,14 +51,42 @@ import { DashboardService } from './dashboard.service'; /** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das * Herkunfts-Tenant protokollierenden Wrapper versieht. `module` ist bewusst * NICHT enthalten — der Katalogzugriff bleibt ungebunden. `searchProvider` - * ergaenzt seit Aufgabe 3 (260910-krx). */ -const BOUND_MODEL_NAMES = ['dashboardLayout', 'widgetInstance', 'searchProvider']; + * ergaenzt seit Aufgabe 3 (260910-krx). `dashboard` ergaenzt seit + * quick-260923-ad9 (Task 1). */ +const BOUND_MODEL_NAMES = ['dashboard', 'dashboardLayout', 'widgetInstance', 'searchProvider']; + +/** Standard-Reiter-Kennung, die die meisten Tests verwenden — ein Reiter + * `dash-1`, der `user-1`/`tenant-1` gehört (Standard-Fixture unten). */ +const DASH_1 = 'dash-1'; + +function makeDashboard( + overrides: Partial<{ + id: string; + userId: string; + tenantId: string; + name: string; + position: number; + createdAt: Date; + updatedAt: Date; + }> = {}, +) { + return { + id: overrides.id ?? DASH_1, + userId: overrides.userId ?? 'user-1', + tenantId: overrides.tenantId ?? 'tenant-1', + name: overrides.name ?? 'Dashboard', + position: overrides.position ?? 0, + createdAt: overrides.createdAt ?? new Date('2026-01-01'), + updatedAt: overrides.updatedAt ?? new Date('2026-01-01'), + }; +} function makeWidget( overrides: Partial<{ id: string; userId: string; tenantId: string; + dashboardId: string; widgetType: string; config: Record; createdAt: Date; @@ -60,6 +96,7 @@ function makeWidget( id: overrides.id ?? 'w1', userId: overrides.userId ?? 'user-1', tenantId: overrides.tenantId ?? 'tenant-1', + dashboardId: overrides.dashboardId ?? DASH_1, widgetType: overrides.widgetType ?? 'clock', config: overrides.config ?? {}, createdAt: overrides.createdAt ?? new Date('2026-01-01'), @@ -92,35 +129,67 @@ function makeFakePrisma( opts: { widgets?: ReturnType[]; modules?: { id: string; slug: string }[]; - layout?: { userId: string; tenantId: string; layouts: unknown } | null; + layout?: { dashboardId: string; userId: string; tenantId: string; layouts: unknown } | null; searchProviders?: ReturnType[]; + dashboards?: ReturnType[]; } = {}, ) { const widgets = opts.widgets ?? []; const modules = opts.modules ?? []; let layoutRow = opts.layout ?? null; const searchProviders = opts.searchProviders ?? []; + // Standard-Fixture: GENAU EIN Reiter `dash-1`, der user-1/tenant-1 gehört + // — die meisten Tests wollen sich um Reiter-Verwaltung nicht kümmern. + // Tests, die eine andere Besitzlage brauchen (fremder Reiter, ADMIN mit + // eigenem Reiter, leere Reiterliste), übergeben `dashboards` explizit. + const dashboards = opts.dashboards ?? [makeDashboard()]; const boundCallLog: { tenantId: string; model: string; method: string }[] = []; const fake: any = { + dashboard: { + findMany: vi.fn(async ({ where }: any) => { + return dashboards + .filter((d) => d.userId === where.userId) + .slice() + .sort((a, b) => a.position - b.position); + }), + findUnique: vi.fn(async ({ where }: any) => { + return dashboards.find((d) => d.id === where.id) ?? null; + }), + count: vi.fn(async ({ where }: any) => { + return dashboards.filter( + (d) => d.userId === where.userId && d.tenantId === where.tenantId, + ).length; + }), + create: vi.fn(async ({ data }: any) => { + const created = makeDashboard({ id: `new-dash-${dashboards.length + 1}`, ...data }); + dashboards.push(created); + return created; + }), + }, dashboardLayout: { findUnique: vi.fn(async ({ where }: any) => { - if (layoutRow && layoutRow.userId === where.userId) return layoutRow; + if (layoutRow && layoutRow.dashboardId === where.dashboardId) return layoutRow; return null; }), upsert: vi.fn(async ({ where, update, create }: any) => { - if (layoutRow && layoutRow.userId === where.userId) { + if (layoutRow && layoutRow.dashboardId === where.dashboardId) { layoutRow = { ...layoutRow, layouts: update.layouts }; return layoutRow; } - layoutRow = { userId: create.userId, tenantId: create.tenantId, layouts: create.layouts }; + layoutRow = { + dashboardId: create.dashboardId, + userId: create.userId, + tenantId: create.tenantId, + layouts: create.layouts, + }; return layoutRow; }), }, widgetInstance: { findMany: vi.fn(async ({ where }: any) => { return widgets - .filter((w) => w.userId === where.userId) + .filter((w) => w.dashboardId === where.dashboardId) .slice() .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); }), @@ -188,8 +257,16 @@ function makeFakePrisma( } bound[modelName] = wrapped; } + // quick-260923-ad9: `listDashboards` setzt die Transaktionssperre über + // ein rohes `$executeRaw` auf `tx` — Attrappe genügt, das Ergebnis + // wird nicht ausgewertet. + bound.$executeRaw = vi.fn(async () => undefined); return bound; }, + __withTenantTransaction(tenantId: string, fn: (tx: any) => any) { + boundCallLog.push({ tenantId, model: '$transaction', method: 'withTenantTransaction' }); + return fn(fake.__makeBoundClient(tenantId)); + }, }; return fake; @@ -245,7 +322,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => { const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); - const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); + const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1); expect(result).toEqual(widgets); expect(moduleAccessService.getAccessibleModuleIds).not.toHaveBeenCalled(); @@ -261,7 +338,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => { const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1'])); const service = new DashboardService(prisma as any, moduleAccessService as any); - const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); + const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1); expect(result).toEqual([widget]); }); @@ -276,7 +353,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => { const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); - const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); + const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1); expect(result).toEqual([]); }); @@ -287,12 +364,13 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => { const prisma = makeFakePrisma({ widgets: [widget], modules: [{ id: 'mod-1', slug: 'tender-radar' }], + dashboards: [makeDashboard({ userId: 'admin-1' })], }); // Simuliert den D-03-Kurzschluss der Zugriffsauflösung aus 15-01: ADMIN erhält alle aktiven Module. const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1'])); const service = new DashboardService(prisma as any, moduleAccessService as any); - const result = await service.getWidgets('admin-1', 'tenant-1', 'ADMIN' as any); + const result = await service.getWidgets('admin-1', 'tenant-1', 'ADMIN' as any, DASH_1); expect(result).toEqual([widget]); expect(moduleAccessService.getAccessibleModuleIds).toHaveBeenCalledWith( @@ -313,7 +391,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => { const moduleAccessService = makeFakeModuleAccessService(new Set()); // kein Zugriff auf tender-radar const service = new DashboardService(prisma as any, moduleAccessService as any); - const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); + const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1); expect(result).toEqual([platformWidget]); }); @@ -334,7 +412,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => { const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1'])); const service = new DashboardService(prisma as any, moduleAccessService as any); - const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); + const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1); expect(result.map((w: any) => w.id)).toEqual(['w1', 'w2', 'w3']); }); @@ -349,8 +427,8 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => { const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); - await service.getWidgets('user-1', 'tenant-1', 'USER' as any); - await service.getWidgets('user-1', 'tenant-1', 'USER' as any); + await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1); + await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1); expect(prisma.widgetInstance.delete).not.toHaveBeenCalled(); expect(prisma.widgetInstance.findMany).toHaveBeenCalledTimes(2); @@ -363,7 +441,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => { const moduleAccessService = makeFakeModuleAccessService(new Set(['irgendeine-id'])); const service = new DashboardService(prisma as any, moduleAccessService as any); - const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); + const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1); expect(result).toEqual([]); }); @@ -379,15 +457,16 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26 it('getLayout: der Lesezugriff läuft über den gebundenen Klienten, mit der übergebenen Mandantenkennung im Protokoll', async () => { const prisma = makeFakePrisma({ - layout: { userId: 'user-1', tenantId: 'tenant-1', layouts: { lg: [{ i: 'w1' }] } }, + layout: { dashboardId: DASH_1, userId: 'user-1', tenantId: 'tenant-1', layouts: { lg: [{ i: 'w1' }] } }, }); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); - const result = await service.getLayout('user-1', 'tenant-1'); + const result = await service.getLayout('user-1', 'tenant-1', DASH_1); expect(result).toEqual({ lg: [{ i: 'w1' }] }); expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique'); + expectBoundCall(prisma, 'tenant-1', 'dashboard', 'findUnique'); // Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument. expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-1', 'user-1'); }); @@ -397,7 +476,7 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26 const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); - const result = await service.getLayout('user-1', 'tenant-1'); + const result = await service.getLayout('user-1', 'tenant-1', DASH_1); expect(result).toEqual({ lg: [], md: [], sm: [], xs: [], xxs: [] }); }); @@ -407,17 +486,18 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26 const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); - await service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any); + await service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: { lg: [] } } as any); expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'upsert'); + expectBoundCall(prisma, 'tenant-1', 'dashboard', 'findUnique'); }); /** - * Gegenrichtung der Bindung (w4, 260910-krx). `DashboardLayout.userId` ist - * plattformweit eindeutig, ohne Mandantenanteil. Ist die vorhandene Zeile - * unter dem gebundenen Kontext unsichtbar, laeuft das `upsert` in einen - * Konflikt — und der aeussert sich hier NICHT als der bekannte P2002-Fehler - * (`PrismaClientKnownRequestError`), sondern als + * Gegenrichtung der Bindung (w4, 260910-krx). `DashboardLayout.dashboardId` + * (bis quick-260923-ad9: `userId`) ist die eindeutige Spalte. Ist die + * vorhandene Zeile unter dem gebundenen Kontext unsichtbar, laeuft das + * `upsert` in einen Konflikt — und der aeussert sich hier NICHT als der + * bekannte P2002-Fehler (`PrismaClientKnownRequestError`), sondern als * `PrismaClientUnknownRequestError`, weil die Zeilenschutz-Regel den * Schreibzugriff mit SQLSTATE 42501 abweist, bevor die Eindeutigkeit * ueberhaupt geprueft wird. Gemessen in `rls-scratch-check.mjs` @@ -440,7 +520,7 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26 const service = new DashboardService(prisma as any, moduleAccessService as any); await expect( - service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any), + service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: { lg: [] } } as any), ).rejects.toBeInstanceOf(ConflictException); }); @@ -457,20 +537,20 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26 const service = new DashboardService(prisma as any, moduleAccessService as any); await expect( - service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any), + service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: { lg: [] } } as any), ).rejects.toBe(known); }); }); it('Anordnung lesen und speichern sind GEMEINSAM gebunden: beide laufen über denselben gebundenen Klienten und dieselbe Mandantenkennung', async () => { const prisma = makeFakePrisma({ - layout: { userId: 'user-1', tenantId: 'tenant-1', layouts: {} }, + layout: { dashboardId: DASH_1, userId: 'user-1', tenantId: 'tenant-1', layouts: {} }, }); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); - await service.getLayout('user-1', 'tenant-1'); - await service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any); + await service.getLayout('user-1', 'tenant-1', DASH_1); + await service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: { lg: [] } } as any); expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique'); expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'upsert'); @@ -481,7 +561,7 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26 const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); - const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); + const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1); expect(result).toEqual([]); expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'findMany'); @@ -493,9 +573,10 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26 const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); - await service.addWidget('user-1', 'tenant-1', { widgetType: 'clock' } as any); + await service.addWidget('user-1', 'tenant-1', { widgetType: 'clock', dashboardId: DASH_1 } as any); expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'create'); + expectBoundCall(prisma, 'tenant-1', 'dashboard', 'findUnique'); }); it('Widget-Konfiguration ändern: BEIDE Abfragen (Besitzprüfung und Änderung) laufen über DENSELBEN gebundenen Klienten und dieselbe Mandantenkennung', async () => { @@ -548,16 +629,16 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26 const widget = makeWidget({ id: 'w1', userId: 'user-1' }); const prisma = makeFakePrisma({ widgets: [widget], - layout: { userId: 'user-1', tenantId: 'tenant-1', layouts: {} }, + layout: { dashboardId: DASH_1, userId: 'user-1', tenantId: 'tenant-1', layouts: {} }, }); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); for (const call of [ - () => service.getLayout('user-1', 'tenant-1'), - () => service.saveLayout('user-1', 'tenant-1', { layouts: {} } as any), - () => service.getWidgets('user-1', 'tenant-1', 'USER' as any), - () => service.addWidget('user-1', 'tenant-1', { widgetType: 'clock' } as any), + () => service.getLayout('user-1', 'tenant-1', DASH_1), + () => service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: {} } as any), + () => service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1), + () => service.addWidget('user-1', 'tenant-1', { widgetType: 'clock', dashboardId: DASH_1 } as any), () => service.updateWidgetConfig('w1', 'user-1', 'tenant-1', { config: {} } as any), () => service.removeWidget('w1', 'user-1', 'tenant-1'), ]) { @@ -575,7 +656,7 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26 const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); - const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); + const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1); expect(result).toEqual([]); }); @@ -593,9 +674,9 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26 const service = new DashboardService(prisma as any, moduleAccessService as any); const stored = { lg: [{ i: 'w1', x: 0, y: 0, w: 4, h: 4 }], __gridVersion: 2 }; - await service.saveLayout('user-1', 'tenant-1', { layouts: stored } as any); + await service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: stored } as any); - const result = await service.getLayout('user-1', 'tenant-1'); + const result = await service.getLayout('user-1', 'tenant-1', DASH_1); expect(result).toEqual(stored); expect((result as Record).__gridVersion).toBe(2); @@ -623,6 +704,178 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26 }); }); +// --- Reiter (quick-260923-ad9, Task 1) -------------------------------------- + +describe('DashboardService.listDashboards — Reiter anlegen/lesen (quick-260923-ad9, Task 1)', () => { + beforeEach(() => { + vi.mocked(forTenant).mockClear(); + }); + + it('erster Aufruf ohne vorhandenen Reiter legt genau einen mit Position 0 und Namen "Dashboard" an und liefert ihn', async () => { + const prisma = makeFakePrisma({ dashboards: [] }); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + const result = await service.listDashboards('user-1', 'tenant-1'); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ name: 'Dashboard', position: 0, userId: 'user-1' }); + expect(prisma.dashboard.create).toHaveBeenCalledTimes(1); + }); + + it('zweiter Aufruf legt keinen weiteren Reiter an', async () => { + const prisma = makeFakePrisma({ dashboards: [] }); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + await service.listDashboards('user-1', 'tenant-1'); + const second = await service.listDashboards('user-1', 'tenant-1'); + + expect(second).toHaveLength(1); + expect(prisma.dashboard.create).toHaveBeenCalledTimes(1); + }); + + it('das Anlegen läuft als EINE withTenantTransaction (Sperre + erneute Zählung), nicht als Einzelbefehl (T-AD9-07)', async () => { + const prisma = makeFakePrisma({ dashboards: [] }); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + await service.listDashboards('user-1', 'tenant-1'); + + const found = prisma.__boundCallLog.some( + (c: any) => c.tenantId === 'tenant-1' && c.model === '$transaction' && c.method === 'withTenantTransaction', + ); + expect(found, 'erwartete withTenantTransaction fehlt im Protokoll').toBe(true); + }); + + it('die Liste kommt nach Position aufsteigend, unabhängig von der Einfügereihenfolge', async () => { + const prisma = makeFakePrisma({ + dashboards: [ + makeDashboard({ id: 'd2', position: 1 }), + makeDashboard({ id: 'd1', position: 0 }), + ], + }); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + const result = await service.listDashboards('user-1', 'tenant-1'); + + expect(result.map((d: any) => d.id)).toEqual(['d1', 'd2']); + }); + + it('der Lesezugriff läuft über den gebundenen Klienten mit der richtigen Mandantenkennung', async () => { + const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: 'd1' })] }); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + await service.listDashboards('user-1', 'tenant-1'); + + expectBoundCall(prisma, 'tenant-1', 'dashboard', 'findMany'); + }); +}); + +describe('DashboardService — Riegel gegen fremde Reiter (quick-260923-ad9, Task 1, T-AD9-01/02)', () => { + beforeEach(() => { + vi.mocked(forTenant).mockClear(); + }); + + it('getLayout: fremde Reiter-Kennung führt zur Nicht-gefunden-Antwort', async () => { + const prisma = makeFakePrisma({ + dashboards: [makeDashboard({ id: DASH_1, userId: 'other-user' })], + }); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + await expect(service.getLayout('user-1', 'tenant-1', DASH_1)).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('saveLayout: fremde Reiter-Kennung führt zur Nicht-gefunden-Antwort, ohne zu schreiben', async () => { + const prisma = makeFakePrisma({ + dashboards: [makeDashboard({ id: DASH_1, userId: 'other-user' })], + }); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + await expect( + service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: {} } as any), + ).rejects.toBeInstanceOf(NotFoundException); + expect(prisma.dashboardLayout.upsert).not.toHaveBeenCalled(); + }); + + it('getWidgets: fremde Reiter-Kennung führt zur Nicht-gefunden-Antwort', async () => { + const prisma = makeFakePrisma({ + dashboards: [makeDashboard({ id: DASH_1, userId: 'other-user' })], + }); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + await expect( + service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('addWidget: fremde Reiter-Kennung führt zur Nicht-gefunden-Antwort, ohne zu schreiben', async () => { + const prisma = makeFakePrisma({ + dashboards: [makeDashboard({ id: DASH_1, userId: 'other-user' })], + }); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + await expect( + service.addWidget('user-1', 'tenant-1', { widgetType: 'clock', dashboardId: DASH_1 } as any), + ).rejects.toBeInstanceOf(NotFoundException); + expect(prisma.widgetInstance.create).not.toHaveBeenCalled(); + }); + + it('unbekannte Reiter-Kennung (existiert nicht) führt ebenso zur Nicht-gefunden-Antwort — dieselbe Antwort wie "gehört einem Kollegen"', async () => { + const prisma = makeFakePrisma({ dashboards: [] }); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + await expect(service.getLayout('user-1', 'tenant-1', 'unknown-dash')).rejects.toThrow( + "Dashboard with id 'unknown-dash' not found", + ); + }); +}); + +describe('DashboardService — Kacheln und Anordnung sind je Reiter getrennt (quick-260923-ad9, Task 1)', () => { + beforeEach(() => { + vi.mocked(forTenant).mockClear(); + }); + + it('getWidgets liest über die Reiter-Kennung, nicht über die Benutzerkennung — zwei Reiter desselben Benutzers teilen keine Kacheln', async () => { + const widgetOnDash1 = makeWidget({ id: 'w1', dashboardId: 'dash-1' }); + const widgetOnDash2 = makeWidget({ id: 'w2', dashboardId: 'dash-2' }); + const prisma = makeFakePrisma({ + widgets: [widgetOnDash1, widgetOnDash2], + dashboards: [makeDashboard({ id: 'dash-1' }), makeDashboard({ id: 'dash-2' })], + }); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, 'dash-2'); + + expect(result.map((w: any) => w.id)).toEqual(['w2']); + }); + + it('saveLayout schreibt die Anordnung unter der Reiter-Kennung (create-Zweig)', async () => { + const prisma = makeFakePrisma({}); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + await service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: { lg: [] } } as any); + + expect(prisma.dashboardLayout.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { dashboardId: DASH_1 }, + create: expect.objectContaining({ dashboardId: DASH_1 }), + }), + ); + }); +}); + // --- Bindung an forTenant() (260910-krx, Aufgabe 3: Suchmaschinen) --------- describe('DashboardService — Suchmaschinen gebunden an forTenant(), Katalog bewusst ungebunden (260910-krx, Aufgabe 3)', () => { @@ -710,7 +963,7 @@ describe('DashboardService — Suchmaschinen gebunden an forTenant(), Katalog be name: 'Intranet', urlTemplate: 'https://intranet.test/?q={query}', } as any); - await service.getWidgets('user-1', 'tenant-1', 'USER' as any); + await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1); // Beweist, dass der Katalogzugriff tatsaechlich lief (sonst waere die // Wachhund-Pruefung unten wirkungslos, weil sie nichts protokollieren diff --git a/apps/api/src/dashboard/dashboard.service.ts b/apps/api/src/dashboard/dashboard.service.ts index 9e813a6..3df5eef 100644 --- a/apps/api/src/dashboard/dashboard.service.ts +++ b/apps/api/src/dashboard/dashboard.service.ts @@ -5,7 +5,7 @@ import { } from '@nestjs/common'; import { Prisma, Role } from '@prisma/client'; import { ModuleAccessService } from '../module-registry/module-access.service'; -import { forTenant } from '../prisma/prisma-tenant.extension'; +import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension'; import { PrismaService } from '../prisma/prisma.service'; import { CreateSearchProviderDto } from './dto/create-search-provider.dto'; import { CreateWidgetDto } from './dto/create-widget.dto'; @@ -88,13 +88,88 @@ export class DashboardService { ) {} /** - * Returns the user's saved layout, or a default empty layout - * with all breakpoint arrays initialized. + * Reiter (quick-260923-ad9, D-01/D-08/D-09): liest die Dashboards des + * Benutzers, nach `position` aufsteigend — Position 0 ist der Standard + * und wird beim Öffnen geladen. Ist die Liste leer (erster Aufruf des + * Benutzers ueberhaupt), wird genau EIN Reiter „Dashboard“ angelegt. + * + * Das Anlegen laeuft in einer `withTenantTransaction`, deren ERSTE + * Anweisung eine Transaktionssperre auf die Benutzerkennung nimmt + * (`pg_advisory_xact_lock`, `hashtext` ueber die Benutzerkennung als + * ersten Schluessel, 0 als zweiten — beides eingebaute Postgres- + * Funktionen). Zwei gleichzeitige erste Aufrufe desselben Benutzers + * warten dadurch aufeinander statt beide "kein Reiter vorhanden" zu + * sehen; die erneute Zaehlung INNERHALB der Sperre verhindert die + * doppelte Anlage (T-AD9-07). `withTenantTransaction` setzt keine + * Benutzerdimension in der Sitzung — die Bedingung traegt `userId` UND + * `tenantId` deshalb selbst, als zweites Netz. */ - async getLayout(userId: string, tenantId: string) { + async listDashboards(userId: string, tenantId: string) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); - const record = await tenantPrisma.dashboardLayout.findUnique({ + let dashboards = await tenantPrisma.dashboard.findMany({ where: { userId }, + orderBy: { position: 'asc' }, + }); + + if (dashboards.length === 0) { + await withTenantTransaction(this.prisma, tenantId, async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${userId}), 0)`; + const existing = await tx.dashboard.count({ + where: { userId, tenantId }, + }); + if (existing === 0) { + await tx.dashboard.create({ + data: { userId, tenantId, name: 'Dashboard', position: 0 }, + }); + } + }); + + dashboards = await tenantPrisma.dashboard.findMany({ + where: { userId }, + orderBy: { position: 'asc' }, + }); + } + + return dashboards; + } + + /** + * Riegel gegen fremde Reiter (T-AD9-01/02/03, Muster `FavoritesService. + * create`/T-GWH-05): liest den Reiter ueber den BEREITS gebundenen + * Klienten des Aufrufers (kein zweiter `forTenant()`-Aufruf) und wirft + * fuer drei ununterscheidbare Faelle dieselbe `NotFoundException` — "gibt + * es nicht", "gehoert einem Kollegen" und "liegt bei einem fremden + * Mandanten" (die Mandantengrenze zieht bereits der gebundene Klient). + * Niemals eine abweichende Antwort, aus der sich die Existenz eines + * fremden Reiters ablesen liesse. + */ + private async assertOwnedDashboard( + tenantPrisma: ReturnType, + dashboardId: string, + userId: string, + ): Promise { + const dashboard = await tenantPrisma.dashboard.findUnique({ + where: { id: dashboardId }, + }); + + if (!dashboard || dashboard.userId !== userId) { + throw new NotFoundException(`Dashboard with id '${dashboardId}' not found`); + } + } + + /** + * Returns the saved layout of one dashboard tab, or a default empty + * layout with all breakpoint arrays initialized. + * + * quick-260923-ad9 (D-02): scoped by `dashboardId` instead of `userId` — + * `assertOwnedDashboard` runs first, over the SAME bound client. + */ + async getLayout(userId: string, tenantId: string, dashboardId: string) { + const tenantPrisma = forTenant(this.prisma, tenantId, userId); + await this.assertOwnedDashboard(tenantPrisma, dashboardId, userId); + + const record = await tenantPrisma.dashboardLayout.findUnique({ + where: { dashboardId }, }); if (!record) { @@ -105,32 +180,33 @@ export class DashboardService { } /** - * Upserts the user's dashboard layout. + * Upserts the layout of one dashboard tab. * Creates a new record if none exists, updates if it does. * - * `userId` is platform-wide `@unique` (no tenant component) — a tenant - * whose user id was, by hand, moved off its actually-visible row could hit - * an `upsert` conflict on a row it cannot see under RLS. Measured - * (260910-krx, Aufgabe 1): a bound conflicting upsert against such a row - * throws `Prisma.PrismaClientUnknownRequestError` (NOT the `P2002` known - * error that the `tenders` area's translation pattern catches — this is a + * quick-260923-ad9 (D-02): scoped by `dto.dashboardId` instead of + * `userId` — `assertOwnedDashboard` runs first, over the SAME bound + * client. `dashboardId` is now the `@unique` column on `DashboardLayout` + * (was `userId` before this plan). + * + * A bound conflicting upsert against a row invisible under RLS throws + * `Prisma.PrismaClientUnknownRequestError` (NOT the `P2002` known error + * that the `tenders` area's translation pattern catches — this is a * different Prisma error class, `.code`/`.meta` are `undefined`, the only - * signal is the raw `.message` text). Translated below into an - * understandable German message instead of a raw 500, same intent as - * `tender-notification-pref.service.ts`, different detection. Not - * reachable via any application path today (a user's tenant id never - * changes after creation) — the honest fix is a schema change and is - * deferred as a product decision to Etappe 3, same as WINDOWS #22. + * signal is the raw `.message` text) — measured 260910-krx, Aufgabe 1, + * translation kept unchanged from before this plan. */ async saveLayout(userId: string, tenantId: string, dto: SaveLayoutDto) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); + await this.assertOwnedDashboard(tenantPrisma, dto.dashboardId, userId); + try { return await tenantPrisma.dashboardLayout.upsert({ - where: { userId }, + where: { dashboardId: dto.dashboardId }, update: { layouts: dto.layouts as unknown as Prisma.InputJsonValue }, create: { userId, tenantId, + dashboardId: dto.dashboardId, layouts: dto.layouts as unknown as Prisma.InputJsonValue, }, }); @@ -145,12 +221,13 @@ export class DashboardService { } /** - * Returns all widget instances for a given user, gefiltert um Widgets + * Returns all widget instances of one dashboard tab, gefiltert um Widgets * eines für den Benutzer gesperrten Moduls (D-22, PERM-07). * - * Die bestehende Query bleibt unverändert die erste Aktion. Steht unter - * den geladenen Widgets kein einziger Typ in `WIDGET_MODULE_MAP` — der - * Zustand am Ende dieser Phase, weil die Tabelle leer ist — wird die + * quick-260923-ad9 (D-02): scoped by `dashboardId` instead of `userId` — + * `assertOwnedDashboard` runs first, over the SAME bound client. Steht + * unter den geladenen Widgets kein einziger Typ in `WIDGET_MODULE_MAP` — + * der Zustand am Ende dieser Phase, weil die Tabelle leer ist — wird die * Liste unverändert zurückgegeben, ohne einen Zugriffs-Lookup. Nur bei * mindestens einem modulgebundenen Widget wird die Zugriffsauflösung * aus 15-01 einmal aufgerufen (D-01: dieselbe Auflösung wie Guard und @@ -158,10 +235,12 @@ export class DashboardService { * Modul-Slug nicht auf einen `Module`-Datensatz auflösen, wird das * betroffene Widget entfernt (Fail-Closed). */ - async getWidgets(userId: string, tenantId: string, role: Role) { + async getWidgets(userId: string, tenantId: string, role: Role, dashboardId: string) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); + await this.assertOwnedDashboard(tenantPrisma, dashboardId, userId); + const widgets = await tenantPrisma.widgetInstance.findMany({ - where: { userId }, + where: { dashboardId }, orderBy: { createdAt: 'asc' }, }); @@ -207,14 +286,20 @@ export class DashboardService { } /** - * Creates a new widget instance for the user. + * Creates a new widget instance on one dashboard tab. + * quick-260923-ad9 (D-02): `assertOwnedDashboard` runs first, over the + * SAME bound client — a widget can only be created on a tab the caller + * owns. */ async addWidget(userId: string, tenantId: string, dto: CreateWidgetDto) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); + await this.assertOwnedDashboard(tenantPrisma, dto.dashboardId, userId); + return tenantPrisma.widgetInstance.create({ data: { userId, tenantId, + dashboardId: dto.dashboardId, widgetType: dto.widgetType, config: (dto.config ?? {}) as unknown as Prisma.InputJsonValue, }, diff --git a/apps/api/src/dashboard/dto/create-widget.dto.ts b/apps/api/src/dashboard/dto/create-widget.dto.ts index f24a6ee..cbb510a 100644 --- a/apps/api/src/dashboard/dto/create-widget.dto.ts +++ b/apps/api/src/dashboard/dto/create-widget.dto.ts @@ -2,7 +2,7 @@ import { IsIn, IsObject, IsOptional, IsString } from 'class-validator'; import { WIDGET_TYPES } from '@tessera/shared'; /** - * DTO for creating a new widget instance on a user's dashboard. + * DTO for creating a new widget instance on one dashboard tab. * * quick-260922-m1h: `widgetType` wird gegen `WIDGET_TYPES` aus * `@tessera/shared` geprüft — dieselbe Liste, aus der das Frontend seine @@ -10,9 +10,15 @@ import { WIDGET_TYPES } from '@tessera/shared'; * zweites Mal; vergaß man einen Eintrag, lehnte die API eine im Katalog * angebotene Kachel mit 400 ab. * + * quick-260923-ad9: `dashboardId` selects the tab — the service verifies + * ownership before writing (`assertOwnedDashboard`). + * * config is optional and defaults to {} on the model. */ export class CreateWidgetDto { + @IsString() + dashboardId!: string; + @IsString() @IsIn([...WIDGET_TYPES]) widgetType!: string; diff --git a/apps/api/src/dashboard/dto/save-layout.dto.ts b/apps/api/src/dashboard/dto/save-layout.dto.ts index 3c2f847..ed01e90 100644 --- a/apps/api/src/dashboard/dto/save-layout.dto.ts +++ b/apps/api/src/dashboard/dto/save-layout.dto.ts @@ -1,11 +1,16 @@ -import { IsObject } from 'class-validator'; +import { IsObject, IsString } from 'class-validator'; /** - * DTO for saving/updating a user's dashboard layout. + * DTO for saving/updating the layout of one dashboard tab (quick-260923-ad9). * The layouts object contains responsive breakpoint layouts * (lg, md, sm, xs, xxs) as managed by react-grid-layout. + * `dashboardId` selects the tab — the service verifies ownership before + * writing (`assertOwnedDashboard`). */ export class SaveLayoutDto { + @IsString() + dashboardId!: string; + @IsObject() layouts!: Record; } diff --git a/apps/api/src/dashboard/widget-module-map.spec.ts b/apps/api/src/dashboard/widget-module-map.spec.ts index e59f387..ff72730 100644 --- a/apps/api/src/dashboard/widget-module-map.spec.ts +++ b/apps/api/src/dashboard/widget-module-map.spec.ts @@ -39,8 +39,11 @@ describe('widget-module-map (quick-260922-m1h)', () => { * ablehnen zu lassen. */ describe('CreateWidgetDto-Whitelist (quick-260922-m1h)', () => { + // quick-260923-ad9: dashboardId ist seither ein Pflichtfeld (Reiter- + // Kennung) — hier fest mitgegeben, damit dieser Test weiterhin nur die + // Whitelist von widgetType prueft. async function validateType(widgetType: string) { - const dto = plainToInstance(CreateWidgetDto, { widgetType }); + const dto = plainToInstance(CreateWidgetDto, { widgetType, dashboardId: 'dash-1' }); return validate(dto); } diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 8ce0391..136b07a 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -168,16 +168,16 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet | dkv | 0 | 22 | 1 | **war 21/0** — Aufgabe 2/3 (260909-mir) haben `dkv.service.ts` vollständig auf `forTenant()` umgestellt: Konfigurationspfade (`loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection`), Historie, Fahrzeugstammdaten und der neue Besitzriegel vor dem Ausfuhrdatei-Download. Gebunden sind es 22 statt 21, weil der Riegel einen zusätzlichen Lesezugriff auf `dkvInvoiceHistory` einführt (T-MIR-03). Der eine verbleibende ungebundene Treffer war der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21). **260914-eym:** ersetzt durch `loadActiveConfigsForScheduler()` über `forSystem()` (1→0 ungebunden, 1 System) — WINDOWS #21 geschlossen | | user | 8 | 14 | 0 | **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) | | module-registry | 7 | 10 | 0 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) | -| dashboard | 1 | 21 | 1 | **260922-hk4:** 18→21 gebunden, 0→1 System — die Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Spalte `data`. Drei zusätzliche gebundene Rohtreffer in `dashboard-images.service.ts`: das Nachtragen von `storagePath` nach dem Upload (die UUID steht erst nach `create` fest), das Zurücknehmen der Zeile bei fehlgeschlagenem Schreiben, und das Nachtragen im Umzug beim Start. Der eine System-Rohtreffer ist die Lesehälfte dieses Umzugs (`onApplicationBootstrap`, Zeilen ohne `storagePath` über ALLE Mandanten, Muster DKV-Planer) — geschrieben wird auch dort je Zeile mandantengebunden. Nachgemessen mit der Gate-Schleife. Vorher: **260921-pi9:** 12→18 gebunden — `dashboard-images.service.ts` (Bilderrahmen) bringt sechs gebundene `dashboardImage`-Rohtreffer (`findMany`, `count`, `create`, zweimal `findUnique`, `delete`), nachgemessen mit der Gate-Schleife. Vorher: **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) | +| dashboard | 1 | 24 | 1 | **quick-260923-ad9 (Task 1):** 21→24 gebunden — die neue Reitertabelle bringt drei gebundene `dashboard`-Rohtreffer in `dashboard.service.ts` (zwei `findMany` in `listDashboards`, ein `findUnique` im Riegel `assertOwnedDashboard`), nachgemessen mit der Gate-Schleife. Vorher: **260922-hk4:** 18→21 gebunden, 0→1 System — die Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Spalte `data`. Drei zusätzliche gebundene Rohtreffer in `dashboard-images.service.ts`: das Nachtragen von `storagePath` nach dem Upload (die UUID steht erst nach `create` fest), das Zurücknehmen der Zeile bei fehlgeschlagenem Schreiben, und das Nachtragen im Umzug beim Start. Der eine System-Rohtreffer ist die Lesehälfte dieses Umzugs (`onApplicationBootstrap`, Zeilen ohne `storagePath` über ALLE Mandanten, Muster DKV-Planer) — geschrieben wird auch dort je Zeile mandantengebunden. Nachgemessen mit der Gate-Schleife. Vorher: **260921-pi9:** 12→18 gebunden — `dashboard-images.service.ts` (Bilderrahmen) bringt sechs gebundene `dashboardImage`-Rohtreffer (`findMany`, `count`, `create`, zweimal `findUnique`, `delete`), nachgemessen mit der Gate-Schleife. Vorher: **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) | | auth | 3 | 10 | 0 | **war 8/5** — 260911-fh9 (Aufgabe 2) hat `getMe`, `changePassword`, `adminResetPassword` (fünf Rohtreffer auf `user`, drei Methoden) auf `forTenant()` umgestellt. Die 3 verbleibenden ungebundenen Rohtreffer sind die `$queryRaw`-Aufrufe der drei Anmeldefunktionen (`validateUser`, `requestPasswordReset`, `resetPassword`) — KEINE Modellzugriffe (`$` liegt nicht in `[a-zA-Z]`, die Bestandsaufnahme führt sie deshalb nicht als (Datei, Modell)-Paar), bewusst und dauerhaft ungebunden, siehe `20260909160000_auth_lookup_functions` und `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h1) | | calendar | 0 | 12 | 0 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg | | tenant | 8 | 3 | 0 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet | | favorites | 0 | 8 | 0 | **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile | | bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff | | settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen | -| **Summe** | **61** | **190** | **6** | **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | +| **Summe** | **61** | **193** | **6** | **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | -## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 74 Paare) +## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 75 Paare) Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf (260909-ipc) plus ein bisher vollstaendig unsichtbares Paar @@ -201,6 +201,19 @@ muss-mandantengebunden) war in der Tabelle eingetragen, in dieser Verteilung aber nie mitgezaehlt. Beide Korrekturen (72→74, 35→37) sind gemessen, nicht geschaetzt. +**Nachtrag 260923-ad9 (Task 1, Dashboard-Reiter):** 75 Paare — ein neues +Paar `dashboard/dashboard.service.ts`/`dashboard` (muss-mandantengebunden, +gebunden) fuer die neue Reitertabelle (`listDashboards`, +`assertOwnedDashboard`). Nachgezaehlt mit `grep -cE "^\| apps/api/src/" +docs/mandantentrennung-zugriffsklassifikation.md` ueber die +Fundstellentabelle: vor diesem Eintrag standen dort 74 Zeilen. Die +Uebersichtszeile `dashboard` und die Summenzeile unten sind mit derselben +Gate-Schleife nachgerechnet (Rohtreffer, nicht Paare): `dashboard.service.ts` +traegt jetzt drei zusaetzliche gebundene `tenantPrisma.dashboard.`-Rohtreffer +(zwei `findMany` in `listDashboards`, ein `findUnique` in +`assertOwnedDashboard`) — Bereich `dashboard` 21→24 gebunden, Summe +190→193 gebunden, ungebunden und System unveraendert. + **Stand 260909-laa (Aufgabe 2):** dieselben 62 Paare, keine neue Fundstelle hinzugekommen oder verschwunden — nur EINE Klasse hat sich verschoben: `tender-rss-feed.service.ts`/`tenderRssFeedSource` wechselt von @@ -329,11 +342,11 @@ entnommen (30 Zusicherungen, darunter der Wachhund | Klasse | Anzahl Paare | |---|---| -| muss-mandantengebunden | 37 | +| muss-mandantengebunden | 38 | | keine-mandantengebundene-tabelle | 21 | | beides | 14 | | bewusst-uebergreifend | 2 | -| **Summe** | **74** | +| **Summe** | **75** | ## Der Hintergrunddienst als Falle — sechs Fälle @@ -674,10 +687,11 @@ werden. | apps/api/src/bug-reports/bug-reports.service.ts | user | muss-mandantengebunden | gebunden | Fehler-melden-Knopf (quick-260914-m97): eine gebundene Leseoperation auf die Zeile des angemeldeten Benutzers (Anzeigename, E-Mail, Rolle fuer den Bericht), Mandant ausschliesslich aus dem Sitzungsnachweis. | | apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. Benutzerdimension seit 20260911120000 (260911-nke). | | apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | system-gebunden | **260922-hk4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `onApplicationBootstrap()` zieht die Bilder einmalig aus der Spalte `data` in den Dateibereich (`user-files/dashboard-images//.`) und muss dafür die noch nicht umgezogenen Zeilen ALLER Mandanten sehen (`const systemPrisma = forSystem(this.prisma)`, ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht über `system_read_policy … FOR SELECT` auf "DashboardImage", Migration 20260922120000). GESCHRIEBEN wird auch dort je Zeile über `forTenant(prisma, row.tenantId, row.userId)` — einmal-lesen-viele-bedienen, Muster DKV-Planer. Die Bytes selbst liegen seither auf der Platte, die Zeile hält nur noch `storagePath` (Muster `User.avatarPath`); der Dateiname ist IMMER servergeneriert (UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ), `originalName` kommt in keinem Pfad vor (T-HK4-01). Alle vier Anfragewege sind unverändert mandantengebunden: Hochgeladene Bilder des Bilderrahmen-Widgets (quick-260921-pi9), gehoeren dem hochladenden Benutzer; `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260921120000, Form aus 20260911120000). Alle vier Methoden (`list`, `upload`, `getBytes`, `remove`) holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und Zaehler filtern zusaetzlich explizit `where: { tenantId, userId }`, `getBytes`/`remove` pruefen den Besitz anwendungsseitig (`row.userId !== userId || row.tenantId !== tenantId` -> 404, nie 403) — zweites Netz, kein Ersatz, weil der RLS-Schalter heute aus ist. `select` der Liste/Upload-Antwort ohne `data` (Bytes nur ueber `GET :id`). | -| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt auf der plattformweit eindeutigen `userId`, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. | +| apps/api/src/dashboard/dashboard.service.ts | dashboard | muss-mandantengebunden | gebunden | quick-260923-ad9 (Task 1) — Reiter (mehrere Dashboards je Benutzer), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260923120000, Form aus 20260911120000/20260921120000). `listDashboards` liest ueber `forTenant()` und legt bei Bedarf genau einen Reiter an (Transaktionssperre `pg_advisory_xact_lock` innerhalb `withTenantTransaction`, T-AD9-07); der Riegel `assertOwnedDashboard` liest ueber DENSELBEN, bereits gebundenen Klienten des Aufrufers (kein zweiter `forTenant()`-Aufruf) und wirft fuer "gibt es nicht", "gehoert einem Kollegen" und "liegt bei einem fremden Mandanten" dieselbe `NotFoundException` (T-AD9-01/02/03). | +| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Anordnung eines Reiters, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. quick-260923-ad9 (Task 1): die eindeutige Spalte ist jetzt `dashboardId` statt `userId` (D-02) — beide Methoden pruefen vorher ueber `assertOwnedDashboard`, dass der Reiter dem Aufrufer gehoert. | | apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. | | apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). | -| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). Benutzerdimension seit 20260911120000 (260911-nke). | +| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Kacheln eines Reiters, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). Benutzerdimension seit 20260911120000 (260911-nke). quick-260923-ad9 (Task 1): `getWidgets`/`addWidget` filtern/schreiben ueber `dashboardId` statt `userId` (D-02) — `assertOwnedDashboard` prueft vorher, dass der Reiter dem Aufrufer gehoert. | | apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | gebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-mir (Aufgabe 3) laufen beide Historien-Schreibzugriffe der Verarbeitungsstrecke, beide parallelen Lesezugriffe von `getHistory` und der neue Riegel vor dem Ausfuhrdatei-Download vollstaendig ueber `forTenant()`. | | apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | system-gebunden | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Seit 260914-eym liest der Planer-Startpfad `loadActiveConfigsForScheduler()` ueber `forSystem()` (alle aktiven Konfigurationen, nur lesend, `system_read_policy`) — kein ungebundener Zugriff mehr, WINDOWS #21 geschlossen; alle uebrigen Zugriffe bleiben mandantengebunden (Stand-Vorrang: system ohne ungebunden = `system-gebunden`). | | apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | gebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 3) laufen Fahrzeugliste, Anlegen, beide Paare aus Besitzpruefung und Schreibzugriff (Aendern/Loeschen), beide Zweige des CSV-Imports und der gebuendelte Lesezugriff beim Aufbau der Ausfuhrzeilen vollstaendig ueber `forTenant()`; die vorgeschalteten Besitzpruefungen bei Aendern/Loeschen bleiben zusaetzlich bestehen (Befund G — ein gebundenes UPDATE ueber die Kennung allein trifft eine fremde Zeile still, nicht laut). |