diff --git a/apps/api/src/dashboard/dashboard.controller.ts b/apps/api/src/dashboard/dashboard.controller.ts index 3ec21de..ef4fa6e 100644 --- a/apps/api/src/dashboard/dashboard.controller.ts +++ b/apps/api/src/dashboard/dashboard.controller.ts @@ -56,8 +56,8 @@ export class DashboardController { @Get('layout') async getLayout(@Req() req: Request) { - const { userId } = this.extractContext(req); - return this.dashboardService.getLayout(userId); + const { userId, tenantId } = this.extractContext(req); + return this.dashboardService.getLayout(userId, tenantId); } @Put('layout') @@ -85,8 +85,8 @@ export class DashboardController { @Req() req: Request, @Body() dto: UpdateWidgetConfigDto, ) { - const { userId } = this.extractContext(req); - return this.dashboardService.updateWidgetConfig(id, userId, dto); + const { userId, tenantId } = this.extractContext(req); + return this.dashboardService.updateWidgetConfig(id, userId, tenantId, dto); } @Delete('widgets/:id') @@ -94,8 +94,8 @@ export class DashboardController { @Param('id') id: string, @Req() req: Request, ) { - const { userId } = this.extractContext(req); - return this.dashboardService.removeWidget(id, userId); + const { userId, tenantId } = this.extractContext(req); + return this.dashboardService.removeWidget(id, userId, tenantId); } // --- Search Providers (05-02, D-15) --- diff --git a/apps/api/src/dashboard/dashboard.service.spec.ts b/apps/api/src/dashboard/dashboard.service.spec.ts index 2cfc7f4..941d205 100644 --- a/apps/api/src/dashboard/dashboard.service.spec.ts +++ b/apps/api/src/dashboard/dashboard.service.spec.ts @@ -17,22 +17,48 @@ vi.mock('./widget-module-map', () => ({ getModuleSlugForWidgetType: (widgetType: string) => mockMap[widgetType], })); +/** + * Bindung an forTenant() (260910-krx, Aufgabe 2). Dasselbe Muster wie + * `module-access.service.spec.ts` (260910-exd): der gebundene Klient ist + * ein ZWEITES, von `prisma` unterscheidbares Objekt über DEMSELBEN + * Speicher, das protokolliert, welche Aufrufe über ihn liefen (Modellname, + * Methodenname, Mandantenkennung). Ein reiner Identitäts-Mock + * (`forTenant: vi.fn((p) => p)`) könnte einen vergessenen Bindungsaufruf + * nicht von einem ungebundenen Aufruf unterscheiden. + * + * `module` wird NICHT gewrappt — der Katalogzugriff läuft bewusst über den + * ungebundenen Klienten (Aufgabe 1, Befund E/H übernommen aus + * `module-registry`): die Tabelle trägt heute keinen Zeilenschutz, eine + * Bindung wäre heute wirkungslos. + */ +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), +})); + +import { forTenant } from '../prisma/prisma-tenant.extension'; 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. */ +const BOUND_MODEL_NAMES = ['dashboardLayout', 'widgetInstance']; + function makeWidget( overrides: Partial<{ id: string; userId: string; + tenantId: string; widgetType: string; + config: Record; createdAt: Date; }> = {}, ) { return { id: overrides.id ?? 'w1', userId: overrides.userId ?? 'user-1', - tenantId: 'tenant-1', + tenantId: overrides.tenantId ?? 'tenant-1', widgetType: overrides.widgetType ?? 'clock', - config: {}, + config: overrides.config ?? {}, createdAt: overrides.createdAt ?? new Date('2026-01-01'), }; } @@ -41,12 +67,29 @@ function makeFakePrisma( opts: { widgets?: ReturnType[]; modules?: { id: string; slug: string }[]; + layout?: { userId: string; tenantId: string; layouts: unknown } | null; } = {}, ) { const widgets = opts.widgets ?? []; const modules = opts.modules ?? []; + let layoutRow = opts.layout ?? null; + const boundCallLog: { tenantId: string; model: string; method: string }[] = []; - return { + const fake: any = { + dashboardLayout: { + findUnique: vi.fn(async ({ where }: any) => { + if (layoutRow && layoutRow.userId === where.userId) return layoutRow; + return null; + }), + upsert: vi.fn(async ({ where, update, create }: any) => { + if (layoutRow && layoutRow.userId === where.userId) { + layoutRow = { ...layoutRow, layouts: update.layouts }; + return layoutRow; + } + layoutRow = { userId: create.userId, tenantId: create.tenantId, layouts: create.layouts }; + return layoutRow; + }), + }, widgetInstance: { findMany: vi.fn(async ({ where }: any) => { return widgets @@ -54,7 +97,26 @@ function makeFakePrisma( .slice() .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); }), - delete: vi.fn(), + create: vi.fn(async ({ data }: any) => { + const created = makeWidget({ id: `new-${widgets.length + 1}`, ...data }); + widgets.push(created); + return created; + }), + findUnique: vi.fn(async ({ where }: any) => { + return widgets.find((w) => w.id === where.id) ?? null; + }), + update: vi.fn(async ({ where, data }: any) => { + const widget = widgets.find((w) => w.id === where.id); + if (!widget) return null; + Object.assign(widget, data); + return widget; + }), + delete: vi.fn(async ({ where }: any) => { + const idx = widgets.findIndex((w) => w.id === where.id); + if (idx === -1) return null; + const [removed] = widgets.splice(idx, 1); + return removed; + }), }, module: { findMany: vi.fn(async ({ where }: any) => { @@ -62,7 +124,26 @@ function makeFakePrisma( return modules.filter((m) => slugs.includes(m.slug)); }), }, + // --- Bindungsnachweis (260910-krx, Muster aus 260910-exd) -------------- + __boundCallLog: boundCallLog, + __makeBoundClient(tenantId: string) { + const bound: any = { __isBoundClient: true, __tenantId: tenantId }; + for (const modelName of BOUND_MODEL_NAMES) { + const model = fake[modelName]; + const wrapped: any = {}; + for (const method of Object.keys(model)) { + wrapped[method] = async (...args: any[]) => { + boundCallLog.push({ tenantId, model: modelName, method }); + return model[method](...args); + }; + } + bound[modelName] = wrapped; + } + return bound; + }, }; + + return fake; } function makeFakeModuleAccessService(accessibleIds: Set) { @@ -72,13 +153,38 @@ function makeFakeModuleAccessService(accessibleIds: Set) { } /** - * DashboardService.getWidgets — Modulfilter (D-22, PERM-07). Deckt jeden - * Fall aus 15-05-PLAN.md inklusive der Edge-Probe-Kategorien - * adjacency/empty/ordering/idempotency ab. + * Bindungsnachweis: mindestens ein Aufruf von `..` + * lief über den gebundenen Client (nicht über den rohen, ungebundenen + * Fake). Ein vergessener `forTenant()`-Aufruf hinterlässt hier KEINEN + * Eintrag und lässt den Test fehlschlagen. */ +function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) { + const found = prisma.__boundCallLog.some( + (c: any) => c.tenantId === tenantId && c.model === model && c.method === method, + ); + expect( + found, + `erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`, + ).toBe(true); +} + +/** + * Wachhund-Gegenprobe: ein Modell darf im Bindungsprotokoll gar nicht + * vorkommen — das ist der Testfall, der jemanden erwischt, der den bewusst + * ungebundenen Katalogzugriff später versehentlich bindet. + */ +function expectNeverBound(prisma: any, model: string) { + const found = prisma.__boundCallLog.some((c: any) => c.model === model); + expect( + found, + `Modell "${model}" darf nie im Bindungsprotokoll auftauchen (Katalog bleibt ungebunden): ${JSON.stringify(prisma.__boundCallLog)}`, + ).toBe(false); +} + describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => { beforeEach(() => { for (const key of Object.keys(mockMap)) delete mockMap[key]; + vi.mocked(forTenant).mockClear(); }); it('empty/PERM-07: liefert bei leerer Zuordnungstabelle exakt die Prisma-Menge, ohne Zugriffs-Lookup', async () => { @@ -213,3 +319,163 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => { expect(result).toEqual([]); }); }); + +// --- Bindung an forTenant() (260910-krx, Aufgabe 2) ------------------------- + +describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (260910-krx, Aufgabe 2)', () => { + beforeEach(() => { + for (const key of Object.keys(mockMap)) delete mockMap[key]; + vi.mocked(forTenant).mockClear(); + }); + + 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' }] } }, + }); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + const result = await service.getLayout('user-1', 'tenant-1'); + + expect(result).toEqual({ lg: [{ i: 'w1' }] }); + expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique'); + }); + + it('getLayout: kein Widget/keine Anordnung vorhanden liefert die Vorgabeanordnung, keinen Fehler — heutiges Verhalten, damit eine spätere Änderung sichtbar wird', async () => { + const prisma = makeFakePrisma({ layout: null }); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + const result = await service.getLayout('user-1', 'tenant-1'); + + expect(result).toEqual({ lg: [], md: [], sm: [], xs: [], xxs: [] }); + }); + + it('saveLayout: der Schreibzugriff läuft über den gebundenen Klienten mit derselben Mandantenkennung', 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', { layouts: { lg: [] } } as any); + + expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'upsert'); + }); + + 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: {} }, + }); + 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); + + expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique'); + expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'upsert'); + }); + + it('Widgets lesen: der Widget-Lesezugriff läuft gebunden, der Katalogzugriff NICHT', async () => { + const prisma = makeFakePrisma({ widgets: [] }); + 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); + + expect(result).toEqual([]); + expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'findMany'); + expectNeverBound(prisma, 'module'); + }); + + it('Widget anlegen: gebunden, mit der übergebenen Mandantenkennung', async () => { + const prisma = makeFakePrisma({}); + 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); + + expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'create'); + }); + + it('Widget-Konfiguration ändern: BEIDE Abfragen (Besitzprüfung und Änderung) laufen über DENSELBEN gebundenen Klienten und dieselbe Mandantenkennung', async () => { + const widget = makeWidget({ id: 'w1', userId: 'user-1' }); + const prisma = makeFakePrisma({ widgets: [widget] }); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + await service.updateWidgetConfig('w1', 'user-1', 'tenant-1', { config: { foo: 'bar' } } as any); + + expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'findUnique'); + expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'update'); + }); + + it('Widget entfernen: ebenso, beide Abfragen über denselben Klienten', async () => { + const widget = makeWidget({ id: 'w1', userId: 'user-1' }); + const prisma = makeFakePrisma({ widgets: [widget] }); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + await service.removeWidget('w1', 'user-1', 'tenant-1'); + + expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'findUnique'); + expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'delete'); + }); + + it('Besitzprüfung bleibt wirksam beim Ändern: ein Widget eines anderen Benutzers führt weiterhin zu NotFoundException — die Bindung ERGÄNZT die Prüfung über die Benutzerkennung, sie ersetzt sie nicht', async () => { + const widget = makeWidget({ id: 'w1', userId: 'other-user' }); + const prisma = makeFakePrisma({ widgets: [widget] }); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + await expect( + service.updateWidgetConfig('w1', 'user-1', 'tenant-1', { config: {} } as any), + ).rejects.toThrow("Widget with id 'w1' not found"); + }); + + it('Besitzprüfung bleibt wirksam beim Entfernen: ein Widget eines anderen Benutzers führt weiterhin zu NotFoundException', async () => { + const widget = makeWidget({ id: 'w1', userId: 'other-user' }); + const prisma = makeFakePrisma({ widgets: [widget] }); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + await expect(service.removeWidget('w1', 'user-1', 'tenant-1')).rejects.toThrow( + "Widget with id 'w1' not found", + ); + }); + + it('keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf', async () => { + const widget = makeWidget({ id: 'w1', userId: 'user-1' }); + const prisma = makeFakePrisma({ + widgets: [widget], + layout: { 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.updateWidgetConfig('w1', 'user-1', 'tenant-1', { config: {} } as any), + () => service.removeWidget('w1', 'user-1', 'tenant-1'), + ]) { + vi.mocked(forTenant).mockClear(); + await call().catch(() => undefined); + expect( + vi.mocked(forTenant).mock.calls.length, + `Aufruf erzeugte ${vi.mocked(forTenant).mock.calls.length} gebundene Klienten, erwartet genau 1`, + ).toBe(1); + } + }); + + it('Kein Widget vorhanden: der Rückgabewert ist eine leere Liste, kein Fehler — Deutung von Leere als Abwesenheit, heutiges Verhalten', async () => { + const prisma = makeFakePrisma({ widgets: [] }); + 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); + + expect(result).toEqual([]); + }); +}); diff --git a/apps/api/src/dashboard/dashboard.service.ts b/apps/api/src/dashboard/dashboard.service.ts index 7af5480..e0c7864 100644 --- a/apps/api/src/dashboard/dashboard.service.ts +++ b/apps/api/src/dashboard/dashboard.service.ts @@ -1,9 +1,11 @@ import { + ConflictException, Injectable, NotFoundException, } 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 { PrismaService } from '../prisma/prisma.service'; import { CreateSearchProviderDto } from './dto/create-search-provider.dto'; import { CreateWidgetDto } from './dto/create-widget.dto'; @@ -52,7 +54,16 @@ const DEFAULT_SEARCH_PROVIDERS = [ * Layout (position/size) and widget config are stored in separate models * to avoid unnecessary saves when only one changes (RESEARCH anti-pattern). * - * All operations are scoped by userId for security (T-05-01, T-05-02). + * All operations are scoped by userId for security (T-05-01, T-05-02) — the + * three ownership checks in this file (`updateWidgetConfig`, `removeWidget`, + * `removeSearchProvider`) compare against the user id from the session proof + * and are NOT decorative: the RLS rules on `DashboardLayout`, `WidgetInstance` + * and `SearchProvider` know only the tenant dimension, not the user dimension + * (measured 260910-krx, Aufgabe 1, Befund G) — until the switch is flipped + * (WINDOWS #18) they remain the only actually effective protection against + * cross-reading/cross-deleting between two users of the SAME tenant, and the + * `forTenant()` binding below ADDS a tenant boundary on top of them, it never + * replaces them. */ @Injectable() export class DashboardService { @@ -65,8 +76,9 @@ export class DashboardService { * Returns the user's saved layout, or a default empty layout * with all breakpoint arrays initialized. */ - async getLayout(userId: string) { - const record = await this.prisma.dashboardLayout.findUnique({ + async getLayout(userId: string, tenantId: string) { + const tenantPrisma = forTenant(this.prisma, tenantId); + const record = await tenantPrisma.dashboardLayout.findUnique({ where: { userId }, }); @@ -80,17 +92,41 @@ export class DashboardService { /** * Upserts the user's dashboard layout. * 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 + * 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. */ async saveLayout(userId: string, tenantId: string, dto: SaveLayoutDto) { - return this.prisma.dashboardLayout.upsert({ - where: { userId }, - update: { layouts: dto.layouts as unknown as Prisma.InputJsonValue }, - create: { - userId, - tenantId, - layouts: dto.layouts as unknown as Prisma.InputJsonValue, - }, - }); + const tenantPrisma = forTenant(this.prisma, tenantId); + try { + return await tenantPrisma.dashboardLayout.upsert({ + where: { userId }, + update: { layouts: dto.layouts as unknown as Prisma.InputJsonValue }, + create: { + userId, + tenantId, + layouts: dto.layouts as unknown as Prisma.InputJsonValue, + }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientUnknownRequestError) { + throw new ConflictException( + 'Die Dashboard-Anordnung konnte nicht gespeichert werden, weil bereits ein widersprüchlicher Eintrag existiert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.', + ); + } + throw error; + } } /** @@ -108,7 +144,8 @@ export class DashboardService { * betroffene Widget entfernt (Fail-Closed). */ async getWidgets(userId: string, tenantId: string, role: Role) { - const widgets = await this.prisma.widgetInstance.findMany({ + const tenantPrisma = forTenant(this.prisma, tenantId); + const widgets = await tenantPrisma.widgetInstance.findMany({ where: { userId }, orderBy: { createdAt: 'asc' }, }); @@ -125,12 +162,16 @@ export class DashboardService { return widgets; } + // getAccessibleModuleIds() already binds internally (260910-exd, + // module-access.service.ts) — do NOT wrap it a second time here. const accessibleModuleIds = await this.moduleAccessService.getAccessibleModuleIds( tenantId, userId, role, ); + // Module catalogue: deliberately left UNBOUND — see the reasoning at + // the bottom of this file (260910-krx, Aufgabe 3). const modules = await this.prisma.module.findMany({ where: { slug: { in: boundSlugs } }, select: { id: true, slug: true }, @@ -154,7 +195,8 @@ export class DashboardService { * Creates a new widget instance for the user. */ async addWidget(userId: string, tenantId: string, dto: CreateWidgetDto) { - return this.prisma.widgetInstance.create({ + const tenantPrisma = forTenant(this.prisma, tenantId); + return tenantPrisma.widgetInstance.create({ data: { userId, tenantId, @@ -166,14 +208,23 @@ export class DashboardService { /** * Updates the config of a widget instance. - * Verifies ownership by userId before updating (T-05-01). + * Verifies ownership by userId before updating (T-05-01) — REAL, not + * decorative (unlike the `ldap`/`dkv` findUnique-then-write shape that + * produced this effort's first two vulnerabilities): `widget.userId !== + * userId` genuinely compares against the session-sourced user id and + * subsumes the tenant dimension. Both queries below run over the SAME + * bound client and the same tenant id — reading and writing are never + * split across the binding, or the check could pass on a row the write no + * longer sees, or vice versa (260910-krx, Aufgabe 1, Befund D). */ async updateWidgetConfig( id: string, userId: string, + tenantId: string, dto: UpdateWidgetConfigDto, ) { - const widget = await this.prisma.widgetInstance.findUnique({ + const tenantPrisma = forTenant(this.prisma, tenantId); + const widget = await tenantPrisma.widgetInstance.findUnique({ where: { id }, }); @@ -189,7 +240,7 @@ export class DashboardService { ...dto.config, }; - return this.prisma.widgetInstance.update({ + return tenantPrisma.widgetInstance.update({ where: { id }, data: { config: mergedConfig as unknown as Prisma.InputJsonValue }, }); @@ -197,10 +248,13 @@ export class DashboardService { /** * Removes a widget instance. - * Verifies ownership by userId before deleting (T-05-01). + * Verifies ownership by userId before deleting (T-05-01) — same real + * ownership check as `updateWidgetConfig` above, same reasoning: both + * queries run over the SAME bound client and tenant id. */ - async removeWidget(id: string, userId: string) { - const widget = await this.prisma.widgetInstance.findUnique({ + async removeWidget(id: string, userId: string, tenantId: string) { + const tenantPrisma = forTenant(this.prisma, tenantId); + const widget = await tenantPrisma.widgetInstance.findUnique({ where: { id }, }); @@ -210,7 +264,7 @@ export class DashboardService { ); } - return this.prisma.widgetInstance.delete({ + return tenantPrisma.widgetInstance.delete({ where: { id }, }); } diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 95d7255..a8b47da 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -294,10 +294,10 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit | apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. | | apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gemischt | `getMe`, `changePassword`, `adminResetPassword` suchen über die Benutzerkennung aus dem Sitzungsnachweis — der Mandant ist dort bereits bekannt (Aufgabe 2 fasst sie bewusst nicht an, siehe SUMMARY). | | apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | ungebunden | Kalenderquellen eines Nutzers, `tenantId`-Spalte vorhanden. | -| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | ungebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. | +| 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`) in eine deutsche Konfliktmeldung. Vollstaendige Nachziehung (Uebersichtszeile, Summenzeile, Klassen-Verteilung) folgt in Aufgabe 3 mit dem Rest des Bereichs. | | apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). | | apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | ungebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell: lokal gemessen null Zeilen mit `tenantId = NULL` insgesamt, dieser Schreibweg verlangt die Mandantenkennung als Pflichtparameter; Vorgabe-Anbieter kommen laut 05-02 aus Konstanten, nicht aus der DB. Die Regel auf `SearchProvider` bleibt deshalb unverändert streng. | -| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | ungebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. | +| 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). Vollstaendige Nachziehung folgt in Aufgabe 3. | | 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 | gemischt | 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()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnyActiveConfigForScheduler()` (WINDOWS #21) — keine uebersehene Fundstelle. | | 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). |