diff --git a/.planning/WINDOWS.md b/.planning/WINDOWS.md index 56f0a34..d11d782 100644 --- a/.planning/WINDOWS.md +++ b/.planning/WINDOWS.md @@ -1,10 +1,10 @@ --- schema_version: 1 -open_count: 6 +open_count: 7 waived_count: 1 fixed_count: 17 -total_count: 24 -last_updated: 2026-09-10T12:35:40.000Z +total_count: 25 +last_updated: 2026-09-11T09:01:00.000Z --- # Broken Windows Ledger @@ -39,6 +39,7 @@ last_updated: 2026-09-10T12:35:40.000Z | 22 | quick-260910-das | deviation | apps/api/src/user/user.service.ts | | Plattformweite Eindeutigkeit von username/email (kein tenantId-Anteil im Unique-Index): die gemessene Kette unsichtbare Zeile -> falsches 'frei' -> harter Eindeutigkeitsfehler (SQLSTATE 23505) ist in dieser Etappe im Anwendungscode entschaerft (Konfliktmeldung bei create/update, Startsperre in admin-seed.service.ts abgefangen), nicht an der Ursache geloest. Die ehrliche Reparatur waere eine Schemaaenderung (Eindeutigkeit mit Mandantendimension) und ist als Produktentscheidung fuer Etappe 3 vorgemerkt. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich user' (u1/u4). | open | | 2026-09-10T08:17:20.009Z | | | 23 | quick-260910-exd | deviation | apps/api/src/module-registry/module-access.service.ts | | Es gibt heute KEIN Signal, das 'wirklich keine Freigabe' (USER hat tatsaechlich keinen Grant) von 'die Abfrage hat nichts gefunden' (z. B. eine nach dem Scharfschalten ungebunden gebliebene Abfrage) unterscheidet: dieselbe ForbiddenException-Meldung im Waechter, dieselbe leere Modulliste mit Status 200, kein Protokolleintrag. Nach dem Scharfschalten ist ein zu kleines Ergebnis in diesem Bereich TOTAL und lautlos -- JEDER Benutzer JEDES Mandanten verliert gleichzeitig jedes Modul, waehrend die Aktivierungs- und Freigabetabellen weiterhin Zeilen halten -- und sieht fuer den Betroffenen wie ein absichtlicher Rechteentzug aus, nicht wie ein Fehler (der Betroffene hat eine fertige, falsche Erklaerung zur Hand und meldet deshalb keinen Fehler). Konkrete Vorabpruefung fuer Etappe 4 (rls-preflight.mjs): aktive Aktivierungszeilen vorhanden, aber die Aufloesung liefert fuer einen bekannten Administrator eine leere Menge. Eine Laufzeitwarnung an den betroffenen Stellen wurde erwogen und begruendet verworfen (Dauerlaerm auf einer frischen Installation, dieselbe Begruendung wie bei getAllActiveConfigs im Bereich ldap und den fuenf Stellen im Bereich tenders). In module.guard.spec.ts als Testfall festgenagelt, damit die Aufzeichnung rot wird, sobald jemand ein unterscheidendes Signal einbaut. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich module-registry' (m3). | open | | 2026-09-10T09:28:45.310Z | | | 24 | quick-260910-jab | deviation | apps/api/src/tenders/tender-rss-feed.service.ts | | Was das Schliessen von WINDOWS #19 NICHT loest: unter der Anwendungsrolle laesst sich eine plattformweite RSS-Quelle (TenderRssFeedSource, tenantId NULL) weder anlegen noch entfernen — in der alten wie in der neuen Regel, weil jede Schreibregel (Einfuegen/Aendern/Entfernen) ausdruecklich einen Mandanten verlangt (tenant_insert_policy/tenant_update_policy/tenant_delete_policy, 20260910120000_rls_widen_membership_grant_and_platform_read). Betroffen sind zwei Pfade in TenderRssFeedSourceService: createPlatform() (setzt tenantId=NULL, ein gebundenes INSERT liefe in die WITH-CHECK-Klausel und wuerde abgewiesen) und remove() (deckt fuer Administratoren auch das Entfernen einer plattformweiten Zeile ab; ein gebundenes DELETE traefe sie nie). Beide bleiben deshalb bewusst ungebunden — das ist KEINE Folge dieser Reparatur, sondern bestand bereits vor 260910-jab identisch, weil die vom Ledger vorgegebene #19-Semantik Schreibzugriffe ausdruecklich an einen Mandanten bindet. Vorabpruefung fuer Etappe 4 (rls-preflight.mjs): ein Verwaltungsweg fuer plattformweite Zeilen (Anlegen/Entfernen unter der Anwendungsrolle) muss gebaut werden, BEVOR die Rolle umgeschaltet wird — sonst kann kein Administrator nach dem Scharfschalten mehr eine plattformweite Quelle pflegen. Eigener Eintrag, damit dieser Rest nicht mit #19 verschwindet. | open | | 2026-09-10T12:35:40.000Z | | +| 25 | quick-260910-krx | deviation | apps/web/src/lib/stores/dashboard-store.ts | | Die beweisvernichtende Auspraegung der umgekehrten Fehlerrichtung im Bereich dashboard: ein nach dem Scharfschalten (WINDOWS #18) zu klein gebliebenes Leseergebnis auf getLayout sieht nicht wie ein Fehler aus, sondern wie eine leere Vorgabeanordnung. Drei Stellen greifen ineinander (docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich dashboard', (w3)): (1) DashboardService.getLayout liefert bei fehlendem Datensatz {lg:[],md:[],sm:[],xs:[],xxs:[]} statt eines Fehlers; (2) apps/web/src/lib/stores/dashboard-store.ts, loadDashboard setzt layouts/widgets ungeprueft auf das Ergebnis, der catch-Zweig feuert nur bei Netzwerk-/Statusfehlern, nicht bei einer erfolgreichen leeren Antwort; (3) dieselbe Datei, setEditMode(false) schreibt bei isDirty automatisch zurueck, sobald der Bearbeitungsmodus verlassen wird — ohne dass der Nutzer auf Speichern klickt. Die Folge: der Nutzer haelt ein leeres Dashboard fuer einen Fehler des Widget-Systems oder fuer verlorene Einstellungen ('das Widget-System spinnt', 'meine Einstellungen sind weg'), baut seine Anordnung neu auf (addWidget legt echte neue WidgetInstance-Zeilen an, keine Eindeutigkeitsbedingung ueber (userId, widgetType), Dubletten haeufen sich bei wiederholtem Neuaufbau an), und das automatische Zurueckschreiben ueberschreibt die layouts-Spalte der urspruenglichen Zeile — die einzige Aufzeichnung der urspruenglichen Anordnung ist verloren, bevor irgendjemand die Ursache untersuchen konnte. Zusaetzlich, kleiner: apps/web/src/components/dashboard/widgets/search-widget.tsx laesst bei einem zu kleinen custom-Ergebnis die eigenen Suchmaschinen des Nutzers aus der Auswahlliste verschwinden (der Rueckfallzweig auf DEFAULT_PROVIDERS feuert nie, weil getSearchProviders die drei Vorgaben immer voranstellt), und handleSearch faellt bei unbekannter Auswahl auf providers[0] (Google) zurueck — eine fuer ein internes Werkzeug gedachte Suchanfrage ginge dann an eine externe Suchmaschine. Konkrete Vorabpruefung fuer Etappe 4 (rls-preflight.mjs): eine physisch vorhandene DashboardLayout-Zeile fuer einen bekannten Benutzer, aber der gebundene Lesezugriff fuer dessen Mandanten liefert null — das unterscheidet den echten Erstbenutzer-Fall vom Trennungsfehler. An dieselbe Bedingung gebunden wie #18 — beobachtbar erst nach dem Scharfschalten. Das Frontend wird von 260910-krx NICHT geaendert, dieser Eintrag beschreibt es nur. Die verwandte, strukturelle Eindeutigkeitsfrage von DashboardLayout.userId (plattformweit @unique ohne Mandantenanteil) ist derselbe Fall wie WINDOWS #22 im Bereich user — dort mitgefuehrt, kein zweiter Eintrag hier. | open | | 2026-09-11T09:01:00.000Z | | ````json [ @@ -329,6 +330,18 @@ last_updated: 2026-09-10T12:35:40.000Z "reason": "", "recorded_at": "2026-09-10T12:35:40.000Z", "resolved_at": null + }, + { + "id": 25, + "kind": "deviation", + "phase": "quick-260910-krx", + "file": "apps/web/src/lib/stores/dashboard-store.ts", + "line": null, + "description": "Die beweisvernichtende Auspraegung der umgekehrten Fehlerrichtung im Bereich dashboard: ein nach dem Scharfschalten (WINDOWS #18) zu klein gebliebenes Leseergebnis auf getLayout sieht nicht wie ein Fehler aus, sondern wie eine leere Vorgabeanordnung. Drei Stellen greifen ineinander (docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich dashboard', (w3)): (1) DashboardService.getLayout liefert bei fehlendem Datensatz {lg:[],md:[],sm:[],xs:[],xxs:[]} statt eines Fehlers; (2) apps/web/src/lib/stores/dashboard-store.ts, loadDashboard setzt layouts/widgets ungeprueft auf das Ergebnis, der catch-Zweig feuert nur bei Netzwerk-/Statusfehlern, nicht bei einer erfolgreichen leeren Antwort; (3) dieselbe Datei, setEditMode(false) schreibt bei isDirty automatisch zurueck, sobald der Bearbeitungsmodus verlassen wird — ohne dass der Nutzer auf Speichern klickt. Die Folge: der Nutzer haelt ein leeres Dashboard fuer einen Fehler des Widget-Systems oder fuer verlorene Einstellungen ('das Widget-System spinnt', 'meine Einstellungen sind weg'), baut seine Anordnung neu auf (addWidget legt echte neue WidgetInstance-Zeilen an, keine Eindeutigkeitsbedingung ueber (userId, widgetType), Dubletten haeufen sich bei wiederholtem Neuaufbau an), und das automatische Zurueckschreiben ueberschreibt die layouts-Spalte der urspruenglichen Zeile — die einzige Aufzeichnung der urspruenglichen Anordnung ist verloren, bevor irgendjemand die Ursache untersuchen konnte. Zusaetzlich, kleiner: apps/web/src/components/dashboard/widgets/search-widget.tsx laesst bei einem zu kleinen custom-Ergebnis die eigenen Suchmaschinen des Nutzers aus der Auswahlliste verschwinden (der Rueckfallzweig auf DEFAULT_PROVIDERS feuert nie, weil getSearchProviders die drei Vorgaben immer voranstellt), und handleSearch faellt bei unbekannter Auswahl auf providers[0] (Google) zurueck — eine fuer ein internes Werkzeug gedachte Suchanfrage ginge dann an eine externe Suchmaschine. Konkrete Vorabpruefung fuer Etappe 4 (rls-preflight.mjs): eine physisch vorhandene DashboardLayout-Zeile fuer einen bekannten Benutzer, aber der gebundene Lesezugriff fuer dessen Mandanten liefert null — das unterscheidet den echten Erstbenutzer-Fall vom Trennungsfehler. An dieselbe Bedingung gebunden wie #18 — beobachtbar erst nach dem Scharfschalten. Das Frontend wird von 260910-krx NICHT geaendert, dieser Eintrag beschreibt es nur. Die verwandte, strukturelle Eindeutigkeitsfrage von DashboardLayout.userId (plattformweit @unique ohne Mandantenanteil) ist derselbe Fall wie WINDOWS #22 im Bereich user — dort mitgefuehrt, kein zweiter Eintrag hier.", + "status": "open", + "reason": "", + "recorded_at": "2026-09-11T09:01:00.000Z", + "resolved_at": null } ] ```` diff --git a/apps/api/src/dashboard/dashboard.controller.ts b/apps/api/src/dashboard/dashboard.controller.ts index ef4fa6e..a04805a 100644 --- a/apps/api/src/dashboard/dashboard.controller.ts +++ b/apps/api/src/dashboard/dashboard.controller.ts @@ -102,8 +102,8 @@ export class DashboardController { @Get('search-providers') async getSearchProviders(@Req() req: Request) { - const { userId } = this.extractContext(req); - return this.dashboardService.getSearchProviders(userId); + const { userId, tenantId } = this.extractContext(req); + return this.dashboardService.getSearchProviders(userId, tenantId); } @Post('search-providers') @@ -120,7 +120,7 @@ export class DashboardController { @Param('id') id: string, @Req() req: Request, ) { - const { userId } = this.extractContext(req); - return this.dashboardService.removeSearchProvider(id, userId); + const { userId, tenantId } = this.extractContext(req); + return this.dashboardService.removeSearchProvider(id, userId, tenantId); } } diff --git a/apps/api/src/dashboard/dashboard.service.spec.ts b/apps/api/src/dashboard/dashboard.service.spec.ts index 941d205..0a43ab9 100644 --- a/apps/api/src/dashboard/dashboard.service.spec.ts +++ b/apps/api/src/dashboard/dashboard.service.spec.ts @@ -40,8 +40,9 @@ import { DashboardService } from './dashboard.service'; /** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das * Herkunfts-Tenant protokollierenden Wrapper versieht. `module` ist bewusst - * NICHT enthalten — der Katalogzugriff bleibt ungebunden. */ -const BOUND_MODEL_NAMES = ['dashboardLayout', 'widgetInstance']; + * NICHT enthalten — der Katalogzugriff bleibt ungebunden. `searchProvider` + * ergaenzt seit Aufgabe 3 (260910-krx). */ +const BOUND_MODEL_NAMES = ['dashboardLayout', 'widgetInstance', 'searchProvider']; function makeWidget( overrides: Partial<{ @@ -63,16 +64,40 @@ function makeWidget( }; } +function makeSearchProvider( + overrides: Partial<{ + id: string; + userId: string | null; + tenantId: string | null; + name: string; + urlTemplate: string; + isDefault: boolean; + createdAt: Date; + }> = {}, +) { + return { + id: overrides.id ?? 'sp1', + userId: overrides.userId ?? 'user-1', + tenantId: overrides.tenantId ?? 'tenant-1', + name: overrides.name ?? 'Eigene Suche', + urlTemplate: overrides.urlTemplate ?? 'https://example.test/?q={query}', + isDefault: overrides.isDefault ?? false, + createdAt: overrides.createdAt ?? new Date('2026-01-01'), + }; +} + function makeFakePrisma( opts: { widgets?: ReturnType[]; modules?: { id: string; slug: string }[]; layout?: { userId: string; tenantId: string; layouts: unknown } | null; + searchProviders?: ReturnType[]; } = {}, ) { const widgets = opts.widgets ?? []; const modules = opts.modules ?? []; let layoutRow = opts.layout ?? null; + const searchProviders = opts.searchProviders ?? []; const boundCallLog: { tenantId: string; model: string; method: string }[] = []; const fake: any = { @@ -124,6 +149,28 @@ function makeFakePrisma( return modules.filter((m) => slugs.includes(m.slug)); }), }, + searchProvider: { + findMany: vi.fn(async ({ where }: any) => { + return searchProviders + .filter((p) => p.userId === where.userId) + .slice() + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + }), + create: vi.fn(async ({ data }: any) => { + const created = makeSearchProvider({ id: `new-sp-${searchProviders.length + 1}`, ...data }); + searchProviders.push(created); + return created; + }), + findUnique: vi.fn(async ({ where }: any) => { + return searchProviders.find((p) => p.id === where.id) ?? null; + }), + delete: vi.fn(async ({ where }: any) => { + const idx = searchProviders.findIndex((p) => p.id === where.id); + if (idx === -1) return null; + const [removed] = searchProviders.splice(idx, 1); + return removed; + }), + }, // --- Bindungsnachweis (260910-krx, Muster aus 260910-exd) -------------- __boundCallLog: boundCallLog, __makeBoundClient(tenantId: string) { @@ -479,3 +526,124 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26 expect(result).toEqual([]); }); }); + +// --- Bindung an forTenant() (260910-krx, Aufgabe 3: Suchmaschinen) --------- + +describe('DashboardService — Suchmaschinen gebunden an forTenant(), Katalog bewusst ungebunden (260910-krx, Aufgabe 3)', () => { + beforeEach(() => { + for (const key of Object.keys(mockMap)) delete mockMap[key]; + vi.mocked(forTenant).mockClear(); + }); + + it('Suchmaschinen lesen: der Lesezugriff läuft gebunden, die drei Vorgabe-Suchmaschinen aus der Konstante werden UNVERÄNDERT vorangestellt', async () => { + const own = makeSearchProvider({ id: 'sp1', userId: 'user-1', tenantId: 'tenant-1' }); + const prisma = makeFakePrisma({ searchProviders: [own] }); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + const result = await service.getSearchProviders('user-1', 'tenant-1'); + + expect(result.map((p: any) => p.id)).toEqual(['google', 'bing', 'ddg', 'sp1']); + expectBoundCall(prisma, 'tenant-1', 'searchProvider', 'findMany'); + }); + + it('Suchmaschine anlegen: gebunden, mit der übergebenen Mandantenkennung, die Mandantenkennung bleibt Pflichtangabe — wird rot, sobald ein Schreibweg ohne Mandantenkennung eingeführt wird', async () => { + const prisma = makeFakePrisma({}); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + const created = await service.addSearchProvider('user-1', 'tenant-1', { + name: 'Intranet', + urlTemplate: 'https://intranet.test/?q={query}', + } as any); + + expect(created.tenantId).toBe('tenant-1'); + expectBoundCall(prisma, 'tenant-1', 'searchProvider', 'create'); + expect(prisma.searchProvider.create).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ tenantId: 'tenant-1' }) }), + ); + }); + + it('Suchmaschine entfernen: BEIDE Abfragen über DENSELBEN gebundenen Klienten', async () => { + const own = makeSearchProvider({ id: 'sp1', userId: 'user-1', tenantId: 'tenant-1' }); + const prisma = makeFakePrisma({ searchProviders: [own] }); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + await service.removeSearchProvider('sp1', 'user-1', 'tenant-1'); + + expectBoundCall(prisma, 'tenant-1', 'searchProvider', 'findUnique'); + expectBoundCall(prisma, 'tenant-1', 'searchProvider', 'delete'); + }); + + it('Besitzprüfung bleibt wirksam: die Suchmaschine eines anderen Benutzers führt weiterhin zu NotFoundException', async () => { + const foreign = makeSearchProvider({ id: 'sp1', userId: 'other-user' }); + const prisma = makeFakePrisma({ searchProviders: [foreign] }); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + await expect(service.removeSearchProvider('sp1', 'user-1', 'tenant-1')).rejects.toThrow( + "Search provider with id 'sp1' not found", + ); + }); + + it('eine der drei Vorgabe-Suchmaschinen lässt sich weiterhin nicht entfernen (userId null fällt in den Nicht-gefunden-Zweig)', async () => { + const prisma = makeFakePrisma({}); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + await expect(service.removeSearchProvider('google', 'user-1', 'tenant-1')).rejects.toThrow( + "Search provider with id 'google' not found", + ); + }); + + it('Wachhund: der Modulkatalog taucht im Bindungsprotokoll nie auf, auch nicht nach der Suchmaschinen-Bindung — und der Katalogpfad wird tatsächlich durchlaufen, nicht nur theoretisch geprüft', async () => { + mockMap['tender-radar'] = 'tender-radar'; + const boundWidget = makeWidget({ id: 'w1', widgetType: 'tender-radar' }); + const own = makeSearchProvider({ id: 'sp1', userId: 'user-1', tenantId: 'tenant-1' }); + const prisma = makeFakePrisma({ + searchProviders: [own], + widgets: [boundWidget], + modules: [{ id: 'mod-1', slug: 'tender-radar' }], + }); + const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1'])); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + await service.getSearchProviders('user-1', 'tenant-1'); + await service.addSearchProvider('user-1', 'tenant-1', { + name: 'Intranet', + urlTemplate: 'https://intranet.test/?q={query}', + } as any); + await service.getWidgets('user-1', 'tenant-1', 'USER' as any); + + // Beweist, dass der Katalogzugriff tatsaechlich lief (sonst waere die + // Wachhund-Pruefung unten wirkungslos, weil sie nichts protokollieren + // koennte). + expect(prisma.module.findMany).toHaveBeenCalled(); + expectNeverBound(prisma, 'module'); + }); + + it('keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf — auch fuer die drei Suchmaschinen-Methoden', async () => { + const own = makeSearchProvider({ id: 'sp1', userId: 'user-1', tenantId: 'tenant-1' }); + const prisma = makeFakePrisma({ searchProviders: [own] }); + const moduleAccessService = makeFakeModuleAccessService(new Set()); + const service = new DashboardService(prisma as any, moduleAccessService as any); + + for (const call of [ + () => service.getSearchProviders('user-1', 'tenant-1'), + () => + service.addSearchProvider('user-1', 'tenant-1', { + name: 'x', + urlTemplate: 'https://x.test/?q={query}', + } as any), + () => service.removeSearchProvider('sp1', 'user-1', 'tenant-1'), + ]) { + vi.mocked(forTenant).mockClear(); + await call().catch(() => undefined); + expect( + vi.mocked(forTenant).mock.calls.length, + `Aufruf erzeugte ${vi.mocked(forTenant).mock.calls.length} gebundene Klienten, erwartet genau 1`, + ).toBe(1); + } + }); +}); diff --git a/apps/api/src/dashboard/dashboard.service.ts b/apps/api/src/dashboard/dashboard.service.ts index e0c7864..97e088e 100644 --- a/apps/api/src/dashboard/dashboard.service.ts +++ b/apps/api/src/dashboard/dashboard.service.ts @@ -274,9 +274,13 @@ export class DashboardService { /** * 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) { - const custom = await this.prisma.searchProvider.findMany({ + async getSearchProviders(userId: string, tenantId: string) { + const tenantPrisma = forTenant(this.prisma, tenantId); + const custom = await tenantPrisma.searchProvider.findMany({ where: { userId }, orderBy: { createdAt: 'asc' }, }); @@ -285,14 +289,19 @@ export class DashboardService { } /** - * Creates a user-custom search provider. + * 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, ) { - return this.prisma.searchProvider.create({ + const tenantPrisma = forTenant(this.prisma, tenantId); + return tenantPrisma.searchProvider.create({ data: { userId, tenantId, @@ -305,11 +314,14 @@ export class DashboardService { /** * Removes a user-custom search provider. - * Verifies ownership — default providers (userId null) cannot be deleted (T-05-07). + * 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) { + async removeSearchProvider(id: string, userId: string, tenantId: string) { + const tenantPrisma = forTenant(this.prisma, tenantId); // Default providers have hardcoded IDs that won't exist in DB - const provider = await this.prisma.searchProvider.findUnique({ + const provider = await tenantPrisma.searchProvider.findUnique({ where: { id }, }); @@ -319,8 +331,24 @@ export class DashboardService { ); } - return this.prisma.searchProvider.delete({ + 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. diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index a8b47da..38b524f 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -122,13 +122,13 @@ autoritative Quelle. | dkv | 1 | 22 | **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 ist der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21) — bewusst, mit dreifacher Markierung | | user | 8 | 14 | **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 | **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 | 13 | 0 | unverändert | +| dashboard | 1 | 12 | **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 | 8 | 5 | unverändert gegenüber dem in Etappe 1 (260909-eor) gemessenen Stand | | calendar | 12 | 0 | unverändert | | tenant | 8 | 0 | unverändert | | favorites | 7 | 0 | unverändert | | settings | 4 | 0 | unverändert | -| **Summe** | **107** | **135** | Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), jetzt 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), jetzt 135 nach 260910-jab (zusätzlich 1 in `tenders`). 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** | **95** | **147** | 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), jetzt 95 nach 260910-krx (`dashboard` 13→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`), jetzt 147 nach 260910-krx (zusätzlich 12 in `dashboard`). 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, 63 Paare) @@ -166,6 +166,18 @@ Erstanlage des Administrators bereits bekannt, nicht strukturell fehlend). Alle drei Zahlen sind der Ausgabe von `rls-access-inventory.spec.ts` entnommen, nicht geschaetzt. +**Stand 260910-krx (Aufgabe 3): unveraendert, ausdruecklich festgehalten +statt uebersprungen.** Weiterhin 63 Paare, keine Klasse verschoben sich. +Die vier Paare des Bereichs `dashboard` +(`dashboard.service.ts`/`dashboardLayout`, `/module`, `/searchProvider`, +`/widgetInstance`) waren bereits vor diesem Durchlauf korrekt klassifiziert +(drei `muss-mandantengebunden`, eines `keine-mandantengebundene-tabelle`) — +dieser Plan aendert nur ihre `Stand`-Spalte (`ungebunden` auf `gebunden` +fuer drei der vier Paare), keine ihrer Klassen. Eine unveraenderte Tabelle +ohne diesen Vermerk waere von einer vergessenen Nachziehung nicht zu +unterscheiden — deshalb steht die Abwesenheit einer Aenderung hier +ausdruecklich, statt stillschweigend uebersprungen zu werden. + | Klasse | Anzahl Paare | |---|---| | muss-mandantengebunden | 31 | @@ -281,6 +293,17 @@ der fünf oben geführten Fälle (übergreifend LESEN über alle Mandanten, dann je Mandant BINDEN) — sie ist deshalb kein Kandidat für diese Liste. Dieser Satz hält die Abwesenheit fest, damit sie nicht wie ein Übersehen aussieht. +**Stand 260910-krx — auch der Bereich `dashboard` fügt diesem Abschnitt +keinen sechsten Fall hinzu, gemessen statt angenommen.** Anweisung (260910-krx, +Aufgabe 1): `grep -rn "onModuleInit\|onApplicationBootstrap\|@Cron\|setInterval\|Scheduler" apps/api/src/dashboard --include=*.ts` +liefert null Treffer außerhalb von Testdateien — der einzige Dienst dieses +Bereichs, `dashboard.service.ts`, enthält keinen Hintergrunddienst, keinen +Planer und keinen Start-Hook. Jede seiner neun mandantengebundenen Methoden +wird ausschließlich synchron aus einer Anfrage eines einzelnen, bereits +bekannten Nutzers heraus aufgerufen — die Bauform dieses Abschnitts +(übergreifend LESEN über alle Mandanten, dann je Mandant BINDEN) kommt in +diesem Bereich an keiner Stelle vor. + ## Bestandsaufnahme Maschinell ermittelt, `rls-access-inventory.spec.ts` hält Vollständigkeit @@ -294,10 +317,10 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit | apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. | | apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gemischt | `getMe`, `changePassword`, `adminResetPassword` suchen über die Benutzerkennung aus dem Sitzungsnachweis — der Mandant ist dort bereits bekannt (Aufgabe 2 fasst sie bewusst nicht an, siehe SUMMARY). | | apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | ungebunden | Kalenderquellen eines Nutzers, `tenantId`-Spalte vorhanden. | -| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt auf der plattformweit eindeutigen `userId`) in eine deutsche Konfliktmeldung. Vollstaendige Nachziehung (Uebersichtszeile, Summenzeile, Klassen-Verteilung) folgt in Aufgabe 3 mit dem Rest des Bereichs. | -| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). | -| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | ungebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell: lokal gemessen null Zeilen mit `tenantId = NULL` insgesamt, dieser Schreibweg verlangt die Mandantenkennung als Pflichtparameter; Vorgabe-Anbieter kommen laut 05-02 aus Konstanten, nicht aus der DB. Die Regel auf `SearchProvider` bleibt deshalb unverändert streng. | -| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). Vollstaendige Nachziehung folgt in Aufgabe 3. | +| apps/api/src/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). | +| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). | | apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | gebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-mir (Aufgabe 3) laufen beide Historien-Schreibzugriffe der Verarbeitungsstrecke, beide parallelen Lesezugriffe von `getHistory` und der neue Riegel vor dem Ausfuhrdatei-Download vollstaendig ueber `forTenant()`. | | apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | gemischt | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnyActiveConfigForScheduler()` (WINDOWS #21) — keine uebersehene Fundstelle. | | apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | gebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 3) laufen Fahrzeugliste, Anlegen, beide Paare aus Besitzpruefung und Schreibzugriff (Aendern/Loeschen), beide Zweige des CSV-Imports und der gebuendelte Lesezugriff beim Aufbau der Ausfuhrzeilen vollstaendig ueber `forTenant()`; die vorgeschalteten Besitzpruefungen bei Aendern/Loeschen bleiben zusaetzlich bestehen (Befund G — ein gebundenes UPDATE ueber die Kennung allein trifft eine fremde Zeile still, nicht laut). | @@ -366,8 +389,11 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit hat sich für denselben dienst-internen Weg entschieden — jede Methode in `groups.service.ts` und `module-grants.service.ts` erzeugt ihren eigenen `forTenant()`- bzw. `withTenantTransaction()`-Aufruf, gebundene Clients - werden nicht zwischen Methoden weitergereicht. Die Frage bleibt für alle - übrigen Bereiche der Etappe 2 offen. + werden nicht zwischen Methoden weitergereicht. Der Bereich `dashboard` + (260910-krx) hat sich für denselben dienst-internen Weg entschieden — jede + der neun umgestellten Methoden in `dashboard.service.ts` erzeugt ihren + eigenen `forTenant()`-Aufruf, wie alle sieben Bereiche vor ihm. Die Frage + bleibt für alle übrigen Bereiche der Etappe 2 offen. - ~~Wie die WINDOWS-#19-Policy für `SearchProvider`/`TenderRssFeedSource` am Ende genau lautet — nur, dass sie vor dem Scharfschalten gelöst sein muss.~~ Aufgelöst (260910-jab): `TenderRssFeedSource` bekommt vier nach