import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { Prisma, Role } from '@prisma/client'; import { removeFavoriteIconFileBestEffort } from '../favorites/favorite-icon-files'; import { ModuleAccessService } from '../module-registry/module-access.service'; import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension'; import { PrismaService } from '../prisma/prisma.service'; import { CreateSearchProviderDto } from './dto/create-search-provider.dto'; import { CreateWidgetDto } from './dto/create-widget.dto'; import { RenameDashboardDto } from './dto/rename-dashboard.dto'; import { ReorderDashboardsDto } from './dto/reorder-dashboards.dto'; import { SaveLayoutDto } from './dto/save-layout.dto'; import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto'; import { getModuleSlugForWidgetType } from './widget-module-map'; /** * T-AD9-06 — Riegel gegen Massenanfragen: hoechstens 20 Reiter je Benutzer * (quick-260923-ad9, Task 2). */ const DASHBOARD_MAX_COUNT = 20; /** * Default search providers (D-15). * Returned as part of getSearchProviders even when no DB rows exist. * userId null = global defaults — cannot be deleted by users. */ const DEFAULT_SEARCH_PROVIDERS = [ { id: 'google', userId: null, tenantId: null, name: 'Google', urlTemplate: 'https://www.google.com/search?q={query}', isDefault: true, createdAt: new Date('2024-01-01'), }, { id: 'bing', userId: null, tenantId: null, name: 'Bing', urlTemplate: 'https://www.bing.com/search?q={query}', isDefault: true, createdAt: new Date('2024-01-01'), }, { id: 'ddg', userId: null, tenantId: null, name: 'DuckDuckGo', urlTemplate: 'https://duckduckgo.com/?q={query}', isDefault: true, createdAt: new Date('2024-01-01'), }, ]; /** * Service managing per-user dashboard layouts and widget instances. * * Layout (position/size) and widget config are stored in separate models * to avoid unnecessary saves when only one changes (RESEARCH anti-pattern). * * All operations are scoped by userId for security (T-05-01, T-05-02) — the * three ownership checks in this file (`updateWidgetConfig`, `removeWidget`, * `removeSearchProvider`) compare against the user id from the session proof * and are NOT decorative: the RLS rules on `DashboardLayout`, `WidgetInstance` * and `SearchProvider` knew only the tenant dimension, not the user dimension, * when measured 260910-krx, Aufgabe 1, Befund G — until the switch is flipped * (WINDOWS #18) they remain the only actually effective protection against * cross-reading/cross-deleting between two users of the SAME tenant, and the * `forTenant()` binding below ADDS a tenant boundary on top of them, it never * replaces them. * * Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 tragen die * Regeln auf `DashboardLayout`, `WidgetInstance` und `SearchProvider` die * Benutzerdimension (`current_user_id() IS NULL OR "userId" = current_user_id()`, * fuer `SearchProvider` zusaetzlich als vier befehlsgetrennte Regeln) — jeder * `forTenant()`-Aufruf unten reicht `userId` als drittes Argument durch. Die * drei anwendungsseitigen Besitzpruefungen bleiben UNVERAENDERT: zweites Netz, * kein Ersatz. Ein Aufrufer, der `userId` vergisst, saehe ohne sie den ganzen * Mandanten (siehe .planning/WINDOWS.md). Beobachtung fuer die Kritikschrift: * `removeWidget`/`updateWidgetConfig`/`removeSearchProvider` holen die Zeile * per `findUnique({ where: { id } })` und vergleichen danach `userId` — nach * dem Scharfschalten liefert `findUnique` fuer die Zeile eines Kollegen * bereits `null` (die Regel blendet sie aus), die Anwendung meldet dann * NotFoundException statt der heutigen Forbidden-Form — beides eine * Abweisung, nur die Fehlerart aendert sich. */ @Injectable() export class DashboardService { private readonly logger = new Logger(DashboardService.name); constructor( private readonly prisma: PrismaService, private readonly moduleAccessService: ModuleAccessService, ) {} /** * T-LRR-07 (quick-260923-lrr, Restrisiko aus dem Favoriten-Plan * geschlossen): loescht ein Widget seine `FavoriteLink`-Zeilen ueber die * Datenbank-Kaskade (`onDelete: Cascade` auf `FavoriteLink.widgetId`), * OHNE `FavoritesService` zu durchlaufen — dessen Datei-Aufraeumung in * `remove()` greift hier also nicht. Diese Hilfsfunktion entfernt die * Symboldateien der betroffenen Favoriten NACHTRAEGLICH, best effort * (Muster T-HK4-04): ein Dateifehler wird protokolliert und geschluckt, * er darf das Loeschen des Widgets/Reiters nie verhindern oder * zuruecknehmen — deshalb laeuft dieser Aufruf immer NACH der * erfolgreichen Datenbankoperation, nie innerhalb ihrer Transaktion. */ private async cleanUpFavoriteIconFiles( userId: string, rows: Array<{ id: string; uploadedIconMime: string | null }>, ): Promise { for (const row of rows) { if (row.uploadedIconMime === null) continue; const removed = await removeFavoriteIconFileBestEffort(userId, row.id, row.uploadedIconMime); if (!removed) { this.logger.warn( `Symboldatei des kaskadiert geloeschten Favoriten ${row.id} konnte nicht entfernt werden (T-LRR-07)`, ); } } } /** * Reiter (quick-260923-ad9, D-01/D-08/D-09): liest die Dashboards des * Benutzers, nach `position` aufsteigend — Position 0 ist der Standard * und wird beim Öffnen geladen. Ist die Liste leer (erster Aufruf des * Benutzers ueberhaupt), wird genau EIN Reiter „Dashboard“ angelegt. * * Das Anlegen laeuft in einer `withTenantTransaction`, deren ERSTE * Anweisung eine Transaktionssperre auf die Benutzerkennung nimmt * (`pg_advisory_xact_lock`, `hashtext` ueber die Benutzerkennung als * ersten Schluessel, 0 als zweiten — beides eingebaute Postgres- * Funktionen). Zwei gleichzeitige erste Aufrufe desselben Benutzers * warten dadurch aufeinander statt beide "kein Reiter vorhanden" zu * sehen; die erneute Zaehlung INNERHALB der Sperre verhindert die * doppelte Anlage (T-AD9-07). `withTenantTransaction` setzt keine * Benutzerdimension in der Sitzung — die Bedingung traegt `userId` UND * `tenantId` deshalb selbst, als zweites Netz. */ async listDashboards(userId: string, tenantId: string) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); let dashboards = await tenantPrisma.dashboard.findMany({ where: { userId }, orderBy: { position: 'asc' }, }); if (dashboards.length === 0) { await withTenantTransaction(this.prisma, tenantId, async (tx) => { await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${userId}), 0)`; const existing = await tx.dashboard.count({ where: { userId, tenantId }, }); if (existing === 0) { await tx.dashboard.create({ data: { userId, tenantId, name: 'Dashboard', position: 0 }, }); } }); dashboards = await tenantPrisma.dashboard.findMany({ where: { userId }, orderBy: { position: 'asc' }, }); } return dashboards; } /** * Riegel gegen fremde Reiter (T-AD9-01/02/03, Muster `FavoritesService. * create`/T-GWH-05): liest den Reiter ueber den BEREITS gebundenen * Klienten des Aufrufers (kein zweiter `forTenant()`-Aufruf) und wirft * fuer drei ununterscheidbare Faelle dieselbe `NotFoundException` — "gibt * es nicht", "gehoert einem Kollegen" und "liegt bei einem fremden * Mandanten" (die Mandantengrenze zieht bereits der gebundene Klient). * Niemals eine abweichende Antwort, aus der sich die Existenz eines * fremden Reiters ablesen liesse. */ private async assertOwnedDashboard( tenantPrisma: ReturnType, dashboardId: string, userId: string, ): Promise { const dashboard = await tenantPrisma.dashboard.findUnique({ where: { id: dashboardId }, }); if (!dashboard || dashboard.userId !== userId) { throw new NotFoundException(`Dashboard with id '${dashboardId}' not found`); } } /** * Legt einen neuen, leeren Reiter an (quick-260923-ad9, Task 2, D-08). * Name automatisch: "Dashboard 2", "Dashboard 3", … — die kleinste noch * freie Zahl ab 2 (füllt eine Lücke, wenn z. B. "Dashboard 2" gelöscht * wurde). Dieser Name ist ein gespeicherter Datenwert, keine * Oberflächenbeschriftung — deshalb ein TypeScript-Text hier statt eines * Übersetzungsschlüssels, genau wie der Name "Dashboard", den die * Migration/`listDashboards` vergeben. Hängt ans Ende (höchste * vorhandene Position plus eins) und liefert den neuen Reiter mit * leerer Kachelliste (es existiert noch keine `WidgetInstance`-Zeile * dafür). */ async createDashboard(userId: string, tenantId: string) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const existing = await tenantPrisma.dashboard.findMany({ where: { userId } }); if (existing.length >= DASHBOARD_MAX_COUNT) { throw new BadRequestException( `Es sind bereits ${DASHBOARD_MAX_COUNT} Dashboards vorhanden — mehr sind nicht möglich.`, ); } const existingNames = new Set(existing.map((d) => d.name)); let n = 2; while (existingNames.has(`Dashboard ${n}`)) n++; const nextPosition = existing.reduce((max, d) => Math.max(max, d.position), -1) + 1; return tenantPrisma.dashboard.create({ data: { userId, tenantId, name: `Dashboard ${n}`, position: nextPosition }, }); } /** * Benennt einen Reiter um (quick-260923-ad9, Task 2). `assertOwnedDashboard` * läuft zuerst, über denselben gebundenen Klienten — eine fremde Kennung * liefert die Nicht-gefunden-Antwort (T-AD9-03). Beschneiden und * Längenprüfung (1–40 Zeichen) liegen bereits im DTO. */ async renameDashboard( id: string, userId: string, tenantId: string, dto: RenameDashboardDto, ) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); await this.assertOwnedDashboard(tenantPrisma, id, userId); return tenantPrisma.dashboard.update({ where: { id }, data: { name: dto.name }, }); } /** * Löscht einen Reiter mit seinen Kacheln und seiner Anordnung * (quick-260923-ad9, Task 2). `assertOwnedDashboard` läuft zuerst; danach * wird geprüft, ob es der letzte verbleibende Reiter ist (D-10) — der * Server weist das ab, die Oberfläche bietet den Knopf dafür gar nicht * erst an. Löschen, Anordnung-/Kachel-Entfernen und das lückenlose * Neuschreiben der verbleibenden Positionen laufen als EINE * `withTenantTransaction` (mehrschrittig, muss atomar sein — dieselbe * Begründung wie `FavoritesService.reorder`). Die Löschweitergabe in der * Datenbank (`onDelete: Cascade`) bleibt als zweites Netz bestehen; der * geschriebene Weg unten ist der gebundene. `withTenantTransaction` * setzt keine Benutzerdimension in der Sitzung — jede Bedingung trägt * `userId` deshalb selbst. */ async deleteDashboard(id: string, userId: string, tenantId: string) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); await this.assertOwnedDashboard(tenantPrisma, id, userId); const count = await tenantPrisma.dashboard.count({ where: { userId, tenantId } }); if (count <= 1) { throw new ConflictException('Der letzte verbleibende Reiter kann nicht gelöscht werden.'); } // T-LRR-07: VOR der Kaskade merken, welche Favoriten dieses Reiters ein // eigenes hochgeladenes Symbol tragen — siehe `cleanUpFavoriteIconFiles`. // Nur ein Lesezugriff, kein Schreiben; laeuft ausserhalb der Transaktion // unten, weil die Dateiraeumung selbst NICHT transaktional sein muss // (und best effort niemals einen Rollback ausloesen darf). const widgetsOnTab = await tenantPrisma.widgetInstance.findMany({ where: { dashboardId: id, userId }, select: { id: true }, }); const widgetIds = widgetsOnTab.map((w: { id: string }) => w.id); const iconRows = widgetIds.length === 0 ? [] : await tenantPrisma.favoriteLink.findMany({ where: { widgetId: { in: widgetIds }, userId, uploadedIconMime: { not: null } }, select: { id: true, uploadedIconMime: true }, }); const result = await withTenantTransaction(this.prisma, tenantId, async (tx) => { await tx.widgetInstance.deleteMany({ where: { dashboardId: id, userId } }); await tx.dashboardLayout.deleteMany({ where: { dashboardId: id, userId } }); await tx.dashboard.deleteMany({ where: { id, userId } }); const remaining = await tx.dashboard.findMany({ where: { userId }, orderBy: { position: 'asc' }, }); for (const [index, dashboard] of remaining.entries()) { await tx.dashboard.updateMany({ where: { id: dashboard.id, userId }, data: { position: index }, }); } return { id }; }); await this.cleanUpFavoriteIconFiles(userId, iconRows); return result; } /** * Persistiert die Reihenfolge der Reiter des Benutzers * (quick-260923-ad9, Task 2). Wörtlich nach dem Muster * `FavoritesService.reorder` (260917-jdd): EINE `withTenantTransaction`, * darin erst die vorhandenen Kennungen lesen, auf exakte Übereinstimmung * mit der gesendeten Liste prüfen (sonst Abweisung, KEIN Teilschreiben — * die Prüfung läuft VOR jedem `updateMany`), dann je Eintrag ein * `updateMany` mit `id` UND `userId` in der Bedingung und einer Prüfung * auf genau eine getroffene Zeile (T-AD9-04). Existenzorakel-Vermeidung: * EINE `BadRequestException` mit DERSELBEN Meldung für unvollständige, * unbekannte und fremde Kennungen — kein Fall verrät, welcher Grund * zutraf (Muster T-GWH-05/T-JDD-06). */ async reorderDashboards(userId: string, tenantId: string, dto: ReorderDashboardsDto) { if (new Set(dto.ids).size !== dto.ids.length) { throw new BadRequestException('ids must match the dashboards of this user exactly'); } return withTenantTransaction(this.prisma, tenantId, async (tx) => { const existing = await tx.dashboard.findMany({ where: { userId }, select: { id: true }, }); const existingIds = new Set(existing.map((r: { id: string }) => r.id)); if (existing.length !== dto.ids.length || dto.ids.some((id) => !existingIds.has(id))) { throw new BadRequestException('ids must match the dashboards of this user exactly'); } for (const [index, id] of dto.ids.entries()) { const { count } = await tx.dashboard.updateMany({ where: { id, userId }, data: { position: index }, }); if (count !== 1) { throw new BadRequestException('ids must match the dashboards of this user exactly'); } } return tx.dashboard.findMany({ where: { userId }, orderBy: { position: 'asc' }, }); }); } /** * Returns the saved layout of one dashboard tab, or a default empty * layout with all breakpoint arrays initialized. * * quick-260923-ad9 (D-02): scoped by `dashboardId` instead of `userId` — * `assertOwnedDashboard` runs first, over the SAME bound client. */ async getLayout(userId: string, tenantId: string, dashboardId: string) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); await this.assertOwnedDashboard(tenantPrisma, dashboardId, userId); const record = await tenantPrisma.dashboardLayout.findUnique({ where: { dashboardId }, }); if (!record) { return { lg: [], md: [], sm: [], xs: [], xxs: [] }; } return record.layouts; } /** * Upserts the layout of one dashboard tab. * Creates a new record if none exists, updates if it does. * * quick-260923-ad9 (D-02): scoped by `dto.dashboardId` instead of * `userId` — `assertOwnedDashboard` runs first, over the SAME bound * client. `dashboardId` is now the `@unique` column on `DashboardLayout` * (was `userId` before this plan). * * A bound conflicting upsert against a row invisible under RLS throws * `Prisma.PrismaClientUnknownRequestError` (NOT the `P2002` known error * that the `tenders` area's translation pattern catches — this is a * different Prisma error class, `.code`/`.meta` are `undefined`, the only * signal is the raw `.message` text) — measured 260910-krx, Aufgabe 1, * translation kept unchanged from before this plan. */ async saveLayout(userId: string, tenantId: string, dto: SaveLayoutDto) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); await this.assertOwnedDashboard(tenantPrisma, dto.dashboardId, userId); try { return await tenantPrisma.dashboardLayout.upsert({ where: { dashboardId: dto.dashboardId }, update: { layouts: dto.layouts as unknown as Prisma.InputJsonValue }, create: { userId, tenantId, dashboardId: dto.dashboardId, layouts: dto.layouts as unknown as Prisma.InputJsonValue, }, }); } catch (error) { if (error instanceof Prisma.PrismaClientUnknownRequestError) { throw new ConflictException( 'Die Dashboard-Anordnung konnte nicht gespeichert werden, weil bereits ein widersprüchlicher Eintrag existiert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.', ); } throw error; } } /** * Returns all widget instances of one dashboard tab, gefiltert um Widgets * eines für den Benutzer gesperrten Moduls (D-22, PERM-07). * * quick-260923-ad9 (D-02): scoped by `dashboardId` instead of `userId` — * `assertOwnedDashboard` runs first, over the SAME bound client. Steht * unter den geladenen Widgets kein einziger Typ in `WIDGET_MODULE_MAP` — * der Zustand am Ende dieser Phase, weil die Tabelle leer ist — wird die * Liste unverändert zurückgegeben, ohne einen Zugriffs-Lookup. Nur bei * mindestens einem modulgebundenen Widget wird die Zugriffsauflösung * aus 15-01 einmal aufgerufen (D-01: dieselbe Auflösung wie Guard und * Sidebar, keine zweite Implementierung). Lässt sich ein eingetragener * Modul-Slug nicht auf einen `Module`-Datensatz auflösen, wird das * betroffene Widget entfernt (Fail-Closed). */ async getWidgets(userId: string, tenantId: string, role: Role, dashboardId: string) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); await this.assertOwnedDashboard(tenantPrisma, dashboardId, userId); const widgets = await tenantPrisma.widgetInstance.findMany({ where: { dashboardId }, orderBy: { createdAt: 'asc' }, }); const boundSlugs = [ ...new Set( widgets .map((w) => getModuleSlugForWidgetType(w.widgetType)) .filter((slug): slug is string => slug !== undefined), ), ]; if (boundSlugs.length === 0) { return widgets; } // getAccessibleModuleIds() already binds internally (260910-exd, // module-access.service.ts) — do NOT wrap it a second time here. const accessibleModuleIds = await this.moduleAccessService.getAccessibleModuleIds( tenantId, userId, role, ); // Module catalogue: deliberately left UNBOUND — see the reasoning at // the bottom of this file (260910-krx, Aufgabe 3). const modules = await this.prisma.module.findMany({ where: { slug: { in: boundSlugs } }, select: { id: true, slug: true }, }); const slugToModuleId = new Map(modules.map((m) => [m.slug, m.id])); return widgets.filter((w) => { const slug = getModuleSlugForWidgetType(w.widgetType); if (slug === undefined) { return true; } const moduleId = slugToModuleId.get(slug); if (moduleId === undefined) { return false; } return accessibleModuleIds.has(moduleId); }); } /** * Creates a new widget instance on one dashboard tab. * quick-260923-ad9 (D-02): `assertOwnedDashboard` runs first, over the * SAME bound client — a widget can only be created on a tab the caller * owns. */ async addWidget(userId: string, tenantId: string, dto: CreateWidgetDto) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); await this.assertOwnedDashboard(tenantPrisma, dto.dashboardId, userId); return tenantPrisma.widgetInstance.create({ data: { userId, tenantId, dashboardId: dto.dashboardId, widgetType: dto.widgetType, config: (dto.config ?? {}) as unknown as Prisma.InputJsonValue, }, }); } /** * Updates the config of a widget instance. * Verifies ownership by userId before updating (T-05-01) — REAL, not * decorative (unlike the `ldap`/`dkv` findUnique-then-write shape that * produced this effort's first two vulnerabilities): `widget.userId !== * userId` genuinely compares against the session-sourced user id and * subsumes the tenant dimension. Both queries below run over the SAME * bound client and the same tenant id — reading and writing are never * split across the binding, or the check could pass on a row the write no * longer sees, or vice versa (260910-krx, Aufgabe 1, Befund D). */ async updateWidgetConfig( id: string, userId: string, tenantId: string, dto: UpdateWidgetConfigDto, ) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const widget = await tenantPrisma.widgetInstance.findUnique({ where: { id }, }); if (!widget || widget.userId !== userId) { throw new NotFoundException( `Widget with id '${id}' not found`, ); } // Merge existing config with new config const mergedConfig = { ...(widget.config as Record), ...dto.config, }; return tenantPrisma.widgetInstance.update({ where: { id }, data: { config: mergedConfig as unknown as Prisma.InputJsonValue }, }); } /** * Removes a widget instance. * Verifies ownership by userId before deleting (T-05-01) — same real * ownership check as `updateWidgetConfig` above, same reasoning: both * queries run over the SAME bound client and tenant id. * * T-LRR-07 (quick-260923-lrr): dieselbe Kaskade wie in `deleteDashboard` * trifft hier ein einzelnes Widget — vor dem Loeschen werden dessen * Favoriten mit hochgeladenem Symbol gemerkt, danach werden ihre Dateien * best effort entfernt (siehe `cleanUpFavoriteIconFiles`). */ async removeWidget(id: string, userId: string, tenantId: string) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const widget = await tenantPrisma.widgetInstance.findUnique({ where: { id }, }); if (!widget || widget.userId !== userId) { throw new NotFoundException( `Widget with id '${id}' not found`, ); } const iconRows = await tenantPrisma.favoriteLink.findMany({ where: { widgetId: id, userId, uploadedIconMime: { not: null } }, select: { id: true, uploadedIconMime: true }, }); const result = await tenantPrisma.widgetInstance.delete({ where: { id }, }); await this.cleanUpFavoriteIconFiles(userId, iconRows); return result; } // --- Search Providers (05-02, D-15) --- /** * Returns the three default providers merged with any user-custom providers. * Defaults are always returned even with an empty DB (no seed migration needed). * The three defaults come from the TypeScript constant above (decision * 05-02), never from the database — they are unaffected by the binding * below and are always prepended unchanged. */ async getSearchProviders(userId: string, tenantId: string) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const custom = await tenantPrisma.searchProvider.findMany({ where: { userId }, orderBy: { createdAt: 'asc' }, }); return [...DEFAULT_SEARCH_PROVIDERS, ...custom]; } /** * Creates a user-custom search provider. `tenantId` stays a required * parameter of this method — the only write path this model has (260910-krx, * Aufgabe 1, Befund F, WINDOWS #19): no application path exists that * creates a tenant-less row, which is why the RLS rule on `SearchProvider` * was deliberately left unchanged/strict in migration 20260910120000. */ async addSearchProvider( userId: string, tenantId: string, dto: CreateSearchProviderDto, ) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); return tenantPrisma.searchProvider.create({ data: { userId, tenantId, name: dto.name, urlTemplate: dto.urlTemplate, isDefault: false, }, }); } /** * Removes a user-custom search provider. * Verifies ownership — default providers (userId null) cannot be deleted * (T-05-07) — REAL, same reasoning as `updateWidgetConfig`/`removeWidget` * above: both queries run over the SAME bound client and tenant id. */ async removeSearchProvider(id: string, userId: string, tenantId: string) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); // Default providers have hardcoded IDs that won't exist in DB const provider = await tenantPrisma.searchProvider.findUnique({ where: { id }, }); if (!provider || provider.userId !== userId) { throw new NotFoundException( `Search provider with id '${id}' not found`, ); } return tenantPrisma.searchProvider.delete({ where: { id }, }); } } // --- Modulkatalog: bewusst ungebunden (260910-krx, Aufgabe 3) -------------- // // Der eine verbleibende ungebundene Modellzugriff dieser Datei (das // `module`-Modell in `getWidgets`, ueber den ungebundenen Basisclient) // betrifft den plattformweiten Modulkatalog (`Module`). // MESSUNG (rls-scratch-check.mjs, Pruefung `module-tabelle-traegt-keinen- // zeilenschutz`, uebernommen aus dem Bereich `module-registry`, 260910-exd // Befund E): die Tabelle traegt heute KEINEN Zeilenschutz — `pg_class. // relrowsecurity` ist `false`, eine Bindung waere heute WIRKUNGSLOS, nicht // katastrophal. BEDINGUNG: sie wuerde katastrophal, WENN Etappe 3 dieser // Tabelle eine Regel gibt — dann verschwaende der gesamte Katalog fuer jeden // Mandanten. Die Katalogaufloesung, die dieser Dienst fuer den Widget- // Modulfilter aufruft (`ModuleAccessService.getAccessibleModuleIds`), bindet // bereits seit 260910-exd in ihrem eigenen Dienst — dieser Zugriff wird hier // NICHT ein zweites Mal gebunden.