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<ArrayBuffer>, multers Buffer wird
  ohne Zusicherung abgelehnt - Kopie per new Uint8Array(buffer) statt Cast

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-21 18:47:07 +02:00
parent 573d070041
commit 737974b653
11 changed files with 884 additions and 10 deletions
@@ -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())
);
+20
View File
@@ -212,6 +212,26 @@ model WidgetInstance {
@@index([tenantId]) @@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 { model SearchProvider {
id String @id @default(uuid()) id String @id @default(uuid())
userId String? userId String?
@@ -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('<svg xmlns="http://www.w3.org/2000/svg"></svg>'))).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);
});
});
@@ -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;
}
@@ -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<string, string> = {};
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');
});
});
@@ -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);
}
}
@@ -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<string, (...args: unknown[]) => Promise<unknown>>;
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> = {}): 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<string, boolean> | undefined) {
if (!select) return row;
const out: Record<string, unknown> = {};
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<string, boolean> };
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<ImageRow, 'id' | 'createdAt'>; select?: Record<string, boolean> };
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<string, boolean> };
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');
}
});
});
@@ -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<DashboardImageMeta[]> {
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<DashboardImageMeta> {
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<ArrayBuffer>`, 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 };
}
}
+6 -2
View File
@@ -1,6 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ModuleRegistryModule } from '../module-registry/module-registry.module'; import { ModuleRegistryModule } from '../module-registry/module-registry.module';
import { DashboardController } from './dashboard.controller'; import { DashboardController } from './dashboard.controller';
import { DashboardImagesController } from './dashboard-images.controller';
import { DashboardImagesService } from './dashboard-images.service';
import { DashboardService } from './dashboard.service'; import { DashboardService } from './dashboard.service';
/** /**
@@ -9,6 +11,8 @@ import { DashboardService } from './dashboard.service';
* Provides: * Provides:
* - DashboardService: CRUD for per-user dashboard layouts and widget instances * - DashboardService: CRUD for per-user dashboard layouts and widget instances
* - DashboardController: REST API for layout and widget operations * - 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 * Imports ModuleRegistryModule so DashboardService can inject
* ModuleAccessService for the D-22 widget-module filter (Plan 15-05). * ModuleAccessService for the D-22 widget-module filter (Plan 15-05).
@@ -17,8 +21,8 @@ import { DashboardService } from './dashboard.service';
*/ */
@Module({ @Module({
imports: [ModuleRegistryModule], imports: [ModuleRegistryModule],
controllers: [DashboardController], controllers: [DashboardController, DashboardImagesController],
providers: [DashboardService], providers: [DashboardService, DashboardImagesService],
exports: [DashboardService], exports: [DashboardService],
}) })
export class DashboardModule {} export class DashboardModule {}
@@ -2,12 +2,22 @@ import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
/** /**
* DTO for creating a new widget instance on a user's dashboard. * 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. * config is optional and defaults to {} on the model.
*/ */
export class CreateWidgetDto { export class CreateWidgetDto {
@IsString() @IsString()
@IsIn(['clock', 'search', 'calendar', 'note', 'calculator', 'favorites', 'stopwatch']) @IsIn([
'clock',
'search',
'calendar',
'note',
'calculator',
'favorites',
'stopwatch',
'picture-frame',
])
widgetType!: string; widgetType!: string;
@IsOptional() @IsOptional()
@@ -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 | | 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) | | 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) | | 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) | | 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 | | 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 | | 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 | | 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 | | 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 | | 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** | **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 | | **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 Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf
(260909-ipc) plus ein bisher vollstaendig unsichtbares Paar (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 `apps/api/src/prisma/rls-access-inventory.spec.ts` entnommen, nicht
geschaetzt. 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 **Stand 260909-laa (Aufgabe 2):** dieselben 62 Paare, keine neue Fundstelle
hinzugekommen oder verschwunden — nur EINE Klasse hat sich verschoben: hinzugekommen oder verschwunden — nur EINE Klasse hat sich verschoben:
`tender-rss-feed.service.ts`/`tenderRssFeedSource` wechselt von `tender-rss-feed.service.ts`/`tenderRssFeedSource` wechselt von
@@ -319,11 +329,11 @@ entnommen (30 Zusicherungen, darunter der Wachhund
| Klasse | Anzahl Paare | | Klasse | Anzahl Paare |
|---|---| |---|---|
| muss-mandantengebunden | 35 | | muss-mandantengebunden | 37 |
| keine-mandantengebundene-tabelle | 21 | | keine-mandantengebundene-tabelle | 21 |
| beides | 14 | | beides | 14 |
| bewusst-uebergreifend | 2 | | bewusst-uebergreifend | 2 |
| **Summe** | **72** | | **Summe** | **74** |
## Der Hintergrunddienst als Falle — sechs Fälle ## 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/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/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/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 | 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 | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. |
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). | | apps/api/src/dashboard/dashboard.service.ts | 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). |