diff --git a/apps/api/prisma/migrations/20260922120000_dashboard_image_to_disk/migration.sql b/apps/api/prisma/migrations/20260922120000_dashboard_image_to_disk/migration.sql new file mode 100644 index 0000000..cc90e98 --- /dev/null +++ b/apps/api/prisma/migrations/20260922120000_dashboard_image_to_disk/migration.sql @@ -0,0 +1,59 @@ +-- quick-260922-hk4 — Bilderrahmen-Bilder wandern aus der Datenbank in den +-- Dateibereich (Volume `user-files`). +-- +-- Warum: gesichert wird von Hand per `pg_dump` (docs/anleitung-betrieb.md +-- Kap. 6). Jedes Bild waechst in diesen Abzug hinein — 30 Bilder à 5 MiB je +-- Benutzer sind im Extremfall 150 MB PRO BENUTZER, gegen eine heute 18 MB +-- grosse Datenbank (gemessen 22.09.2026 auf alpha). Die Bytes liegen ab +-- dieser Version unter +-- `user-files/dashboard-images//.`; die Zeile +-- haelt nur noch den relativen Pfad in "storagePath" — dasselbe Muster wie +-- `User.avatarPath` (user.controller.ts) und die DKV-Ausfuhren +-- (dkv-export.service.ts). Der Dateiname ist IMMER servergeneriert (die +-- UUID der Zeile plus die Endung aus dem an den Magic Bytes ERKANNTEN +-- Mime-Typ); kein Byte aus der Anfrage, insbesondere nicht +-- "originalName", geht je in einen Pfad (T-HK4-01, Muster T-07-09). +-- +-- ZWEISTUFIG, UND WARUM DIESE MIGRATION "data" NICHT LOESCHT (T-HK4-03): +-- Vorhandene Zeilen tragen ihre Bytes noch in "data". Der Umzug auf die +-- Platte passiert beim ersten Start dieser Version automatisch +-- (DashboardImagesService.onApplicationBootstrap, liest systemgebunden ueber +-- alle Mandanten, schreibt je Zeile mandantengebunden zurueck) — der Nutzer +-- muss nichts ausfuehren. Wuerde diese Migration die Spalte sofort +-- loeschen, laufen Migration und Umzug im selben Start in der falschen +-- Reihenfolge ("migrate deploy" laeuft VOR dem Anwendungsstart) und die +-- Bytes waeren weg, bevor sie jemand gelesen hat. Deshalb: +-- Stufe 1 (diese Migration): "storagePath" dazu (NULLbar), "data" bleibt +-- stehen und wird NULLbar, damit neue Uploads sie leer lassen. +-- Stufe 2 (spaetere Freigabe, Migration +-- 20260922120100_dashboard_image_drop_data, vorgemerkt in +-- .planning/todos/pending/): "storagePath" SET NOT NULL und +-- DROP COLUMN "data" — erst, wenn alpha UND live einmal mit +-- einer Version >= dieser gelaufen sind. +-- +-- Das Prisma-Modell behaelt in Stufe 1 bewusst `data Bytes?` (optional). +-- Damit bleibt der Bootstrap-Umzug typisiert und braucht kein rohes SQL; +-- die Spalte verschwindet aus Modell und Tabelle gemeinsam in Stufe 2. +-- +-- Rechte/Regeln: "tenant_isolation_policy" aus 20260921120000 bleibt +-- unveraendert. Kein DROP POLICY. + +-- Relativer Pfad zur Monorepo-Wurzel, z. B. +-- "user-files/dashboard-images//.png". Stufe 2 macht die Spalte +-- NOT NULL. +ALTER TABLE "DashboardImage" ADD COLUMN "storagePath" TEXT; + +-- Neue Uploads schreiben keine Bytes mehr in die Zeile; die Spalte bleibt +-- fuer die Dauer von Stufe 1 als Sicherheitsnetz erhalten. +ALTER TABLE "DashboardImage" ALTER COLUMN "data" DROP NOT NULL; + +-- Systemkontext-Leserecht (Muster 20260914120000_rls_system_context_read): +-- der Bootstrap-Umzug liest die noch nicht umgezogenen Zeilen ueber ALLE +-- Mandanten (`forSystem()`), bevor er je Zeile mandantengebunden +-- zurueckschreibt. Ohne diese Regel saehe er nach dem Scharfschalten der +-- Datenbankrolle (Etappe 4, Schalter heute AUS) NULL Zeilen und stellte die +-- Arbeit stumm ein — genau die Falle, die 20260914120000 fuer die fuenf +-- Hintergrunddienst-Tabellen geschlossen hat. Permissiv und NUR FOR SELECT: +-- Schreiben bleibt allein der Mandantenregel unterstellt. +CREATE POLICY system_read_policy ON "DashboardImage" + FOR SELECT USING (is_system_context()); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c09cad0..b2f2b1b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -212,12 +212,16 @@ 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 +// Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers. +// 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). +// +// quick-260922-hk4: die Bytes liegen jetzt im Dateibereich +// (user-files/dashboard-images//.), die Zeile haelt nur +// noch den relativen Pfad — Muster User.avatarPath. Die Datenbanksicherung +// (pg_dump) bleibt dadurch klein. model DashboardImage { id String @id @default(uuid()) userId String @@ -225,7 +229,14 @@ model DashboardImage { originalName String mimeType String size Int - data Bytes + // Stufe 1 der zweistufigen Umstellung (Migration 20260922120000): die + // Spalte bleibt NULLbar stehen, bis der Bootstrap-Umzug auf allen Servern + // gelaufen ist. Neue Uploads schreiben sie nie. DROP kommt mit + // 20260922120100 (vorgemerkt in .planning/todos/pending/). + data Bytes? + // Relativ zur Monorepo-Wurzel; NULL nur fuer Zeilen, die der + // Bootstrap-Umzug noch nicht angefasst hat. Wird in Stufe 2 NOT NULL. + storagePath String? createdAt DateTime @default(now()) @@index([userId]) diff --git a/apps/api/src/dashboard/dashboard-images.service.spec.ts b/apps/api/src/dashboard/dashboard-images.service.spec.ts index 4ef1101..0de835d 100644 --- a/apps/api/src/dashboard/dashboard-images.service.spec.ts +++ b/apps/api/src/dashboard/dashboard-images.service.spec.ts @@ -1,33 +1,53 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterAll, beforeAll, 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). + * Bindung an forTenant()/forSystem() — 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). Seit quick-260922-hk4 gibt es einen zweiten + * Klienten-Typ: der Systemkontext des Bootstrap-Umzugs (`forSystem()`, + * liest ueber ALLE Mandanten, Muster dkv.service.ts) — das Protokoll + * unterscheidet beide ueber `via`. */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((prisma: FakePrisma, tenantId: string, userId?: string) => prisma.__makeBoundClient(tenantId, userId), ), + forSystem: vi.fn((prisma: FakePrisma) => prisma.__makeSystemClient()), })); -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user'; -import { forTenant } from '../prisma/prisma-tenant.extension'; +import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; import { DashboardImagesService } from './dashboard-images.service'; /** - * dashboard-images.service.spec — NEU (quick-260921-pi9, Bilderrahmen). + * dashboard-images.service.spec — quick-260921-pi9 (Bilderrahmen), + * erweitert in quick-260922-hk4 (Bilder auf der Festplatte statt in der + * Datenbank). * - * 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 + * 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. + * + * Dazu die Grenze Dienst -> Dateibereich (hk4, Tests 13-22): KEIN + * `fs`-Mock, sondern ein echtes Verzeichnis unter `os.tmpdir()` (Muster + * desktop.service.spec.ts) ueber den Testschalter + * `DASHBOARD_IMAGES_DIR` — der Dienst schreibt und liest wirklich. + * Geprueft werden Ablageort und Dateiname (IMMER die UUID der Zeile plus + * die Endung aus dem ERKANNTEN Typ, NIE `originalName`, T-HK4-01), das + * Zuruecknehmen der Zeile bei fehlgeschlagenem Schreiben (T-HK4-04), 404 + * bei fehlender Datei, das Mitloeschen der Datei und der automatische + * Umzug beim Start (T-HK4-03). */ interface ImageRow { @@ -37,11 +57,13 @@ interface ImageRow { originalName: string; mimeType: string; size: number; - data: Uint8Array; + data: Uint8Array | null; + storagePath: string | null; createdAt: Date; } interface BoundCall { + via: 'tenant' | 'system'; tenantId: string; userId: string | undefined; model: string; @@ -55,11 +77,31 @@ interface FakePrisma { __rows: ImageRow[]; __boundCallLog: BoundCall[]; __makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods }; + __makeSystemClient(): { dashboardImage: ModelMethods }; } const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]); +const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 0x4a, 0x46]); const TEXT = Buffer.from('nur Text, kein Bild'); +/** Ablageort der Testdateien — echtes Verzeichnis, kein fs-Mock. */ +let imagesDir: string; +const ORIGINAL_DIR_ENV = process.env.DASHBOARD_IMAGES_DIR; + +beforeAll(() => { + imagesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-dashboard-images-')); + process.env.DASHBOARD_IMAGES_DIR = imagesDir; +}); + +afterAll(() => { + fs.rmSync(imagesDir, { recursive: true, force: true }); + if (ORIGINAL_DIR_ENV === undefined) { + delete process.env.DASHBOARD_IMAGES_DIR; + } else { + process.env.DASHBOARD_IMAGES_DIR = ORIGINAL_DIR_ENV; + } +}); + function makeRow(overrides: Partial = {}): ImageRow { return { id: overrides.id ?? 'img-1', @@ -68,11 +110,30 @@ function makeRow(overrides: Partial = {}): ImageRow { originalName: overrides.originalName ?? 'foto.png', mimeType: overrides.mimeType ?? 'image/png', size: overrides.size ?? PNG.length, - data: overrides.data ?? Uint8Array.from(PNG), + data: overrides.data === undefined ? null : overrides.data, + storagePath: overrides.storagePath === undefined ? null : overrides.storagePath, createdAt: overrides.createdAt ?? new Date('2026-01-01'), }; } +/** + * Legt eine Zeile MIT passender Datei auf der Platte an — der Normalfall + * nach dem Upload (die Tests 8-12 pruefen Besitz und Bindung, nicht die + * Ablage). + */ +function makeStoredRow(overrides: Partial = {}, bytes: Buffer = PNG): ImageRow { + const row = makeRow(overrides); + const relative = `user-files/dashboard-images/${row.userId}/${row.id}.png`; + const absolute = path.join(imagesDir, row.userId, `${row.id}.png`); + fs.mkdirSync(path.dirname(absolute), { recursive: true }); + fs.writeFileSync(absolute, bytes); + return { ...row, storagePath: overrides.storagePath === undefined ? relative : overrides.storagePath }; +} + +function storedFile(userId: string, id: string, ext = 'png'): string { + return path.join(imagesDir, userId, `${id}.${ext}`); +} + function pick(row: ImageRow, select: Record | undefined) { if (!select) return row; const out: Record = {}; @@ -86,9 +147,20 @@ 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 }; + const args = raw as { + where: { tenantId?: string; userId?: string; storagePath?: string | null }; + select?: Record; + }; + const where = args.where ?? {}; return rows - .filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId) + .filter((r) => { + if (where.tenantId !== undefined && r.tenantId !== where.tenantId) return false; + if (where.userId !== undefined && r.userId !== where.userId) return false; + if ('storagePath' in where && where.storagePath === null && r.storagePath !== null) { + return false; + } + return true; + }) .slice() .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) .map((r) => pick(r, args.select)); @@ -98,11 +170,18 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma { 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 args = raw as { data: Partial; 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); }), + update: vi.fn(async (raw: unknown) => { + const args = raw as { where: { id: string }; data: Partial }; + const row = rows.find((r) => r.id === args.where.id); + if (!row) throw new Error(`update: Zeile '${args.where.id}' gibt es nicht`); + Object.assign(row, args.data); + return row; + }), findUnique: vi.fn(async (raw: unknown) => { const args = raw as { where: { id: string } }; return rows.find((r) => r.id === args.where.id) ?? null; @@ -116,19 +195,26 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma { }), }; + function wrap(via: 'tenant' | 'system', tenantId: string, userId?: string) { + const wrapped: ModelMethods = {}; + for (const method of Object.keys(dashboardImage)) { + wrapped[method] = async (...args: unknown[]) => { + boundCallLog.push({ via, tenantId, userId, model: 'dashboardImage', method }); + return dashboardImage[method](...args); + }; + } + return { dashboardImage: wrapped }; + } + 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 wrap('tenant', tenantId, userId); + }, + __makeSystemClient() { + return wrap('system', '', undefined); }, }; return fake; @@ -156,6 +242,7 @@ function file(buffer: Buffer, mimetype: string, originalname = 'foto.png'): Uplo beforeEach(() => { vi.mocked(forTenant).mockClear(); + vi.mocked(forSystem).mockClear(); }); describe('DashboardImagesService (quick-260921-pi9)', () => { @@ -172,6 +259,7 @@ describe('DashboardImagesService (quick-260921-pi9)', () => { } const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record }; expect(call.select.data).toBeUndefined(); + expect(call.select.storagePath).toBeUndefined(); }); it('Test 2: upload ohne Datei -> BadRequestException mit deutscher Meldung', async () => { @@ -234,19 +322,19 @@ describe('DashboardImagesService (quick-260921-pi9)', () => { }); it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => { - const prisma = makeFakePrisma([makeRow({ id: 'img-1', userId: 'user-2' })]); + const prisma = makeFakePrisma([makeStoredRow({ 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 prisma = makeFakePrisma([makeStoredRow({ 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 prisma = makeFakePrisma([makeStoredRow({ 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); @@ -254,9 +342,9 @@ describe('DashboardImagesService (quick-260921-pi9)', () => { 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' }), + makeStoredRow({ id: 'eigen' }), + makeStoredRow({ id: 'fremd-user', userId: 'user-2' }), + makeStoredRow({ id: 'fremd-tenant', tenantId: 'tenant-2' }), ]); const service = makeService(prisma); await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' }); @@ -267,12 +355,12 @@ describe('DashboardImagesService (quick-260921-pi9)', () => { }); 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 prisma = makeFakePrisma([]); 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'); + const created = await service.upload(user, file(PNG, 'image/png')); + await service.getBytes(created.id, 'user-1', 'tenant-1'); + await service.remove(created.id, 'user-1', 'tenant-1'); expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(4); for (const call of vi.mocked(forTenant).mock.calls) { @@ -280,12 +368,174 @@ describe('DashboardImagesService (quick-260921-pi9)', () => { expect(call[1]).toBe('tenant-1'); expect(call[2]).toBe('user-1'); } - // Jeder Modellaufruf steht im Protokoll des gebundenen Klienten. + // Jeder Modellaufruf steht im Protokoll des gebundenen Klienten; der + // Upload schreibt den Ablageort in einem zweiten Schritt nach, weil die + // UUID der Zeile erst nach `create` feststeht (hk4). const methods = prisma.__boundCallLog.map((c) => c.method); - expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']); + expect(methods).toEqual(['findMany', 'count', 'create', 'update', 'findUnique', 'findUnique', 'delete']); for (const c of prisma.__boundCallLog) { + expect(c.via).toBe('tenant'); expect(c.tenantId).toBe('tenant-1'); expect(c.userId).toBe('user-1'); } }); }); + +describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)', () => { + it('Test 13: upload schreibt die Datei unter //.png und speichert den relativen Pfad in der Zeile', async () => { + const prisma = makeFakePrisma(); + const result = await makeService(prisma).upload(user, file(PNG, 'image/png')); + + const onDisk = storedFile('user-1', result.id); + expect(fs.existsSync(onDisk)).toBe(true); + expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true); + expect(prisma.__rows[0].storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`); + // Die Bytes gehen NICHT mehr in die Zeile (das ist der ganze Zweck). + expect(prisma.__rows[0].data).toBeNull(); + const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record }; + expect(createArgs.data.data).toBeUndefined(); + }); + + it('Test 14: der Dateiname ist IMMER die UUID plus die Endung des ERKANNTEN Typs — originalName kommt nie im Pfad vor (T-HK4-01)', async () => { + const prisma = makeFakePrisma(); + const service = makeService(prisma); + const boeserName = '../../../etc/passwd.png'; + const result = await service.upload(user, file(JPEG, 'image/png', boeserName)); + + // Erkannt wurde JPEG (Magic Bytes), also .jpg — nicht .png aus dem Namen. + expect(result.mimeType).toBe('image/jpeg'); + const stored = prisma.__rows[0].storagePath ?? ''; + expect(stored).toBe(`user-files/dashboard-images/user-1/${result.id}.jpg`); + expect(stored).not.toContain('passwd'); + expect(stored).not.toContain('..'); + expect(fs.existsSync(storedFile('user-1', result.id, 'jpg'))).toBe(true); + // Der Anzeigename bleibt in der Zeile erhalten, nur eben als Text. + expect(result.originalName).toBe(boeserName); + }); + + it('Test 15: scheitert das Schreiben, wird die eben angelegte Zeile wieder geloescht und 500 geworfen (T-HK4-04)', async () => { + const blocker = path.join(imagesDir, 'blockade'); + fs.writeFileSync(blocker, 'ich bin eine Datei, kein Verzeichnis'); + const vorher = process.env.DASHBOARD_IMAGES_DIR; + process.env.DASHBOARD_IMAGES_DIR = path.join(blocker, 'unmoeglich'); + try { + const prisma = makeFakePrisma(); + await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).rejects.toThrow( + InternalServerErrorException, + ); + expect(prisma.dashboardImage.create).toHaveBeenCalledTimes(1); + expect(prisma.dashboardImage.delete).toHaveBeenCalledTimes(1); + expect(prisma.__rows).toHaveLength(0); + } finally { + process.env.DASHBOARD_IMAGES_DIR = vorher; + } + }); + + it('Test 16: getBytes liest den Dateiinhalt (nicht die Zeile) — auch wenn in der Zeile noch alte Bytes stehen', async () => { + const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', data: Uint8Array.from(TEXT) }, PNG)]); + const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1'); + expect(Buffer.from(result.data).equals(PNG)).toBe(true); + }); + + it('Test 17: Zeile vorhanden, Datei fehlt -> NotFoundException (die Kachel zeigt „Bild nicht verfügbar")', async () => { + // Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei + // dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da. + const prisma = makeFakePrisma([ + makeRow({ id: 'datei-fehlt', storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png' }), + ]); + await expect(makeService(prisma).getBytes('datei-fehlt', 'user-1', 'tenant-1')).rejects.toThrow( + NotFoundException, + ); + }); + + it('Test 18: Zeile ohne storagePath (noch nicht umgezogen) -> NotFoundException statt Absturz', async () => { + const prisma = makeFakePrisma([makeRow({ id: 'img-1', data: Uint8Array.from(PNG) })]); + await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); + }); + + it('Test 19: remove loescht Zeile UND Datei', async () => { + const prisma = makeFakePrisma([makeStoredRow({ id: 'weg' })]); + const onDisk = storedFile('user-1', 'weg'); + expect(fs.existsSync(onDisk)).toBe(true); + + await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'weg' }); + expect(prisma.__rows).toHaveLength(0); + expect(fs.existsSync(onDisk)).toBe(false); + }); + + it('Test 20: fehlt die Datei beim Loeschen, gelingt das Loeschen trotzdem (eine Dateileiche ist harmloser als eine haengende Loeschung)', async () => { + const prisma = makeFakePrisma([ + makeRow({ id: 'nur-zeile', storagePath: 'user-files/dashboard-images/user-1/nur-zeile.png' }), + ]); + await expect(makeService(prisma).remove('nur-zeile', 'user-1', 'tenant-1')).resolves.toEqual({ + id: 'nur-zeile', + }); + expect(prisma.__rows).toHaveLength(0); + }); +}); + +describe('DashboardImagesService — Umzug beim Start (quick-260922-hk4, T-HK4-03)', () => { + it('Test 21: onApplicationBootstrap schreibt die Bytes alter Zeilen auf die Platte und setzt storagePath — systemgebunden lesen, je Zeile mandantengebunden schreiben', async () => { + const alt = makeRow({ id: 'alt-1', data: Uint8Array.from(PNG) }); + const fremderMandant = makeRow({ + id: 'alt-2', + userId: 'user-9', + tenantId: 'tenant-2', + mimeType: 'image/jpeg', + data: Uint8Array.from(JPEG), + }); + const schonUmgezogen = makeStoredRow({ id: 'neu-1' }); + const prisma = makeFakePrisma([alt, fremderMandant, schonUmgezogen]); + + await makeService(prisma).onApplicationBootstrap(); + + expect(fs.readFileSync(storedFile('user-1', 'alt-1')).equals(PNG)).toBe(true); + expect(fs.readFileSync(storedFile('user-9', 'alt-2', 'jpg')).equals(JPEG)).toBe(true); + expect(prisma.__rows[0].storagePath).toBe('user-files/dashboard-images/user-1/alt-1.png'); + expect(prisma.__rows[1].storagePath).toBe('user-files/dashboard-images/user-9/alt-2.jpg'); + + // Gelesen wird EINMAL ueber den Systemkontext, geschrieben je Zeile + // ueber einen Klienten, der auf Mandant UND Benutzer DIESER Zeile + // gebunden ist. + expect(vi.mocked(forSystem)).toHaveBeenCalledTimes(1); + expect(vi.mocked(forSystem).mock.calls[0][0]).toBe(prisma); + const leseAufrufe = prisma.__boundCallLog.filter((c) => c.via === 'system'); + expect(leseAufrufe.map((c) => c.method)).toEqual(['findMany']); + const findManyArgs = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { + where: Record; + }; + expect(findManyArgs.where.storagePath).toBeNull(); + + expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(2); + expect(vi.mocked(forTenant).mock.calls[0].slice(1)).toEqual(['tenant-1', 'user-1']); + expect(vi.mocked(forTenant).mock.calls[1].slice(1)).toEqual(['tenant-2', 'user-9']); + const schreibAufrufe = prisma.__boundCallLog.filter((c) => c.via === 'tenant'); + expect(schreibAufrufe.map((c) => c.method)).toEqual(['update', 'update']); + + // Die bereits umgezogene Zeile wird nicht angefasst. + expect(prisma.__rows[2].storagePath).toBe('user-files/dashboard-images/user-1/neu-1.png'); + }); + + it('Test 22: ohne offene Zeilen bleibt der Start still — kein Schreibzugriff, keine Bindung je Mandant', async () => { + const prisma = makeFakePrisma([makeStoredRow({ id: 'neu-2' })]); + await makeService(prisma).onApplicationBootstrap(); + + expect(vi.mocked(forSystem)).toHaveBeenCalledTimes(1); + expect(vi.mocked(forTenant)).not.toHaveBeenCalled(); + expect(prisma.dashboardImage.update).not.toHaveBeenCalled(); + }); + + it('Test 23: der Umzug ist wiederholbar — ein zweiter Lauf findet nichts mehr und ueberschreibt nichts', async () => { + const prisma = makeFakePrisma([makeRow({ id: 'alt-3', data: Uint8Array.from(PNG) })]); + const service = makeService(prisma); + await service.onApplicationBootstrap(); + const ersterStand = fs.statSync(storedFile('user-1', 'alt-3')).mtimeMs; + + vi.mocked(forTenant).mockClear(); + await service.onApplicationBootstrap(); + + expect(vi.mocked(forTenant)).not.toHaveBeenCalled(); + expect(fs.statSync(storedFile('user-1', 'alt-3')).mtimeMs).toBe(ersterStand); + expect(prisma.__rows[0].storagePath).toBe('user-files/dashboard-images/user-1/alt-3.png'); + }); +}); diff --git a/apps/api/src/dashboard/dashboard-images.service.ts b/apps/api/src/dashboard/dashboard-images.service.ts index 2cfa2fc..6461599 100644 --- a/apps/api/src/dashboard/dashboard-images.service.ts +++ b/apps/api/src/dashboard/dashboard-images.service.ts @@ -1,6 +1,15 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, + type OnApplicationBootstrap, +} from '@nestjs/common'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user'; -import { forTenant } from '../prisma/prisma-tenant.extension'; +import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; import { PrismaService } from '../prisma/prisma.service'; import { DASHBOARD_IMAGE_MAX_COUNT, @@ -10,20 +19,53 @@ import { /** * DashboardImagesService — hochgeladene Bilder des Bilderrahmen-Widgets - * (quick-260921-pi9). + * (quick-260921-pi9), seit quick-260922-hk4 im Dateibereich statt in der + * Datenbank. * - * 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. + * WO DIE BYTES LIEGEN (hk4): unter + * `user-files/dashboard-images//.`, die Zeile + * haelt nur noch den relativen Pfad in `storagePath` — dasselbe Muster wie + * `User.avatarPath` (user.controller.ts) und die DKV-Ausfuhren + * (dkv-export.service.ts). Grund ist die Sicherung: gesichert wird von Hand + * per `pg_dump` (docs/anleitung-betrieb.md Kap. 6), und 30 Bilder à 5 MiB je + * Benutzer waeren im Extremfall 150 MB pro Benutzer in jedem Abzug. Das + * Volume `user-files` wird daneben gesichert. Geschwindigkeit war NICHT das + * Argument (ein Bild wird je Browser einmal taeglich geladen). + * + * DER DATEINAME KOMMT IMMER VOM SERVER (T-HK4-01, Muster T-07-09 aus + * `dkv-export.service.ts`): er ist die UUID der Zeile plus die Endung aus + * dem an den Magic Bytes ERKANNTEN Mime-Typ. `originalName` ist reiner + * Anzeigetext und erscheint weder im Pfad noch in einem Header (T-PI9-06). + * `absoluteImagePath()` prueft zusaetzlich, dass der aus der Zeile + * gelesene Pfad im Bilderverzeichnis liegt — ein Wert aus der Datenbank + * wird nie ungeprueft an `path.join` gereicht. + * + * EIN EIGENER ORDNER JE BENUTZER IST KEIN SCHUTZ: wer welches Bild sehen + * darf, entscheidet weiterhin dieser Dienst. Die Datei wird nie direkt + * ausgeliefert, nur ueber `GET /dashboard/images/:id` mit Besitzpruefung + * (T-HK4-02); das Volume haengt in keinem Webserver. + * + * HALBE ZUSTAENDE (T-HK4-04, bewusst benannt): beim Upload entsteht ZUERST + * die Zeile (erst danach steht die UUID fest), dann die Datei; scheitert + * das Schreiben, wird die Zeile wieder geloescht und 500 geworfen. Beim + * Loeschen faellt ZUERST die Zeile, ein Fehler beim Entfernen der Datei + * wird protokolliert und geschluckt — eine Dateileiche ist harmloser als + * eine haengende Loeschung. Fehlt die Datei beim Lesen, ist die Antwort + * 404 und die Kachel zeigt „Bild nicht verfügbar". + * + * Besitz: ein Bild gehoert dem hochladenden Benutzer (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. @@ -31,9 +73,7 @@ import { * 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). + * `detectImageMime` an den Bytes erkannt hat. * * Zaehler (T-PI9-03): `count` je Mandant+Benutzer vor `create` im selben * Dienst. Zwei gleichzeitige Uploads desselben Benutzers koennen die Grenze @@ -47,7 +87,10 @@ import { const ORIGINAL_NAME_MAX = 255; -/** Metadaten-Auswahl fuer Liste und Upload-Antwort — `data` NIE dabei. */ +/** Ablageort unterhalb der Monorepo-Wurzel, so wie er in der Zeile steht. */ +const STORAGE_PREFIX = 'user-files/dashboard-images/'; + +/** Metadaten-Auswahl fuer Liste und Upload-Antwort — nie Bytes, nie Pfad. */ const META_SELECT = { id: true, originalName: true, @@ -64,10 +107,124 @@ export interface DashboardImageMeta { createdAt: Date; } +/** + * Loest das Bilderverzeichnis relativ zur Monorepo-Wurzel auf — Muster + * `resolveAvatarsDir()` (user.controller.ts): zur Laufzeit ist + * `__dirname` = apps/api/dist/dashboard/, also vier Ebenen hoch. + * + * `DASHBOARD_IMAGES_DIR` ist ein Testschalter (Muster `DESKTOP_DIST_DIR`, + * desktop.service.ts) und im Betrieb nie gesetzt; die Tests zeigen damit + * auf ein Wegwerfverzeichnis unter `os.tmpdir()`, statt `fs` nachzubauen. + */ +export function resolveDashboardImagesDir(): string { + const override = process.env.DASHBOARD_IMAGES_DIR; + if (override !== undefined && override !== '') { + return path.resolve(override); + } + return path.resolve(__dirname, '..', '..', '..', '..', 'user-files', 'dashboard-images'); +} + +/** Endung aus dem ERKANNTEN Typ; alles andere ergibt `null`, nie eine Vermutung. */ +function extensionFor(mimeType: string): string | null { + switch (mimeType) { + case 'image/png': + return 'png'; + case 'image/jpeg': + return 'jpg'; + case 'image/gif': + return 'gif'; + case 'image/webp': + return 'webp'; + default: + return null; + } +} + +/** Relativer Pfad, wie er in der Zeile steht (`storagePath`). */ +function relativeStoragePath(userId: string, id: string, extension: string): string { + return `${STORAGE_PREFIX}${userId}/${id}.${extension}`; +} + +/** + * Wandelt den in der Zeile gespeicherten Pfad in einen absoluten Pfad im + * Bilderverzeichnis um — und gibt `null` zurueck, sobald der Wert nicht + * die erwartete Form hat oder aus dem Verzeichnis herausfuehren wuerde + * (T-HK4-01). Der Aufrufer behandelt `null` wie eine fehlende Datei. + */ +function absoluteImagePath(storagePath: string): string | null { + const normalized = storagePath.split('\\').join('/'); + if (!normalized.startsWith(STORAGE_PREFIX)) return null; + + const base = resolveDashboardImagesDir(); + const absolute = path.resolve(base, normalized.slice(STORAGE_PREFIX.length)); + if (absolute !== base && !absolute.startsWith(base + path.sep)) return null; + return absolute; +} + @Injectable() -export class DashboardImagesService { +export class DashboardImagesService implements OnApplicationBootstrap { + private readonly logger = new Logger(DashboardImagesService.name); + constructor(private readonly prisma: PrismaService) {} + /** + * Einmaliger Umzug der Bestandsbilder beim Start (T-HK4-03), damit der + * Betreiber nichts von Hand ausfuehren muss. + * + * ZWEISTUFIG, und deshalb steht die Spalte `data` noch im Schema: die + * SQL-Migration 20260922120000 legt nur `storagePath` an und macht `data` + * NULLbar; `migrate deploy` laeuft VOR dem Anwendungsstart, ein sofortiges + * DROP haette die Bytes vernichtet, bevor dieser Umzug sie lesen konnte. + * Die DROP-Migration 20260922120100 kommt erst, wenn alpha UND live + * einmal mit einer Version >= dieser gelaufen sind (vorgemerkt in + * `.planning/todos/pending/`). + * + * GELESEN WIRD SYSTEMGEBUNDEN (`forSystem()`, Muster + * `DkvService.loadActiveConfigsForScheduler()`): der Umzug betrifft alle + * Mandanten, ein Startpfad hat keinen Mandanten im Ruecken. Geschrieben + * wird je Zeile MANDANTENGEBUNDEN (`forTenant()` mit Mandant UND Benutzer + * dieser Zeile) — unter Systemkontext ist nur Lesen geoeffnet + * (`system_read_policy ... FOR SELECT`, fuer `DashboardImage` angelegt in + * 20260922120000). Einmal-lesen-viele-bedienen, genau wie beim + * DKV-Planer. + * + * Wiederholbar: die Abfrage nimmt nur Zeilen ohne `storagePath`, ein + * zweiter Lauf findet nichts mehr. Eine einzelne fehlgeschlagene Zeile + * wird protokolliert und haelt den Start nicht auf. + */ + async onApplicationBootstrap(): Promise { + const systemPrisma = forSystem(this.prisma); + const pending = await systemPrisma.dashboardImage.findMany({ + where: { storagePath: null }, + select: { id: true, userId: true, tenantId: true, mimeType: true, data: true }, + orderBy: { createdAt: 'asc' }, + }); + + let moved = 0; + for (const row of pending) { + if (row.data === null) continue; + try { + const storagePath = await this.writeImageFile(row.userId, row.id, row.mimeType, row.data); + const tenantPrisma = forTenant(this.prisma, row.tenantId, row.userId); + await tenantPrisma.dashboardImage.update({ + where: { id: row.id }, + data: { storagePath }, + }); + moved += 1; + } catch (error) { + this.logger.error( + `Bilderrahmen-Bild ${row.id} konnte nicht auf die Festplatte umgezogen werden: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + if (moved > 0) { + this.logger.log(`${moved} Bilderrahmen-Bilder auf die Festplatte umgezogen`); + } + } + /** Eigene Bilder, aelteste zuerst, nur Metadaten. */ async list(userId: string, tenantId: string): Promise { const tenantPrisma = forTenant(this.prisma, tenantId, userId); @@ -80,7 +237,13 @@ export class DashboardImagesService { /** * Nimmt eine hochgeladene Datei an: Magic Bytes entscheiden, der Zaehler - * begrenzt, gespeichert wird der erkannte Typ. + * begrenzt, gespeichert wird der erkannte Typ — die Bytes auf der Platte, + * die Zeile haelt den Pfad. + * + * Reihenfolge (T-HK4-04): Zeile zuerst, weil der Dateiname die UUID der + * Zeile IST. Scheitert danach das Schreiben oder das Nachtragen des + * Pfades, wird die Zeile wieder geloescht — lieber gar kein Bild als eine + * Zeile ohne Datei. */ async upload(user: AuthUser, file: UploadedFileLike | undefined): Promise { if (!file) { @@ -102,26 +265,41 @@ export class DashboardImagesService { ); } - return tenantPrisma.dashboardImage.create({ + const created = await 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, }); + + try { + const storagePath = await this.writeImageFile(user.id, created.id, mimeType, file.buffer); + await tenantPrisma.dashboardImage.update({ + where: { id: created.id }, + data: { storagePath }, + }); + } catch (error) { + this.logger.error( + `Bilderrahmen-Bild ${created.id} konnte nicht gespeichert werden, Zeile wird zurueckgenommen: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + await tenantPrisma.dashboardImage.delete({ where: { id: created.id } }); + throw new InternalServerErrorException('Das Bild konnte nicht gespeichert werden.'); + } + + return created; } - /** Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt -> 404. */ + /** + * Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt -> + * 404. Gelesen wird die Datei, nicht die Zeile — eine Zeile ohne Pfad + * (noch nicht umgezogen) und eine fehlende Datei ergeben denselben 404. + */ async getBytes( id: string, userId: string, @@ -132,10 +310,31 @@ export class DashboardImagesService { if (!row || row.userId !== userId || row.tenantId !== tenantId) { throw new NotFoundException(`Image with id '${id}' not found`); } - return { mimeType: row.mimeType, data: row.data }; + + const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath); + if (absolute === null) { + this.logger.warn(`Bilderrahmen-Bild ${id} hat keinen gueltigen Ablageort`); + throw new NotFoundException(`Image with id '${id}' not found`); + } + + try { + const data = await fs.readFile(absolute); + return { mimeType: row.mimeType, data }; + } catch (error) { + this.logger.warn( + `Bilderrahmen-Bild ${id} fehlt im Dateibereich: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + throw new NotFoundException(`Image with id '${id}' not found`); + } } - /** Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht. */ + /** + * Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht. + * Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird + * protokolliert und geschluckt (T-HK4-04). + */ 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 } }); @@ -143,6 +342,47 @@ export class DashboardImagesService { throw new NotFoundException(`Image with id '${id}' not found`); } await tenantPrisma.dashboardImage.delete({ where: { id } }); + + const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath); + if (absolute !== null) { + try { + await fs.unlink(absolute); + } catch (error) { + this.logger.warn( + `Datei des geloeschten Bilderrahmen-Bildes ${id} konnte nicht entfernt werden: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + return { id }; } + + /** + * Schreibt die Bytes an den servergenerierten Ort und liefert den + * relativen Pfad fuer die Zeile zurueck. Der Ordner je Benutzer entsteht + * dabei (`recursive: true`). + */ + private async writeImageFile( + userId: string, + id: string, + mimeType: string, + bytes: Uint8Array, + ): Promise { + const extension = extensionFor(mimeType); + if (extension === null) { + throw new Error(`Unbekannter Bildtyp '${mimeType}'`); + } + + const storagePath = relativeStoragePath(userId, id, extension); + const absolute = absoluteImagePath(storagePath); + if (absolute === null) { + throw new Error(`Ungueltiger Ablageort fuer Bild ${id}`); + } + + await fs.mkdir(path.dirname(absolute), { recursive: true }); + await fs.writeFile(absolute, bytes); + return storagePath; + } } diff --git a/apps/api/src/prisma/rls-access-inventory.spec.ts b/apps/api/src/prisma/rls-access-inventory.spec.ts index 36b25a9..d251003 100644 --- a/apps/api/src/prisma/rls-access-inventory.spec.ts +++ b/apps/api/src/prisma/rls-access-inventory.spec.ts @@ -150,8 +150,24 @@ const RELATION_SPEC_EXCEPTIONS = new Set(['apps/api/src/tenders/backfill * der Schleife ist `tenant.findMany` auf `Tenant`, das in keiner Migration * eine Regel traegt — kein Systemkontext noetig, Datei unveraendert. * Summe: 4 Dateien, 5 Aufrufe. + * + * SIEBTER FALL (quick-260922-hk4): `dashboard-images.service.ts`, EIN + * Aufruf, ausschliesslich in `onApplicationBootstrap()` — der einmalige + * Umzug der Bilderrahmen-Bilder aus der Spalte `data` in den Dateibereich. + * Ein Startpfad hat keinen Mandanten im Ruecken und muss die noch nicht + * umgezogenen Zeilen ALLER Mandanten sehen; die passende Regel + * `system_read_policy ... FOR SELECT` auf "DashboardImage" legt die + * Migration 20260922120000 an. Dieselbe Datei bedient daneben Anfragewege + * (`list`/`upload`/`getBytes`/`remove`) — die bleiben ausnahmslos + * mandantengebunden, und auch der Umzug SCHREIBT je Zeile ueber + * `forTenant(prisma, row.tenantId, row.userId)`, nie ueber den + * Systemklienten. Praezedenz fuer "ein Dienst mit Anfrageweg UND + * systemgebundenem Startpfad": `ldap-config.service.ts`, dessen + * Nachverschluesselung in `onApplicationBootstrap()` genauso gebaut ist. + * Summe neu: 5 Dateien, 6 Aufrufe. */ const FORSYSTEM_ALLOWED_CALL_SITES = new Map([ + ['apps/api/src/dashboard/dashboard-images.service.ts', 1], ['apps/api/src/dkv/dkv.service.ts', 1], ['apps/api/src/ldap/ldap-config.service.ts', 2], ['apps/api/src/tenders/tender-digest.scheduler.ts', 1], diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 51bfa27..8ce0391 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -168,14 +168,14 @@ 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 | 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) | +| 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) | | 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** | **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 | +| **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 | ## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 74 Paare) @@ -673,7 +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-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 | 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). | @@ -808,7 +808,10 @@ werden. Anmeldenamen pro Mandant (Etappe 3a) bleibt offen. **Systemkontext (Etappe 3c) — erledigt (260914-eym):** Migration `20260914120000_rls_system_context_read` (`is_system_context()`, - `system_read_policy … FOR SELECT` auf fünf Tabellen), Schwesterhelfer + `system_read_policy … FOR SELECT` auf fünf Tabellen; seit 260922-hk4 + kommt "DashboardImage" als sechste dazu, angelegt in der Migration + 20260922120000 für den Bootstrap-Umzug der Bilderrahmen-Bilder), + Schwesterhelfer `forSystem()`, fünfte Erkennungsform des Detektors mit Erlaubnisliste; siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Systemkontext (Etappe 3c, 260914-eym)" und den Regelschluss je Fall