From 737974b65324c114f2aa7f6bcbcae7cc83eb2a7e Mon Sep 17 00:00:00 2001 From: Schalli Date: Mon, 21 Sep 2026 18:47:07 +0200 Subject: [PATCH] feat(quick-260921-pi9): Bilderrahmen-API - Bilder je Benutzer in der Datenbank, Magic-Byte-Pruefung, 5 MiB / 30 Stueck - Prisma-Modell DashboardImage (bytea) mit Migration 20260921120000: Tabelle, Indizes, RLS ENABLE/FORCE und tenant_isolation_policy mit Benutzerdimension - dashboard-image-rules.ts: detectImageMime ueber Magic Bytes (PNG/JPEG/GIF/ WebP), Grenzen 5 MiB je Datei und 30 je Benutzer - DashboardImagesService: list/upload/getBytes/remove, je Methode forTenant(prisma, tenantId, userId); Besitz = Mandant UND Benutzer, sonst 404 - DashboardImagesController unter dashboard/images: GET, POST (FileInterceptor image, 5 MiB, eine Datei), GET :id mit Content-Type aus dem erkannten Typ, Cache-Control private, nosniff, Content-Disposition inline ohne Dateinamen, CSP sandbox; DELETE :id - CreateWidgetDto kennt 'picture-frame' - Klassifikationsdokument: neues Paar dashboard-images.service.ts/ dashboardImage; Bereichs- und Summenzeilen nachgemessen (dashboard 12->18, settings 3->4 und bug-reports waren in der Summe nie mitgezaehlt) - Befund: Prisma-Bytes verlangt Uint8Array, multers Buffer wird ohne Zusicherung abgelehnt - Kopie per new Uint8Array(buffer) statt Cast Co-Authored-By: Claude Opus 5 (1M context) --- .../migration.sql | 54 ++++ apps/api/prisma/schema.prisma | 20 ++ .../dashboard/dashboard-image-rules.spec.ts | 76 +++++ .../src/dashboard/dashboard-image-rules.ts | 51 +++ .../dashboard-images.controller.spec.ts | 122 ++++++++ .../dashboard/dashboard-images.controller.ts | 87 ++++++ .../dashboard-images.service.spec.ts | 291 ++++++++++++++++++ .../src/dashboard/dashboard-images.service.ts | 148 +++++++++ apps/api/src/dashboard/dashboard.module.ts | 8 +- .../src/dashboard/dto/create-widget.dto.ts | 14 +- ...andantentrennung-zugriffsklassifikation.md | 23 +- 11 files changed, 884 insertions(+), 10 deletions(-) create mode 100644 apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql create mode 100644 apps/api/src/dashboard/dashboard-image-rules.spec.ts create mode 100644 apps/api/src/dashboard/dashboard-image-rules.ts create mode 100644 apps/api/src/dashboard/dashboard-images.controller.spec.ts create mode 100644 apps/api/src/dashboard/dashboard-images.controller.ts create mode 100644 apps/api/src/dashboard/dashboard-images.service.spec.ts create mode 100644 apps/api/src/dashboard/dashboard-images.service.ts diff --git a/apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql b/apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql new file mode 100644 index 0000000..b36bc05 --- /dev/null +++ b/apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql @@ -0,0 +1,54 @@ +-- 260921-pi9 — Bilderrahmen-Widget: hochgeladene Bilder eines Benutzers. +-- +-- Zweck: die Tabelle "DashboardImage" traegt die Bilddaten (bytea) fuer das +-- Dashboard-Widget „Bilderrahmen“. Bilder liegen in der Datenbank statt in +-- einem Docker-Volume, damit die bestehende Sicherung sie mit abdeckt. +-- +-- Grenzen (durchgesetzt in der Anwendung, apps/api/src/dashboard/ +-- dashboard-image-rules.ts + dashboard-images.service.ts): hoechstens 5 MiB +-- je Datei (multer-Limit je Route), hoechstens 30 Bilder je Benutzer +-- (Zaehler je Mandant+Benutzer vor dem Anlegen); erlaubt sind nur PNG, JPEG, +-- GIF und WebP, erkannt an den Magic Bytes — "mimeType" ist der ERKANNTE Typ, +-- nie der vom Browser behauptete. +-- +-- Besitz: ein Bild gehoert dem hochladenden Benutzer (gleicher Mandant UND +-- gleicher Benutzer). Die Regel unten traegt deshalb von Anfang an die +-- Benutzerdimension (Form aus 20260911120000_rls_user_dimension_personal_ +-- tables); die Anwendung prueft den Besitz zusaetzlich in getBytes/remove und +-- antwortet fuer fremde Kennungen mit 404 (nie 403). +-- +-- Rechte fuer die Anwendungsrolle tessera_app: kommen ueber ALTER DEFAULT +-- PRIVILEGES aus 20260909130000_rls_app_role automatisch — hier nichts zu tun. +-- +-- WICHTIG: wie alle RLS-Regeln dieses Schemas wirkt die Regel erst, wenn die +-- Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS, siehe +-- docs/mandantentrennung-datenbankrolle.md). + +-- CreateTable +CREATE TABLE "DashboardImage" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "originalName" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "data" BYTEA NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DashboardImage_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "DashboardImage_userId_idx" ON "DashboardImage"("userId"); + +-- CreateIndex +CREATE INDEX "DashboardImage_tenantId_idx" ON "DashboardImage"("tenantId"); + +-- Zeilenschutz: Mandant UND Benutzer (Muster 20260911120000) +ALTER TABLE "DashboardImage" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "DashboardImage" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "DashboardImage" + USING ( + "tenantId" = current_tenant_id() + AND (current_user_id() IS NULL OR "userId" = current_user_id()) + ); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 1278451..c09cad0 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -212,6 +212,26 @@ model WidgetInstance { @@index([tenantId]) } +// Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers, +// als bytea in der Datenbank (kein Docker-Volume, die Sicherung deckt es mit +// ab). Keine Relation — wie WidgetInstance. Grenzen (5 MiB je Datei, 30 je +// Benutzer) und die Magic-Byte-Erkennung leben in +// src/dashboard/dashboard-image-rules.ts; Besitz = gleicher Mandant UND +// gleicher Benutzer (Regel in Migration 20260921120000 mit Benutzerdimension). +model DashboardImage { + id String @id @default(uuid()) + userId String + tenantId String + originalName String + mimeType String + size Int + data Bytes + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([tenantId]) +} + model SearchProvider { id String @id @default(uuid()) userId String? diff --git a/apps/api/src/dashboard/dashboard-image-rules.spec.ts b/apps/api/src/dashboard/dashboard-image-rules.spec.ts new file mode 100644 index 0000000..57973cc --- /dev/null +++ b/apps/api/src/dashboard/dashboard-image-rules.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { + DASHBOARD_IMAGE_MAX_BYTES, + DASHBOARD_IMAGE_MAX_COUNT, + detectImageMime, +} from './dashboard-image-rules'; + +/** + * dashboard-image-rules.spec — NEU (quick-260921-pi9, Bilderrahmen-Widget). + * + * Die Magic-Byte-Erkennung entscheidet Annahme UND gespeicherten Typ eines + * hochgeladenen Bildes (T-PI9-01); Dateiname und behaupteter MIME-Typ werden + * nie gelesen. Deshalb wird sie hier direkt an den Bytes geprueft: vier + * gueltige Signaturen, und alles, was NICHT durchgehen darf (leer, Text, + * SVG, PDF, RIFF ohne WEBP). + */ +function bytes(...parts: (number[] | string)[]): Uint8Array { + const out: number[] = []; + for (const p of parts) { + if (typeof p === 'string') { + for (const ch of p) out.push(ch.charCodeAt(0)); + } else { + out.push(...p); + } + } + return Uint8Array.from(out); +} + +describe('detectImageMime (quick-260921-pi9)', () => { + it('Test 1: PNG-Signatur 89 50 4E 47 0D 0A 1A 0A -> image/png', () => { + expect(detectImageMime(bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], [0, 0, 0, 13]))).toBe( + 'image/png', + ); + }); + + it('Test 2: FF D8 FF -> image/jpeg', () => { + expect(detectImageMime(bytes([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10], 'JFIF'))).toBe('image/jpeg'); + }); + + it('Test 3: GIF87a und GIF89a -> image/gif', () => { + expect(detectImageMime(bytes('GIF87a', [1, 0, 1, 0]))).toBe('image/gif'); + expect(detectImageMime(bytes('GIF89a', [1, 0, 1, 0]))).toBe('image/gif'); + }); + + it('Test 4: RIFF????WEBP -> image/webp', () => { + expect(detectImageMime(bytes('RIFF', [0x24, 0x00, 0x00, 0x00], 'WEBP', 'VP8 '))).toBe('image/webp'); + }); + + it('Test 5: leerer Puffer -> null', () => { + expect(detectImageMime(new Uint8Array(0))).toBeNull(); + }); + + it('Test 6: Textdatei -> null, auch wenn sie lang genug ist', () => { + expect(detectImageMime(bytes('Dies ist keine Bilddatei, sondern Text.'))).toBeNull(); + }); + + it('Test 7: SVG-Text und PDF (%PDF) -> null', () => { + expect(detectImageMime(bytes(''))).toBeNull(); + expect(detectImageMime(bytes('%PDF-1.7\n%\xe2\xe3'))).toBeNull(); + }); + + it('Test 8: RIFF-Kopf ohne WEBP an Stelle 8 (z. B. WAVE) -> null', () => { + expect(detectImageMime(bytes('RIFF', [0x24, 0x00, 0x00, 0x00], 'WAVE', 'fmt '))).toBeNull(); + }); + + it('Test 9: zu kurze Puffer werfen nicht — PNG-Anfang mit 4 Bytes ist null, JPEG-Anfang mit 2 Bytes ist null', () => { + expect(detectImageMime(bytes([0x89, 0x50, 0x4e, 0x47]))).toBeNull(); + expect(detectImageMime(bytes([0xff, 0xd8]))).toBeNull(); + expect(detectImageMime(bytes('RIFF'))).toBeNull(); + }); + + it('Test 10: Grenzen — 5 MiB je Datei, 30 Bilder je Benutzer', () => { + expect(DASHBOARD_IMAGE_MAX_BYTES).toBe(5 * 1024 * 1024); + expect(DASHBOARD_IMAGE_MAX_COUNT).toBe(30); + }); +}); diff --git a/apps/api/src/dashboard/dashboard-image-rules.ts b/apps/api/src/dashboard/dashboard-image-rules.ts new file mode 100644 index 0000000..eebdf80 --- /dev/null +++ b/apps/api/src/dashboard/dashboard-image-rules.ts @@ -0,0 +1,51 @@ +/** + * dashboard-image-rules — reine Regeln fuer das Bilderrahmen-Widget + * (quick-260921-pi9). Kein Nest, kein Prisma: Grenzen und die + * Magic-Byte-Erkennung, damit Dienst und Controller dieselben Werte + * anwenden und die Erkennung direkt an den Bytes testbar ist. + * + * Warum Magic Bytes und nicht `file.mimetype` oder die Dateiendung + * (T-PI9-01): beides behauptet der Browser, beides kann eine umbenannte + * HTML-, SVG- oder PDF-Datei tragen. Was hier NICHT als eine der vier + * Signaturen erkannt wird, kommt nicht in die Datenbank — und der erkannte + * Typ ist zugleich der Typ, mit dem die Auslieferung antwortet (T-PI9-08). + * + * Bewusst KEIN `file-type`-Paket (T-PI9-SC): vier feste Signaturen sind + * eine Handvoll Zeilen und brauchen keine Abhaengigkeit. + */ + +/** Hoechstgroesse je Datei: 5 MiB (multer `limits.fileSize` je Route). */ +export const DASHBOARD_IMAGE_MAX_BYTES = 5 * 1024 * 1024; + +/** Hoechstzahl je Benutzer (Zaehler je Mandant+Benutzer vor dem Anlegen). */ +export const DASHBOARD_IMAGE_MAX_COUNT = 30; + +export type DashboardImageMime = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'; + +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; +const JPEG_SIGNATURE = [0xff, 0xd8, 0xff]; +const GIF87A = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]; // "GIF87a" +const GIF89A = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]; // "GIF89a" +const RIFF = [0x52, 0x49, 0x46, 0x46]; // "RIFF" +const WEBP = [0x57, 0x45, 0x42, 0x50]; // "WEBP" + +function startsWith(buffer: Uint8Array, signature: number[], offset = 0): boolean { + if (buffer.length < offset + signature.length) return false; + for (let i = 0; i < signature.length; i++) { + if (buffer[offset + i] !== signature[i]) return false; + } + return true; +} + +/** + * Erkennt PNG, JPEG, GIF und WebP an den ersten Bytes; alles andere + * (leer, Text, SVG, PDF, RIFF ohne WEBP an Stelle 8) ergibt `null`. + * Wirft nie — ein zu kurzer Puffer ist schlicht kein Bild. + */ +export function detectImageMime(buffer: Uint8Array): DashboardImageMime | null { + if (startsWith(buffer, PNG_SIGNATURE)) return 'image/png'; + if (startsWith(buffer, JPEG_SIGNATURE)) return 'image/jpeg'; + if (startsWith(buffer, GIF87A) || startsWith(buffer, GIF89A)) return 'image/gif'; + if (startsWith(buffer, RIFF) && startsWith(buffer, WEBP, 8)) return 'image/webp'; + return null; +} diff --git a/apps/api/src/dashboard/dashboard-images.controller.spec.ts b/apps/api/src/dashboard/dashboard-images.controller.spec.ts new file mode 100644 index 0000000..95346ea --- /dev/null +++ b/apps/api/src/dashboard/dashboard-images.controller.spec.ts @@ -0,0 +1,122 @@ +import 'reflect-metadata'; +import { describe, expect, it, vi } from 'vitest'; + +/** + * `FileInterceptor` wird als Attrappe eingesetzt, damit die Grenzen je + * Route (T-PI9-02) am AUFRUF pruefbar sind — die echte Fabrik verschliesst + * ihre Optionen in einer Klasse ohne lesbare Metadaten. + */ +const { fileInterceptorMock } = vi.hoisted(() => ({ + fileInterceptorMock: vi.fn(() => class FakeInterceptor {}), +})); +vi.mock('@nestjs/platform-express', () => ({ FileInterceptor: fileInterceptorMock })); + +import { ROLES_KEY } from '../auth/decorators/roles.decorator'; +import type { AuthUser } from '../auth/types/auth-user'; +import { DASHBOARD_IMAGE_MAX_BYTES } from './dashboard-image-rules'; +import { DashboardImagesController } from './dashboard-images.controller'; + +/** + * dashboard-images.controller.spec — NEU (quick-260921-pi9). + * + * Fuenf Tests an der Grenze Browser -> API: Pfad und Routenreihenfolge + * (statisch vor `:id`), Interceptor-Grenzen (5 MiB, genau eine Datei, Feld + * `image`), die Header der Binaerantwort (Content-Type aus dem gespeicherten + * Typ, private Cache, nosniff, inline OHNE Dateinamen, CSP), kein + * `@Roles`-Metadatum, und die Weitergabe von Mandant/Benutzer ausschliesslich + * aus dem Sitzungsnachweis (T-PI9-11). + */ +const user: AuthUser = { + id: 'user-1', + username: 'anna', + role: 'USER', + tenantId: 'tenant-1', + mustChangePassword: false, +}; + +function makeService() { + return { + list: vi.fn(async () => []), + upload: vi.fn(async () => ({ id: 'new' })), + getBytes: vi.fn(async () => ({ + mimeType: 'image/webp', + data: Uint8Array.from([0x52, 0x49, 0x46, 0x46]), + })), + remove: vi.fn(async (id: string) => ({ id })), + }; +} + +function makeRes() { + const headers: Record = {}; + return { + headers, + setHeader: vi.fn((name: string, value: string) => { + headers[name] = value; + }), + send: vi.fn(), + }; +} + +// Kein vi.clearAllMocks() vor den Tests: der Interceptor-Aufruf geschieht +// beim Laden der Klasse (Dekorator), Test 2 liest genau diesen Aufruf. +describe('DashboardImagesController (quick-260921-pi9)', () => { + it('Test 1: Pfad dashboard/images; Reihenfolge list -> upload -> getImage -> remove (statische Routen vor :id)', () => { + expect(Reflect.getMetadata('path', DashboardImagesController)).toBe('dashboard/images'); + const proto = DashboardImagesController.prototype; + const order = Object.getOwnPropertyNames(proto).filter((n) => n !== 'constructor'); + expect(order).toEqual(['list', 'upload', 'getImage', 'remove']); + expect(Reflect.getMetadata('path', proto.getImage)).toBe(':id'); + expect(Reflect.getMetadata('path', proto.remove)).toBe(':id'); + expect(Reflect.getMetadata('path', proto.list)).toBe('/'); + expect(Reflect.getMetadata('path', proto.upload)).toBe('/'); + }); + + it('Test 2: FileInterceptor nimmt das Feld image mit fileSize 5 MiB und files 1 (T-PI9-02)', () => { + expect(fileInterceptorMock).toHaveBeenCalledTimes(1); + expect(fileInterceptorMock.mock.calls[0]).toEqual([ + 'image', + { limits: { fileSize: DASHBOARD_IMAGE_MAX_BYTES, files: 1 } }, + ]); + expect(DASHBOARD_IMAGE_MAX_BYTES).toBe(5 * 1024 * 1024); + }); + + it('Test 3: Binaerantwort traegt Content-Type aus dem gespeicherten Typ, Cache private, nosniff, inline ohne Dateinamen, CSP; Bytes als Buffer', async () => { + const service = makeService(); + const controller = new DashboardImagesController(service as never); + const res = makeRes(); + + await controller.getImage('img-1', user, res as never); + + expect(service.getBytes).toHaveBeenCalledWith('img-1', 'user-1', 'tenant-1'); + expect(res.headers['Content-Type']).toBe('image/webp'); + expect(res.headers['Cache-Control']).toBe('private, max-age=86400'); + expect(res.headers['X-Content-Type-Options']).toBe('nosniff'); + expect(res.headers['Content-Disposition']).toBe('inline'); + expect(res.headers['Content-Security-Policy']).toBe("default-src 'none'; sandbox"); + const sent = res.send.mock.calls[0][0] as unknown; + expect(Buffer.isBuffer(sent)).toBe(true); + expect((sent as Buffer).equals(Buffer.from([0x52, 0x49, 0x46, 0x46]))).toBe(true); + }); + + it('Test 4: kein @Roles-Metadatum auf einer der vier Routen — alle angemeldeten Rollen', () => { + const proto = DashboardImagesController.prototype; + for (const name of ['list', 'upload', 'getImage', 'remove'] as const) { + expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined(); + } + }); + + it('Test 5: list/upload/remove reichen Mandant und Benutzer nur aus dem Sitzungsnachweis weiter; remove antwortet { id }', async () => { + const service = makeService(); + const controller = new DashboardImagesController(service as never); + const file = { buffer: Buffer.alloc(0), originalname: 'x.png', mimetype: 'image/png', size: 0 }; + + await controller.list(user); + expect(service.list).toHaveBeenCalledWith('user-1', 'tenant-1'); + + await controller.upload(user, file); + expect(service.upload).toHaveBeenCalledWith(user, file); + + await expect(controller.remove('img-9', user)).resolves.toEqual({ id: 'img-9' }); + expect(service.remove).toHaveBeenCalledWith('img-9', 'user-1', 'tenant-1'); + }); +}); diff --git a/apps/api/src/dashboard/dashboard-images.controller.ts b/apps/api/src/dashboard/dashboard-images.controller.ts new file mode 100644 index 0000000..92c109f --- /dev/null +++ b/apps/api/src/dashboard/dashboard-images.controller.ts @@ -0,0 +1,87 @@ +import { + Controller, + Delete, + Get, + Param, + Post, + Res, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { Response } from 'express'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user'; +import { DASHBOARD_IMAGE_MAX_BYTES } from './dashboard-image-rules'; +import { DashboardImagesService } from './dashboard-images.service'; + +/** + * Bilder des Bilderrahmen-Widgets (quick-260921-pi9). + * + * Offen fuer ALLE angemeldeten Rollen: bewusst KEIN Rollen-Dekorator + * (Muster bug-reports.controller.ts — der globale `JwtAuthGuard` verlangt + * weiterhin eine Sitzung). Mandant und Benutzer kommen NUR aus dem + * Sitzungsnachweis (`@CurrentUser()`), nie aus dem Rumpf (T-PI9-11); der + * Multipart-Rumpf hat genau das Feld `image`. + * + * Groessenlimit JE ROUTE (T-PI9-02): `FileInterceptor` nimmt genau eine + * Datei bis 5 MiB; multers `LIMIT_FILE_SIZE` bildet Nest auf 413 ab (die + * deutsche Meldung dafuer entsteht im Web-Klienten aus dem Status). + * `main.ts` bleibt ohne globales Body-Limit. + * + * Routen (statische VOR `:id`, sonst 404-Shadowing): + * - GET /dashboard/images — eigene Bilder, nur Metadaten + * - POST /dashboard/images — Bild hochladen (multipart-Feld `image`) + * - GET /dashboard/images/:id — Bytes eines eigenen Bildes + * - DELETE /dashboard/images/:id — eigenes Bild loeschen + */ +@Controller('dashboard/images') +export class DashboardImagesController { + constructor(private readonly service: DashboardImagesService) {} + + @Get() + async list(@CurrentUser() user: AuthUser) { + return this.service.list(user.id, user.tenantId); + } + + @Post() + @UseInterceptors( + FileInterceptor('image', { limits: { fileSize: DASHBOARD_IMAGE_MAX_BYTES, files: 1 } }), + ) + async upload(@CurrentUser() user: AuthUser, @UploadedFile() file?: UploadedFileLike) { + return this.service.upload(user, file); + } + + /** + * Binaerantwort mit den Headern aus T-PI9-06/08/10: der Typ kommt aus der + * gespeicherten Magic-Byte-Erkennung, `nosniff` verbietet dem Browser das + * Umdeuten, `Content-Disposition: inline` traegt bewusst KEINEN Dateinamen + * (`originalName` gehoert in keinen Header), `private` haelt gemeinsame + * Zwischenspeicher (Nginx Proxy Manager) fern, die CSP laesst ein direkt im + * Tab geoeffnetes Bild ohne Skript und ohne Tessera-Origin laufen (Muster + * favorites.controller.ts `getIcon`). + * + * Prisma 6 liefert `Bytes` als `Uint8Array` — `Buffer.from(...)` ueber + * denselben Speicher, keine Kopie, keine Zusicherung. + */ + @Get(':id') + async getImage( + @Param('id') id: string, + @CurrentUser() user: AuthUser, + @Res() res: Response, + ) { + const { mimeType, data } = await this.service.getBytes(id, user.id, user.tenantId); + + res.setHeader('Content-Type', mimeType); + res.setHeader('Cache-Control', 'private, max-age=86400'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Disposition', 'inline'); + res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox"); + res.send(Buffer.from(data.buffer, data.byteOffset, data.byteLength)); + } + + @Delete(':id') + async remove(@Param('id') id: string, @CurrentUser() user: AuthUser) { + return this.service.remove(id, user.id, user.tenantId); + } +} diff --git a/apps/api/src/dashboard/dashboard-images.service.spec.ts b/apps/api/src/dashboard/dashboard-images.service.spec.ts new file mode 100644 index 0000000..4ef1101 --- /dev/null +++ b/apps/api/src/dashboard/dashboard-images.service.spec.ts @@ -0,0 +1,291 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * Bindung an forTenant() — dasselbe Muster wie dashboard.service.spec.ts + * (260910-krx): der gebundene Klient ist ein ZWEITES, von `prisma` + * unterscheidbares Objekt ueber DEMSELBEN Speicher, das protokolliert, + * welche Aufrufe ueber ihn liefen. Ein vergessener Bindungsaufruf faellt + * damit auf (`prisma.dashboardImage` waere dann ohne Protokoll-Eintrag). + */ +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((prisma: FakePrisma, tenantId: string, userId?: string) => + prisma.__makeBoundClient(tenantId, userId), + ), +})); + +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user'; +import { forTenant } from '../prisma/prisma-tenant.extension'; +import { DashboardImagesService } from './dashboard-images.service'; + +/** + * dashboard-images.service.spec — NEU (quick-260921-pi9, Bilderrahmen). + * + * Elf Faelle an der Grenze Dienst -> Datenbank: Liste ohne `data`, Upload + * ohne Datei, Magic Bytes schlagen den behaupteten MIME-Typ in BEIDE + * Richtungen (T-PI9-01), Zaehler 30 (T-PI9-03), fremder Benutzer UND + * fremder Mandant -> 404 (T-PI9-04, nie 403), eigenes Bild liefert Bytes, + * Loeschen eigen/fremd, und der Nachweis, dass jede Methode + * `forTenant(prisma, tenantId, userId)` mit dem Benutzer als drittem + * Argument aufruft. + */ + +interface ImageRow { + id: string; + userId: string; + tenantId: string; + originalName: string; + mimeType: string; + size: number; + data: Uint8Array; + createdAt: Date; +} + +interface BoundCall { + tenantId: string; + userId: string | undefined; + model: string; + method: string; +} + +type ModelMethods = Record Promise>; + +interface FakePrisma { + dashboardImage: ModelMethods; + __rows: ImageRow[]; + __boundCallLog: BoundCall[]; + __makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods }; +} + +const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]); +const TEXT = Buffer.from('nur Text, kein Bild'); + +function makeRow(overrides: Partial = {}): ImageRow { + return { + id: overrides.id ?? 'img-1', + userId: overrides.userId ?? 'user-1', + tenantId: overrides.tenantId ?? 'tenant-1', + originalName: overrides.originalName ?? 'foto.png', + mimeType: overrides.mimeType ?? 'image/png', + size: overrides.size ?? PNG.length, + data: overrides.data ?? Uint8Array.from(PNG), + createdAt: overrides.createdAt ?? new Date('2026-01-01'), + }; +} + +function pick(row: ImageRow, select: Record | undefined) { + if (!select) return row; + const out: Record = {}; + for (const key of Object.keys(select)) { + if (select[key]) out[key] = row[key as keyof ImageRow]; + } + return out; +} + +function makeFakePrisma(rows: ImageRow[] = []): FakePrisma { + const boundCallLog: BoundCall[] = []; + const dashboardImage: ModelMethods = { + findMany: vi.fn(async (raw: unknown) => { + const args = raw as { where: { tenantId: string; userId: string }; select?: Record }; + return rows + .filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId) + .slice() + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + .map((r) => pick(r, args.select)); + }), + count: vi.fn(async (raw: unknown) => { + const args = raw as { where: { tenantId: string; userId: string } }; + return rows.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId).length; + }), + create: vi.fn(async (raw: unknown) => { + const args = raw as { data: Omit; select?: Record }; + const created = makeRow({ id: `new-${rows.length + 1}`, ...args.data, createdAt: new Date('2026-02-02') }); + rows.push(created); + return pick(created, args.select); + }), + findUnique: vi.fn(async (raw: unknown) => { + const args = raw as { where: { id: string } }; + return rows.find((r) => r.id === args.where.id) ?? null; + }), + delete: vi.fn(async (raw: unknown) => { + const args = raw as { where: { id: string } }; + const idx = rows.findIndex((r) => r.id === args.where.id); + if (idx === -1) return null; + const [removed] = rows.splice(idx, 1); + return removed; + }), + }; + + const fake: FakePrisma = { + dashboardImage, + __rows: rows, + __boundCallLog: boundCallLog, + __makeBoundClient(tenantId: string, userId?: string) { + const wrapped: ModelMethods = {}; + for (const method of Object.keys(dashboardImage)) { + wrapped[method] = async (...args: unknown[]) => { + boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method }); + return dashboardImage[method](...args); + }; + } + return { dashboardImage: wrapped }; + }, + }; + return fake; +} + +function makeService(prisma: FakePrisma) { + // Der Dienst verlangt einen PrismaService; die Attrappe deckt genau das + // Modell ab, das der Dienst anfasst (nur ueber den gebundenen Klienten). + // `as never` statt einer Doppelzusicherung ueber unknown (Muster + // tender-mail.service.spec.ts; der Zusicherungs-Zaehler bleibt bei 27). + return new DashboardImagesService(prisma as never); +} + +const user: AuthUser = { + id: 'user-1', + username: 'anna', + role: 'USER', + tenantId: 'tenant-1', + mustChangePassword: false, +}; + +function file(buffer: Buffer, mimetype: string, originalname = 'foto.png'): UploadedFileLike { + return { buffer, mimetype, originalname, size: buffer.length }; +} + +beforeEach(() => { + vi.mocked(forTenant).mockClear(); +}); + +describe('DashboardImagesService (quick-260921-pi9)', () => { + it('Test 1: list liefert nur eigene Zeilen, nur Metadaten (nie data), aelteste zuerst', async () => { + const prisma = makeFakePrisma([ + makeRow({ id: 'b', createdAt: new Date('2026-03-01') }), + makeRow({ id: 'a', createdAt: new Date('2026-01-01') }), + makeRow({ id: 'fremd', userId: 'user-2' }), + ]); + const result = await makeService(prisma).list('user-1', 'tenant-1'); + expect(result.map((r) => r.id)).toEqual(['a', 'b']); + for (const r of result) { + expect(Object.keys(r).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']); + } + const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record }; + expect(call.select.data).toBeUndefined(); + }); + + it('Test 2: upload ohne Datei -> BadRequestException mit deutscher Meldung', async () => { + const prisma = makeFakePrisma(); + await expect(makeService(prisma).upload(user, undefined)).rejects.toThrow( + new BadRequestException('Bitte wählen Sie eine Bilddatei aus.'), + ); + expect(prisma.dashboardImage.create).not.toHaveBeenCalled(); + }); + + it('Test 3: PNG-Bytes mit behauptetem text/plain gelingen und speichern image/png (Magic Bytes, nicht mimetype)', async () => { + const prisma = makeFakePrisma(); + const result = await makeService(prisma).upload(user, file(PNG, 'text/plain', 'irgendwas.txt')); + expect(result.mimeType).toBe('image/png'); + expect(result.originalName).toBe('irgendwas.txt'); + expect(result.size).toBe(PNG.length); + expect(Object.keys(result).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']); + expect(prisma.__rows[0].userId).toBe('user-1'); + expect(prisma.__rows[0].tenantId).toBe('tenant-1'); + }); + + it('Test 4: Textdatei mit behauptetem image/png scheitert mit deutscher Meldung, nichts wird angelegt', async () => { + const prisma = makeFakePrisma(); + await expect(makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png'))).rejects.toThrow( + new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'), + ); + expect(prisma.dashboardImage.create).not.toHaveBeenCalled(); + }); + + it('Test 5: Zaehler — 30 eigene Bilder blocken das 31., 29 lassen das 30. durch', async () => { + const thirty = Array.from({ length: 30 }, (_, i) => makeRow({ id: `i${i}` })); + const full = makeFakePrisma(thirty); + await expect(makeService(full).upload(user, file(PNG, 'image/png'))).rejects.toThrow( + new BadRequestException( + 'Sie haben die Höchstzahl von 30 Bildern erreicht. Bitte löschen Sie zuerst ein Bild.', + ), + ); + expect(full.dashboardImage.create).not.toHaveBeenCalled(); + + const almost = makeFakePrisma(thirty.slice(0, 29)); + await expect(makeService(almost).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({ + mimeType: 'image/png', + }); + }); + + it('Test 6: Zaehler zaehlt nur den eigenen Benutzer im eigenen Mandanten (fremde Zeilen zaehlen nicht)', async () => { + const foreign = Array.from({ length: 30 }, (_, i) => makeRow({ id: `f${i}`, userId: 'user-2' })); + const prisma = makeFakePrisma(foreign); + await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({ + mimeType: 'image/png', + }); + const countArgs = vi.mocked(prisma.dashboardImage.count).mock.calls[0][0] as { where: unknown }; + expect(countArgs.where).toEqual({ tenantId: 'tenant-1', userId: 'user-1' }); + }); + + it('Test 7: originalName wird auf 255 Zeichen gekuerzt', async () => { + const prisma = makeFakePrisma(); + const result = await makeService(prisma).upload(user, file(PNG, 'image/png', 'x'.repeat(400))); + expect(result.originalName).toHaveLength(255); + }); + + it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => { + const prisma = makeFakePrisma([makeRow({ id: 'img-1', userId: 'user-2' })]); + await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); + }); + + it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => { + const prisma = makeFakePrisma([makeRow({ id: 'img-1', tenantId: 'tenant-2' })]); + const service = makeService(prisma); + await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); + await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); + }); + + it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => { + const prisma = makeFakePrisma([makeRow({ id: 'img-1' })]); + const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1'); + expect(result.mimeType).toBe('image/png'); + expect(Buffer.from(result.data).equals(PNG)).toBe(true); + }); + + it('Test 11: remove — eigenes Bild wird geloescht und { id } geliefert; fremdes (Benutzer ODER Mandant) -> 404 ohne Loeschung', async () => { + const prisma = makeFakePrisma([ + makeRow({ id: 'eigen' }), + makeRow({ id: 'fremd-user', userId: 'user-2' }), + makeRow({ id: 'fremd-tenant', tenantId: 'tenant-2' }), + ]); + const service = makeService(prisma); + await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' }); + expect(prisma.__rows.map((r) => r.id)).toEqual(['fremd-user', 'fremd-tenant']); + await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); + await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); + expect(prisma.__rows).toHaveLength(2); + }); + + it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => { + const prisma = makeFakePrisma([makeRow({ id: 'img-1' })]); + const service = makeService(prisma); + await service.list('user-1', 'tenant-1'); + await service.upload(user, file(PNG, 'image/png')); + await service.getBytes('img-1', 'user-1', 'tenant-1'); + await service.remove('img-1', 'user-1', 'tenant-1'); + + expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(4); + for (const call of vi.mocked(forTenant).mock.calls) { + expect(call[0]).toBe(prisma); + expect(call[1]).toBe('tenant-1'); + expect(call[2]).toBe('user-1'); + } + // Jeder Modellaufruf steht im Protokoll des gebundenen Klienten. + const methods = prisma.__boundCallLog.map((c) => c.method); + expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']); + for (const c of prisma.__boundCallLog) { + expect(c.tenantId).toBe('tenant-1'); + expect(c.userId).toBe('user-1'); + } + }); +}); diff --git a/apps/api/src/dashboard/dashboard-images.service.ts b/apps/api/src/dashboard/dashboard-images.service.ts new file mode 100644 index 0000000..2cfa2fc --- /dev/null +++ b/apps/api/src/dashboard/dashboard-images.service.ts @@ -0,0 +1,148 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user'; +import { forTenant } from '../prisma/prisma-tenant.extension'; +import { PrismaService } from '../prisma/prisma.service'; +import { + DASHBOARD_IMAGE_MAX_COUNT, + type DashboardImageMime, + detectImageMime, +} from './dashboard-image-rules'; + +/** + * DashboardImagesService — hochgeladene Bilder des Bilderrahmen-Widgets + * (quick-260921-pi9). + * + * Ein Bild gehoert dem hochladenden Benutzer: Besitz = gleicher Mandant UND + * gleicher Benutzer. Die Besitzpruefung in `getBytes`/`remove` (Zeile holen, + * `userId` UND `tenantId` gegen den Sitzungsnachweis vergleichen, sonst 404) + * ist NICHT dekorativ: die RLS-Regel auf `DashboardImage` (Migration + * 20260921120000, mit Benutzerdimension) wirkt erst, wenn die Anwendung als + * Rolle ohne Umgehungsrecht verbindet — der Schalter ist heute AUS + * (docs/mandantentrennung-datenbankrolle.md). Bis dahin ist der Vergleich + * hier der einzige wirksame Schutz gegen Quer-Lesen und Quer-Loeschen; die + * `forTenant()`-Bindung je Methode LEGT eine Mandantengrenze obendrauf, sie + * ersetzt den Vergleich nicht (Muster dashboard.service.ts). Nach dem + * Scharfschalten liefert `findUnique` fuer eine fremde Zeile bereits `null` + * — die Antwort bleibt 404, nur der Weg dorthin aendert sich. + * + * Warum 404 und nie 403 (T-PI9-04): ein 403 wuerde verraten, dass die + * Kennung existiert. Kennungen sind `uuid()`, nicht erratbar. + * + * Warum der Typ aus den Magic Bytes kommt (T-PI9-01, T-PI9-08): + * `file.mimetype` und `originalname` behauptet der Browser; gespeichert und + * spaeter als `Content-Type` ausgeliefert wird ausschliesslich das, was + * `detectImageMime` an den Bytes erkannt hat. Der Dateiname wird nur als + * Anzeigetext gefuehrt (auf 255 Zeichen gekuerzt) und erscheint nie in + * einem HTTP-Header (T-PI9-06). + * + * Zaehler (T-PI9-03): `count` je Mandant+Benutzer vor `create` im selben + * Dienst. Zwei gleichzeitige Uploads desselben Benutzers koennen die Grenze + * um wenige Bilder ueberschreiten — Restrisiko bewusst angenommen, es + * betrifft nur den eigenen Speicher. + * + * Der Dienst ruft NIE eine Webadresse ab (T-PI9-05): URL-Eintraege des + * Widgets sind fuer die API undurchsichtige Config-Werte, der Browser des + * Benutzers laedt sie selbst. + */ + +const ORIGINAL_NAME_MAX = 255; + +/** Metadaten-Auswahl fuer Liste und Upload-Antwort — `data` NIE dabei. */ +const META_SELECT = { + id: true, + originalName: true, + mimeType: true, + size: true, + createdAt: true, +} as const; + +export interface DashboardImageMeta { + id: string; + originalName: string; + mimeType: string; + size: number; + createdAt: Date; +} + +@Injectable() +export class DashboardImagesService { + constructor(private readonly prisma: PrismaService) {} + + /** Eigene Bilder, aelteste zuerst, nur Metadaten. */ + async list(userId: string, tenantId: string): Promise { + const tenantPrisma = forTenant(this.prisma, tenantId, userId); + return tenantPrisma.dashboardImage.findMany({ + where: { tenantId, userId }, + select: META_SELECT, + orderBy: { createdAt: 'asc' }, + }); + } + + /** + * Nimmt eine hochgeladene Datei an: Magic Bytes entscheiden, der Zaehler + * begrenzt, gespeichert wird der erkannte Typ. + */ + async upload(user: AuthUser, file: UploadedFileLike | undefined): Promise { + if (!file) { + throw new BadRequestException('Bitte wählen Sie eine Bilddatei aus.'); + } + + const mimeType: DashboardImageMime | null = detectImageMime(file.buffer); + if (mimeType === null) { + throw new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'); + } + + const tenantPrisma = forTenant(this.prisma, user.tenantId, user.id); + const existing = await tenantPrisma.dashboardImage.count({ + where: { tenantId: user.tenantId, userId: user.id }, + }); + if (existing >= DASHBOARD_IMAGE_MAX_COUNT) { + throw new BadRequestException( + `Sie haben die Höchstzahl von ${DASHBOARD_IMAGE_MAX_COUNT} Bildern erreicht. Bitte löschen Sie zuerst ein Bild.`, + ); + } + + return tenantPrisma.dashboardImage.create({ + data: { + userId: user.id, + tenantId: user.tenantId, + originalName: file.originalname.slice(0, ORIGINAL_NAME_MAX), + mimeType, + size: file.buffer.length, + // Befund am Typsystem (TS 5.9 + Prisma 6): `Bytes` verlangt + // `Uint8Array`, multers `Buffer` ist aber ueber + // `ArrayBufferLike` getypt (koennte ein SharedArrayBuffer sein) und + // wird ohne Zusicherung abgelehnt. `new Uint8Array(buffer)` kopiert in + // einen frischen ArrayBuffer — hoechstens 5 MiB, einmal je Upload — + // und ist damit ehrlich getypt statt zugesichert. + data: new Uint8Array(file.buffer), + }, + select: META_SELECT, + }); + } + + /** Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt -> 404. */ + async getBytes( + id: string, + userId: string, + tenantId: string, + ): Promise<{ mimeType: string; data: Uint8Array }> { + const tenantPrisma = forTenant(this.prisma, tenantId, userId); + const row = await tenantPrisma.dashboardImage.findUnique({ where: { id } }); + if (!row || row.userId !== userId || row.tenantId !== tenantId) { + throw new NotFoundException(`Image with id '${id}' not found`); + } + return { mimeType: row.mimeType, data: row.data }; + } + + /** Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht. */ + async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> { + const tenantPrisma = forTenant(this.prisma, tenantId, userId); + const row = await tenantPrisma.dashboardImage.findUnique({ where: { id } }); + if (!row || row.userId !== userId || row.tenantId !== tenantId) { + throw new NotFoundException(`Image with id '${id}' not found`); + } + await tenantPrisma.dashboardImage.delete({ where: { id } }); + return { id }; + } +} diff --git a/apps/api/src/dashboard/dashboard.module.ts b/apps/api/src/dashboard/dashboard.module.ts index dcee374..4e45902 100644 --- a/apps/api/src/dashboard/dashboard.module.ts +++ b/apps/api/src/dashboard/dashboard.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; import { ModuleRegistryModule } from '../module-registry/module-registry.module'; import { DashboardController } from './dashboard.controller'; +import { DashboardImagesController } from './dashboard-images.controller'; +import { DashboardImagesService } from './dashboard-images.service'; import { DashboardService } from './dashboard.service'; /** @@ -9,6 +11,8 @@ import { DashboardService } from './dashboard.service'; * Provides: * - DashboardService: CRUD for per-user dashboard layouts and widget instances * - DashboardController: REST API for layout and widget operations + * - DashboardImagesService/-Controller: Bilder des Bilderrahmen-Widgets + * (quick-260921-pi9), je Benutzer in der Datenbank * * Imports ModuleRegistryModule so DashboardService can inject * ModuleAccessService for the D-22 widget-module filter (Plan 15-05). @@ -17,8 +21,8 @@ import { DashboardService } from './dashboard.service'; */ @Module({ imports: [ModuleRegistryModule], - controllers: [DashboardController], - providers: [DashboardService], + controllers: [DashboardController, DashboardImagesController], + providers: [DashboardService, DashboardImagesService], exports: [DashboardService], }) export class DashboardModule {} diff --git a/apps/api/src/dashboard/dto/create-widget.dto.ts b/apps/api/src/dashboard/dto/create-widget.dto.ts index f12884e..36640e8 100644 --- a/apps/api/src/dashboard/dto/create-widget.dto.ts +++ b/apps/api/src/dashboard/dto/create-widget.dto.ts @@ -2,12 +2,22 @@ import { IsIn, IsObject, IsOptional, IsString } from 'class-validator'; /** * DTO for creating a new widget instance on a user's dashboard. - * widgetType must be one of the seven supported types. + * widgetType must be one of the eight supported types + * ('picture-frame' seit quick-260921-pi9). * config is optional and defaults to {} on the model. */ export class CreateWidgetDto { @IsString() - @IsIn(['clock', 'search', 'calendar', 'note', 'calculator', 'favorites', 'stopwatch']) + @IsIn([ + 'clock', + 'search', + 'calendar', + 'note', + 'calculator', + 'favorites', + 'stopwatch', + 'picture-frame', + ]) widgetType!: string; @IsOptional() diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 24cba5e..51bfa27 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 | 12 | 0 | **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 | 18 | 0 | **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 | 3 | 0 | **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** | **179** | **5** | **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 | +| 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** | **187** | **5** | **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, 72 Paare) +## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 74 Paare) Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf (260909-ipc) plus ein bisher vollstaendig unsichtbares Paar @@ -191,6 +191,16 @@ Die Zahl ist der Ausgabe der Pruefung in `apps/api/src/prisma/rls-access-inventory.spec.ts` entnommen, nicht geschaetzt. +**Nachtrag 260921-pi9:** 74 Paare — ein neues Paar +`dashboard/dashboard-images.service.ts`/`dashboardImage` +(muss-mandantengebunden, gebunden) fuer die hochgeladenen Bilder des +Bilderrahmen-Widgets. Nachgezaehlt mit `grep -cE` ueber die Fundstellen- +tabelle: vor diesem Eintrag standen dort bereits 73 Zeilen, nicht 72 — das +Paar `bug-reports/bug-reports.service.ts`/`user` (260914-m97, +muss-mandantengebunden) war in der Tabelle eingetragen, in dieser +Verteilung aber nie mitgezaehlt. Beide Korrekturen (72→74, 35→37) sind +gemessen, nicht geschaetzt. + **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 @@ -319,11 +329,11 @@ entnommen (30 Zusicherungen, darunter der Wachhund | Klasse | Anzahl Paare | |---|---| -| muss-mandantengebunden | 35 | +| muss-mandantengebunden | 37 | | keine-mandantengebundene-tabelle | 21 | | beides | 14 | | bewusst-uebergreifend | 2 | -| **Summe** | **72** | +| **Summe** | **74** | ## Der Hintergrunddienst als Falle — sechs Fälle @@ -663,6 +673,7 @@ werden. | apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gebunden | Klassenkorrektur (260911-fh9, Aufgabe 2/3): wechselt von `gemischt` auf `gebunden` — `getMe`, `changePassword`, `adminResetPassword` binden seit Aufgabe 2 je über GENAU EINEN Klienten `tenantPrisma` an den Mandanten aus dem Sitzungsnachweis (`@CurrentUser().tenantId`); für die oberste Rolle (SUPER_ADMIN) löst der Controller den Mandanten des ZIELS über den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` auf. `adminResetPassword` verweigert zusätzlich einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04). Die drei Anmeldesuchen (`validateUser`, `requestPasswordReset`, `resetPassword`) laufen weiterhin über die drei SECURITY-DEFINER-Funktionen (`$queryRaw`, keine Modellzugriffe — `$` liegt nicht in `[a-zA-Z]`) und bleiben unverändert auf dem ungebundenen Klienten. Etappe-3-Vorbehalt: die Bindung hängt am Claim `tenantId` und an `User.id` (plattformweite UUID), nicht an `username`/`email` — der Anmeldeweg-Umbau für je Mandant eindeutige Anmeldenamen betrifft diese Bindung nicht, siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h4)(a). | | 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 | gebunden | 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 | 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). |