From 3df72687c1a492b86e5ac97ec28145d355780372 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 10 Sep 2026 11:22:32 +0200 Subject: [PATCH] feat(quick-260910-exd): ModuleAccessService an forTenant() binden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module-access.service.ts: getAccessibleModuleIds (Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge) und getCatalogFlags' eigener Aktivierungs-Lesezugriff laufen ueber forTenant(), EIN Klient je Methode unter dem Namen tenantPrisma; der Katalogzugriff in findAccessibleModules bleibt bewusst ungebunden (Modulkatalog traegt keine Regel), mit Kommentar der Messung und Bedingung trennt - Bestehende where-Filter mit tenantId bleiben als zweites Netz stehen - module-access.service.spec.ts: Zwei-Klienten-Nachweis ueber __makeBoundClient (Muster aus module-grants.service.spec.ts), alle 15 bestehenden Faelle erhalten, neue Faelle fuer jede in genannte Bindungseigenschaft inkl. Wachhund gegen eine kuenftige Katalogbindung - module.guard.spec.ts: ein Fall, der die Abwesenheit eines unterscheidenden Signals fuer "keine Freigabe" vs. "Abfrage fand nichts" festnagelt - Falsifizierungsnachweis durchgefuehrt: Gruppenweg-Bindung probeweise zurueckgebaut, Test "USER-Zweig bindet BEIDE Freigabe-Lesezugriffe..." wurde rot ("expected 1 to be 2"), Ruecknahme bestaetigt wieder gruen - mandantentrennung-zugriffsklassifikation.md: Stand fuer module-access.service.ts/moduleGrant und /tenantModuleActivation auf gebunden nachgezogen (Rule 3 — sonst waere rls-access-inventory.spec.ts rot geblieben); die uebrigen vier Bestandsaufnahme-Stellen bleiben Aufgabe 3 vorbehalten - 817 Tests gruen (55 Dateien), Typpruefung sauber, Wegwerf-Werkzeug 66/66 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR --- .../module-access.service.spec.ts | 180 ++++++++++++++++-- .../module-registry/module-access.service.ts | 40 +++- .../src/module-registry/module.guard.spec.ts | 66 +++++++ ...andantentrennung-zugriffsklassifikation.md | 4 +- 4 files changed, 269 insertions(+), 21 deletions(-) diff --git a/apps/api/src/module-registry/module-access.service.spec.ts b/apps/api/src/module-registry/module-access.service.spec.ts index 7853732..d88c0d7 100644 --- a/apps/api/src/module-registry/module-access.service.spec.ts +++ b/apps/api/src/module-registry/module-access.service.spec.ts @@ -6,9 +6,27 @@ import { ModuleAccessService } from './module-access.service'; * Modulzugriff (D-01, PERM-04/05/06). Deckt die vollständige Behavior- * Liste aus 15-01-PLAN.md, Task 2 ab. * - * Hand-gerollter Prisma-Mock (Projektkonvention, siehe - * tender-matching.service.spec.ts) statt einer echten DB-Verbindung. + * Bindung an forTenant() (260910-exd, Aufgabe 2, Befund C uebertragen von + * `module-grants.service.spec.ts`, dem bereits umgestellten Nachbarn auf + * denselben Modellen `tenantModuleActivation`/`moduleGrant`): der gebundene + * Klient ist ein ZWEITES, von `prisma` unterscheidbares Objekt ueber + * DEMSELBEN Speicher, das protokolliert, welche Aufrufe ueber ihn liefen + * (Modellname, Methodenname, Mandantenkennung). Ein reiner Identitaets-Mock + * (`forTenant: vi.fn((p) => p)`) koennte einen vergessenen Bindungsaufruf + * nicht von einem ungebundenen Aufruf unterscheiden. + * + * `module` wird NICHT gewrappt — der Katalogzugriff laeuft bewusst ueber + * den ungebundenen Klienten (Befund E, Aufgabe 1): die Tabelle traegt heute + * keinen Zeilenschutz, eine Bindung waere heute wirkungslos. */ +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), +})); + +/** 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 = ['tenantModuleActivation', 'moduleGrant']; function makeFakePrisma(opts: { activations?: { moduleId: string }[]; @@ -18,8 +36,9 @@ function makeFakePrisma(opts: { const activations = opts.activations ?? []; const directGrants = opts.directGrants ?? []; const groupGrants = opts.groupGrants ?? []; + const boundCallLog: { tenantId: string; model: string; method: string }[] = []; - const prisma = { + const fake: any = { tenantModuleActivation: { findMany: vi.fn(async ({ where }: any) => { // filtert die simulierten Aktivierungen zusätzlich auf moduleId, @@ -43,9 +62,55 @@ function makeFakePrisma(opts: { return ids.map((id) => ({ id, name: id })); }), }, + // --- Bindungsnachweis (260910-exd, Befund C uebertragen) --------------- + __boundCallLog: boundCallLog, + __makeBoundClient(tenantId: string) { + const bound: any = { __isBoundClient: true, __tenantId: tenantId }; + for (const modelName of BOUND_MODEL_NAMES) { + const model = fake[modelName]; + const wrapped: any = {}; + for (const method of Object.keys(model)) { + wrapped[method] = async (...args: any[]) => { + boundCallLog.push({ tenantId, model: modelName, method }); + return model[method](...args); + }; + } + bound[modelName] = wrapped; + } + return bound; + }, }; - return prisma; + return fake; +} + +/** + * Bindungsnachweis: mindestens ein Aufruf von `..` + * lief ueber den gebundenen Client (nicht ueber den rohen, ungebundenen + * Fake). Ein vergessener `forTenant()`-Aufruf hinterlaesst hier KEINEN + * Eintrag und laesst den Test fehlschlagen. + */ +function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) { + const found = prisma.__boundCallLog.some( + (c: any) => c.tenantId === tenantId && c.model === model && c.method === method, + ); + expect( + found, + `erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`, + ).toBe(true); +} + +/** + * Wachhund-Gegenprobe (Aufgabe 2): ein Modell darf im Bindungsprotokoll gar + * nicht vorkommen — das ist der Testfall, der jemanden erwischt, der den + * bewusst ungebundenen Katalogzugriff spaeter versehentlich bindet. + */ +function expectNeverBound(prisma: any, model: string) { + const found = prisma.__boundCallLog.some((c: any) => c.model === model); + expect( + found, + `Modell "${model}" darf nie im Bindungsprotokoll auftauchen (Katalog bleibt ungebunden): ${JSON.stringify(prisma.__boundCallLog)}`, + ).toBe(false); } describe('ModuleAccessService.getAccessibleModuleIds — ADMIN/SUPER_ADMIN-Kurzschluss (D-03)', () => { @@ -88,13 +153,8 @@ describe('ModuleAccessService.getAccessibleModuleIds — ADMIN/SUPER_ADMIN-Kurzs t1: [{ moduleId: 'mod-tenant-1' }], t2: [{ moduleId: 'mod-tenant-2' }], }; - const prisma = { - tenantModuleActivation: { - findMany: vi.fn(async ({ where }: any) => activationsByTenant[where.tenantId] ?? []), - }, - moduleGrant: { findMany: vi.fn() }, - module: { findMany: vi.fn() }, - }; + const prisma = makeFakePrisma(); + prisma.tenantModuleActivation.findMany = vi.fn(async ({ where }: any) => activationsByTenant[where.tenantId] ?? []); const service = new ModuleAccessService(prisma as any); const resultT2 = await service.getAccessibleModuleIds('t2', 'admin-1', 'ADMIN'); @@ -260,3 +320,101 @@ describe('ModuleAccessService.getCatalogFlags — Marketplace-Katalog (D-08)', ( expect(flags.get('mod-1')).toEqual({ isActiveForTenant: true, hasAccess: true }); }); }); + +// --- Bindung an forTenant() (260910-exd, Aufgabe 2) ------------------------- + +describe('ModuleAccessService — Bindung an forTenant() (260910-exd)', () => { + it('Kurzschlusszweig (ADMIN) bindet seinen Aktivierungs-Lesezugriff an die Mandantenkennung aus dem Sitzungsnachweis', async () => { + const prisma = makeFakePrisma({ activations: [{ moduleId: 'mod-1' }] }); + const service = new ModuleAccessService(prisma as any); + + await service.getAccessibleModuleIds('t1', 'admin-1', 'ADMIN'); + + expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany'); + }); + + it('USER-Zweig bindet BEIDE Freigabe-Lesezugriffe (Direktweg und Gruppenweg) UND den Schnittmengen-Lesezugriff an DIESELBE Mandantenkennung', async () => { + const prisma = makeFakePrisma({ + activations: [{ moduleId: 'mod-1' }], + directGrants: [{ moduleId: 'mod-1' }], + groupGrants: [{ moduleId: 'mod-1' }], + }); + const service = new ModuleAccessService(prisma as any); + + await service.getAccessibleModuleIds('t1', 'user-1', 'USER'); + + const grantCalls = prisma.__boundCallLog.filter( + (c: any) => c.model === 'moduleGrant' && c.method === 'findMany', + ); + expect(grantCalls.length).toBe(2); + expect(grantCalls.every((c: any) => c.tenantId === 't1')).toBe(true); + expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany'); + }); + + it('Vorgabezustand bleibt geschlossen und ueberlebt die Bindung: ohne Grants leeres Set, der Schnittmengen-Lesezugriff wird gar nicht erst ausgefuehrt', async () => { + const prisma = makeFakePrisma({}); + const service = new ModuleAccessService(prisma as any); + + const result = await service.getAccessibleModuleIds('t1', 'user-1', 'USER'); + + expect(result).toEqual(new Set()); + const activationCalls = prisma.__boundCallLog.filter( + (c: any) => c.model === 'tenantModuleActivation' && c.method === 'findMany', + ); + expect( + activationCalls, + `der Schnittmengen-Lesezugriff auf tenantModuleActivation darf ohne Grants nicht stattfinden, gefunden: ${JSON.stringify(activationCalls)}`, + ).toEqual([]); + }); + + it('Rollen-Kurzschluss waechst durch die Bindung nicht: eine Aufloesung fuer einen zweiten Mandanten leitet keine Module des ersten ab, die gebundene Kennung im Protokoll ist die des zweiten', async () => { + const activationsByTenant: Record = { + t1: [{ moduleId: 'mod-tenant-1' }], + t2: [{ moduleId: 'mod-tenant-2' }], + }; + const prisma = makeFakePrisma(); + prisma.tenantModuleActivation.findMany = vi.fn(async ({ where }: any) => activationsByTenant[where.tenantId] ?? []); + const service = new ModuleAccessService(prisma as any); + + const resultT2 = await service.getAccessibleModuleIds('t2', 'admin-2', 'ADMIN'); + + expect(resultT2).toEqual(new Set(['mod-tenant-2'])); + expectBoundCall(prisma, 't2', 'tenantModuleActivation', 'findMany'); + const t1Calls = prisma.__boundCallLog.filter((c: any) => c.tenantId === 't1'); + expect(t1Calls, `keine Bindung an t1 erwartet: ${JSON.stringify(t1Calls)}`).toEqual([]); + }); + + it('findAccessibleModules erreicht den Katalog ueber den UNGEBUNDENEN Klienten — der Katalogzugriff taucht im Bindungsprotokoll nicht auf', async () => { + const prisma = makeFakePrisma({ + activations: [{ moduleId: 'mod-1' }], + directGrants: [{ moduleId: 'mod-1' }], + }); + const service = new ModuleAccessService(prisma as any); + + await service.findAccessibleModules('t1', 'user-1', 'USER'); + + expectNeverBound(prisma, 'module'); + expect(prisma.module.findMany).toHaveBeenCalled(); + }); + + it('getCatalogFlags bindet seinen eigenen Aktivierungs-Lesezugriff, und die geschachtelte Aufloesung erzeugt ihren eigenen gebundenen Klienten mit derselben Mandantenkennung', async () => { + const prisma = makeFakePrisma({ + activations: [{ moduleId: 'mod-1' }], + directGrants: [{ moduleId: 'mod-1' }], + }); + const service = new ModuleAccessService(prisma as any); + + await service.getCatalogFlags('t1', 'user-1', 'USER'); + + const activationCalls = prisma.__boundCallLog.filter( + (c: any) => c.model === 'tenantModuleActivation' && c.method === 'findMany', + ); + // Ein Aufruf fuer getCatalogFlags selbst, ein zweiter aus der + // geschachtelten getAccessibleModuleIds-Aufloesung — beide gebunden an + // denselben Mandanten, aber ueber je einen eigenen forTenant()-Aufruf + // (dieselbe Konvention wie module-grants.service.ts). + expect(activationCalls.length).toBe(2); + expect(activationCalls.every((c: any) => c.tenantId === 't1')).toBe(true); + expectNeverBound(prisma, 'module'); + }); +}); diff --git a/apps/api/src/module-registry/module-access.service.ts b/apps/api/src/module-registry/module-access.service.ts index 84898cf..a6e7ee2 100644 --- a/apps/api/src/module-registry/module-access.service.ts +++ b/apps/api/src/module-registry/module-access.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { Role } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; /** * Single Source of Truth für Modulzugriff (D-01, PERM-04/05/06). @@ -39,31 +40,41 @@ export class ModuleAccessService { userId: string, role: Role, ): Promise> { + // EIN gebundener Klient fuer alle vier mandantengebundenen Zugriffe + // dieser Methode (Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge) + // — nicht ein Klient je Modellzugriff (260910-exd, Aufgabe 2). Die + // bestehenden `where`-Filter mit tenantId bleiben ZUSAETZLICH stehen: + // sie sind das zweite Netz, nicht redundant — dieselbe Begruendung wie + // in `module-grants.service.ts` (die Regel auf `GroupMembership` prueft + // nachweislich nur die Gruppenseite, T-JTS-02, und die Regel auf + // `ModuleGrant` nur die Mandantenkennung der Zeile, T-JTS-03). + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + if (role === 'ADMIN' || role === 'SUPER_ADMIN') { - const activations = await this.prisma.tenantModuleActivation.findMany({ + const activations = await tenantPrisma.tenantModuleActivation.findMany({ where: { tenantId, isActive: true }, select: { moduleId: true }, }); - return new Set(activations.map((a) => a.moduleId)); + return new Set(activations.map((a: { moduleId: string }) => a.moduleId)); } const [direct, viaGroup] = await Promise.all([ - this.prisma.moduleGrant.findMany({ + tenantPrisma.moduleGrant.findMany({ where: { tenantId, userId }, select: { moduleId: true }, }), - this.prisma.moduleGrant.findMany({ + tenantPrisma.moduleGrant.findMany({ where: { tenantId, group: { memberships: { some: { userId } } } }, select: { moduleId: true }, }), ]); - const grantedIds = [...direct, ...viaGroup].map((g) => g.moduleId); + const grantedIds = [...direct, ...viaGroup].map((g: { moduleId: string }) => g.moduleId); if (grantedIds.length === 0) { return new Set(); } - const activations = await this.prisma.tenantModuleActivation.findMany({ + const activations = await tenantPrisma.tenantModuleActivation.findMany({ where: { tenantId, isActive: true, @@ -71,7 +82,7 @@ export class ModuleAccessService { }, select: { moduleId: true }, }); - return new Set(activations.map((a) => a.moduleId)); + return new Set(activations.map((a: { moduleId: string }) => a.moduleId)); } /** @@ -87,6 +98,13 @@ export class ModuleAccessService { return []; } + // Der Katalogzugriff laeuft bewusst ueber den ungebundenen Klienten + // (260910-exd, Aufgabe 1, Befund E): die Tabelle "Module" traegt heute + // keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht + // katastrophal. Katastrophal wuerde sie erst, WENN Etappe 3 dieser + // Tabelle eine Regel gibt — dann verschwaende der gesamte Katalog fuer + // jeden Mandanten. Diese Bedingung steht hier als Bedingung, nicht als + // heute beobachtbare Tatsache. return this.prisma.module.findMany({ where: { id: { in: [...accessibleIds] } }, orderBy: { name: 'asc' }, @@ -113,8 +131,14 @@ export class ModuleAccessService { userId: string, role: Role, ): Promise> { + // Eigener gebundener Klient fuer den Aktivierungs-Lesezugriff dieser + // Methode — die geschachtelte getAccessibleModuleIds()-Aufloesung + // erzeugt ihren EIGENEN Klienten (dieselbe Konvention wie + // `module-grants.service.ts`: gebundene Klienten werden nicht zwischen + // Methoden weitergereicht). Beide laufen wie bisher nebenlaeufig. + const tenantPrisma = forTenant(this.prisma, tenantId) as any; const [activations, accessibleIds] = await Promise.all([ - this.prisma.tenantModuleActivation.findMany({ + tenantPrisma.tenantModuleActivation.findMany({ where: { tenantId, isActive: true }, select: { moduleId: true }, }), diff --git a/apps/api/src/module-registry/module.guard.spec.ts b/apps/api/src/module-registry/module.guard.spec.ts index 84d836a..54f8e13 100644 --- a/apps/api/src/module-registry/module.guard.spec.ts +++ b/apps/api/src/module-registry/module.guard.spec.ts @@ -116,4 +116,70 @@ describe('ModuleGuard.canActivate', () => { expect(result).toBe(true); expect((request as any).moduleAccessIds).toBe(accessibleIds); }); + + /** + * 260910-exd, Aufgabe 2 — nagelt die ABWESENHEIT eines unterscheidenden + * Signals fest: es gibt heute KEIN Signal, das "wirklich keine Freigabe" + * (USER hat tatsächlich keinen Grant) von "die Auflösung hat nichts + * gefunden" (z. B. eine nach dem Scharfschalten ungebunden gebliebene + * Abfrage) unterscheidet. Beide Aufrufe liefern eine leere Menge und + * damit DIESELBE ForbiddenException mit DERSELBEN Meldung — dieser Test + * hält die beiden Meldungen gegeneinander. + * + * DIESER TEST SOLL ROT WERDEN, sobald jemand ein unterscheidendes Signal + * einbaut (z. B. ein eigener Fehlercode oder eine unterschiedliche + * Meldung für "leer, weil keine Freigabe" vs. "leer, weil die Abfrage + * nichts gefunden hat"). Wird er rot, ist das kein Bug in diesem Test, + * sondern der Beleg, dass die Lücke geschlossen wurde — dann sind sowohl + * dieser Test als auch Abschnitt (m3) von + * docs/mandantentrennung-etappe2-fehlerrichtung.md nachzuziehen. + */ + it('liefert fuer "wirklich keine Freigabe" und fuer "die Aufloesung hat nichts gefunden" dieselbe Ausnahme mit derselben Meldung (kein unterscheidendes Signal)', async () => { + const moduleRegistryService = { + findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }), + } as any; + + // Fall A: der Benutzer hat tatsächlich keine Freigabe. + const moduleAccessServiceGenuinelyEmpty = { + getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()), + } as any; + const guardA = new ModuleGuard( + makeReflector('domaincheck'), + moduleRegistryService, + moduleAccessServiceGenuinelyEmpty, + ); + + // Fall B: es gibt Freigaben, aber die Auflösung liefert (z. B. wegen + // einer ungebunden gebliebenen Abfrage) trotzdem eine leere Menge — + // aus Sicht des Wächters nicht von Fall A zu unterscheiden. + const moduleAccessServiceQueryFoundNothing = { + getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()), + } as any; + const guardB = new ModuleGuard( + makeReflector('domaincheck'), + moduleRegistryService, + moduleAccessServiceQueryFoundNothing, + ); + + const request = { tenantId: 't1', user: { id: 'user-1', role: 'USER' } }; + + let messageA = ''; + let messageB = ''; + try { + await guardA.canActivate(makeContext(request)); + } catch (err) { + messageA = (err as ForbiddenException).message; + } + try { + await guardB.canActivate(makeContext(request)); + } catch (err) { + messageB = (err as ForbiddenException).message; + } + + expect(messageA).not.toBe(''); + expect( + messageB, + 'heute gibt es kein Signal, das diese beiden Faelle unterscheidet — beide Meldungen muessen identisch sein', + ).toBe(messageA); + }); }); diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 94ad679..48796cc 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -289,8 +289,8 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit | apps/api/src/ldap/ldap.service.ts | ldapConfig | beides | gebunden | Die `lastSyncAt`-Fortschreibung am Ende von `syncUsersForTenant` ist mit Aufgabe 3 (260909-ipc) auf den in derselben Methode bereits vorhandenen `forTenant()`-Client umgestellt — es entsteht kein zweiter. Damit ist der einzige `ldapConfig`-Zugriff dieser Datei gebunden. | | apps/api/src/ldap/ldap.service.ts | user | beides | gemischt | Mit Aufgabe 3 (260909-ipc) sind `upsertMappedUser` (Identitaetssuche und Aktualisierung), `searchUsers` (die "bereits importiert"-Markierung), `importUsersByDn` (Dedup und ldapDn-Nachtrag) und die Deaktivierungsschleife in `syncUsersForTenant` auf `forTenant()` umgestellt. `resolveEmailForWrite` bleibt ausdruecklich UNGEBUNDEN (Befund A, T-IPC-04): `email`/`username` sind plattformweit eindeutig, eine mandantengebundene Suche saehe einen fremden Halter nicht mehr und meldete faelschlich "frei" — die geloeste Klasse waere `muss-mandantengebunden` gewesen, bleibt wegen dieser einen bewusst uebergreifenden Abfrage `beides`. Der Loeschzweig um `syncBoundGroupsForTenant` (WINDOWS #20) ist bereits seit Etappe 1 gebunden und war nie Teil dieses Befunds. | | apps/api/src/module-registry/module-access.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId`. | -| apps/api/src/module-registry/module-access.service.ts | moduleGrant | muss-mandantengebunden | ungebunden | Modulfreigaben je Mandant. | -| apps/api/src/module-registry/module-access.service.ts | tenantModuleActivation | muss-mandantengebunden | ungebunden | Aktivierung je Mandant, `tenantId`-Spalte vorhanden. | +| apps/api/src/module-registry/module-access.service.ts | moduleGrant | muss-mandantengebunden | gebunden | Modulfreigaben je Mandant. Seit 260910-exd (Aufgabe 2) laufen Direktweg und Gruppenweg von `getAccessibleModuleIds` ueber `forTenant()`, EIN Klient je Methode. | +| apps/api/src/module-registry/module-access.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Aktivierung je Mandant, `tenantId`-Spalte vorhanden. Seit 260910-exd (Aufgabe 2) laufen Kurzschlusszweig, Schnittmengenabfrage und der eigene Lesezugriff von `getCatalogFlags` ueber `forTenant()`. | | apps/api/src/module-registry/module-registry.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit. | | apps/api/src/module-registry/module-registry.service.ts | tenantModuleActivation | muss-mandantengebunden | ungebunden | Aktivierung je Mandant. | | apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | ungebunden | SMTP-Zugangsdaten je Mandant, `tenantId`-Spalte vorhanden. |