diff --git a/apps/api/src/module-registry/module-access.service.spec.ts b/apps/api/src/module-registry/module-access.service.spec.ts new file mode 100644 index 0000000..1568705 --- /dev/null +++ b/apps/api/src/module-registry/module-access.service.spec.ts @@ -0,0 +1,217 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ModuleAccessService } from './module-access.service'; + +/** + * ModuleAccessService.getAccessibleModuleIds — Single Source of Truth für + * 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. + */ + +function makeFakePrisma(opts: { + activations?: { moduleId: string }[]; + directGrants?: { moduleId: string }[]; + groupGrants?: { moduleId: string }[]; +} = {}) { + const activations = opts.activations ?? []; + const directGrants = opts.directGrants ?? []; + const groupGrants = opts.groupGrants ?? []; + + const prisma = { + tenantModuleActivation: { + findMany: vi.fn(async ({ where }: any) => { + // filtert die simulierten Aktivierungen zusätzlich auf moduleId, + // falls die Query (D-02-Schnittmenge) einen moduleId-in-Filter trägt + const inFilter: string[] | undefined = where?.moduleId?.in; + if (!inFilter) return activations; + return activations.filter((a) => inFilter.includes(a.moduleId)); + }), + }, + moduleGrant: { + findMany: vi.fn(async ({ where }: any) => { + // Direkt-Grant-Query trägt `userId` direkt im where, die + // Gruppen-Grant-Query trägt `group: { memberships: { some: { userId } } } }` + if (where.group) return groupGrants; + return directGrants; + }), + }, + module: { + findMany: vi.fn(async ({ where }: any) => { + const ids: string[] = where.id.in; + return ids.map((id) => ({ id, name: id })); + }), + }, + }; + + return prisma; +} + +describe('ModuleAccessService.getAccessibleModuleIds — ADMIN/SUPER_ADMIN-Kurzschluss (D-03)', () => { + it('ADMIN erhält alle mandantenweit aktiven moduleIds ohne jede Grant-Query', async () => { + const prisma = makeFakePrisma({ + activations: [{ moduleId: 'mod-1' }, { moduleId: 'mod-2' }], + }); + const service = new ModuleAccessService(prisma as any); + + const result = await service.getAccessibleModuleIds('t1', 'admin-1', 'ADMIN'); + + expect(result).toEqual(new Set(['mod-1', 'mod-2'])); + expect(prisma.moduleGrant.findMany).not.toHaveBeenCalled(); + }); + + it('SUPER_ADMIN verhält sich identisch zu ADMIN', async () => { + const prisma = makeFakePrisma({ + activations: [{ moduleId: 'mod-1' }], + }); + const service = new ModuleAccessService(prisma as any); + + const result = await service.getAccessibleModuleIds('t1', 'sa-1', 'SUPER_ADMIN'); + + expect(result).toEqual(new Set(['mod-1'])); + expect(prisma.moduleGrant.findMany).not.toHaveBeenCalled(); + }); + + it('idempotency: wiederholte Aufrufe für denselben ADMIN liefern dasselbe Ergebnis, ohne Datensätze anzulegen', async () => { + const prisma = makeFakePrisma({ activations: [{ moduleId: 'mod-1' }] }); + const service = new ModuleAccessService(prisma as any); + + const first = await service.getAccessibleModuleIds('t1', 'admin-1', 'ADMIN'); + const second = await service.getAccessibleModuleIds('t1', 'admin-1', 'ADMIN'); + + expect(first).toEqual(second); + }); + + it('concurrency: der Bypass gilt nur für die tenantId aus dem JWT — ein Aufruf für t2 leitet keine Module von t1 ab', async () => { + const activationsByTenant: Record = { + 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 service = new ModuleAccessService(prisma as any); + + const resultT2 = await service.getAccessibleModuleIds('t2', 'admin-1', 'ADMIN'); + + expect(resultT2).toEqual(new Set(['mod-tenant-2'])); + }); +}); + +describe('ModuleAccessService.getAccessibleModuleIds — USER (Grant-Auflösung, D-02)', () => { + it('empty: USER ohne Grants erhält ein leeres Set', 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()); + }); + + it('USER mit Direkt-Grant auf ein aktives Modul: Set enthält genau diese moduleId', async () => { + const prisma = makeFakePrisma({ + activations: [{ moduleId: 'mod-1' }], + directGrants: [{ moduleId: 'mod-1' }], + }); + const service = new ModuleAccessService(prisma as any); + + const result = await service.getAccessibleModuleIds('t1', 'user-1', 'USER'); + + expect(result).toEqual(new Set(['mod-1'])); + }); + + it('USER mit Grant über eine Gruppe, in der er Mitglied ist: Set enthält diese moduleId', async () => { + const prisma = makeFakePrisma({ + activations: [{ moduleId: 'mod-1' }], + groupGrants: [{ moduleId: 'mod-1' }], + }); + const service = new ModuleAccessService(prisma as any); + + const result = await service.getAccessibleModuleIds('t1', 'user-1', 'USER'); + + expect(result).toEqual(new Set(['mod-1'])); + }); + + it('adjacency: USER mit Direkt-Grant UND Gruppen-Grant auf dasselbe Modul — die moduleId erscheint genau einmal', async () => { + const prisma = makeFakePrisma({ + activations: [{ moduleId: 'mod-1' }], + directGrants: [{ moduleId: 'mod-1' }], + groupGrants: [{ moduleId: 'mod-1' }], + }); + const service = new ModuleAccessService(prisma as any); + + const result = await service.getAccessibleModuleIds('t1', 'user-1', 'USER'); + + expect(result.size).toBe(1); + expect(result).toEqual(new Set(['mod-1'])); + }); + + it('adjacency/D-02: ein Grant auf ein mandantenweit deaktiviertes Modul gewährt KEINEN Zugriff', async () => { + const prisma = makeFakePrisma({ + activations: [], // mod-1 ist NICHT (mehr) aktiv + directGrants: [{ moduleId: 'mod-1' }], + }); + const service = new ModuleAccessService(prisma as any); + + const result = await service.getAccessibleModuleIds('t1', 'user-1', 'USER'); + + expect(result).toEqual(new Set()); + }); + + it('adjacency: eine mandantenweite Aktivierung ohne Grant ergibt ebenfalls keinen Zugriff', async () => { + const prisma = makeFakePrisma({ + activations: [{ moduleId: 'mod-1' }], + // kein Direkt- oder Gruppen-Grant + }); + const service = new ModuleAccessService(prisma as any); + + const result = await service.getAccessibleModuleIds('t1', 'user-1', 'USER'); + + expect(result).toEqual(new Set()); + }); + + it('idempotency: getAccessibleModuleIds ist rein lesend — zwei identische Aufrufe schreiben keinen Datensatz und liefern dasselbe Ergebnis', async () => { + const prisma = makeFakePrisma({ + activations: [{ moduleId: 'mod-1' }], + directGrants: [{ moduleId: 'mod-1' }], + }); + const service = new ModuleAccessService(prisma as any); + + const first = await service.getAccessibleModuleIds('t1', 'user-1', 'USER'); + const second = await service.getAccessibleModuleIds('t1', 'user-1', 'USER'); + + expect(first).toEqual(second); + expect(prisma.moduleGrant.findMany).toHaveBeenCalledTimes(4); // 2x (direct+group) je Aufruf + }); +}); + +describe('ModuleAccessService.findAccessibleModules — ordering (PERM-04)', () => { + it('sortiert die zugänglichen Module deterministisch nach Namen aufsteigend', 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'); + + expect(prisma.module.findMany).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: { name: 'asc' } }), + ); + }); + + it('empty: ein Benutzer ohne Zugriff erhält eine leere Liste, keinen Fehler', async () => { + const prisma = makeFakePrisma({}); + const service = new ModuleAccessService(prisma as any); + + const result = await service.findAccessibleModules('t1', 'user-1', 'USER'); + + expect(result).toEqual([]); + expect(prisma.module.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/module-registry/module-access.service.ts b/apps/api/src/module-registry/module-access.service.ts new file mode 100644 index 0000000..4e461dd --- /dev/null +++ b/apps/api/src/module-registry/module-access.service.ts @@ -0,0 +1,95 @@ +import { Injectable } from '@nestjs/common'; +import { Role } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; + +/** + * Single Source of Truth für Modulzugriff (D-01, PERM-04/05/06). + * + * `ModuleGuard` und `GET /modules/active` rufen beide ausschließlich + * `getAccessibleModuleIds` auf, damit Sidebar, Modulseiten und API + * niemals auseinanderdriften können — genau das Sicherheitsloch, das + * D-01 strukturell verhindert. + */ +@Injectable() +export class ModuleAccessService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Berechnet die Menge der moduleIds, auf die dieser Benutzer Zugriff hat. + * + * D-03: ADMIN/SUPER_ADMIN umgehen jede Grant-Prüfung — sie erhalten alle + * mandantenweit aktiven Module ihres eigenen Mandanten. Diese Rolle + * kommt ausschließlich aus dem JWT (Aufrufer), nie aus Body/Params — + * der Kurzschluss kann daher keine Module eines fremden Mandanten + * liefern, weil `tenantId` ebenfalls aus dem JWT stammt (T-15-10). + * + * Für alle anderen Rollen (USER): Vereinigungsmenge aus Direkt-Grants + * und Grants über Gruppenmitgliedschaften, geschnitten mit den + * mandantenweit aktiven Modulen (D-02 — ein Grant auf ein deaktiviertes + * Modul gewährt keinen Zugriff). Die Gruppen-Query ist eine einzige + * verschachtelte Prisma-Query (`group: { memberships: { some: { userId } } }`) + * statt einer Schleife über die Gruppen des Benutzers — sonst entsteht + * ein N+1 pro geschütztem Endpoint. + * + * Rein lesend, kein Caching über Request-Grenzen hinweg (D-09) — ein + * Freigabe-Entzug wirkt bei der nächsten Anfrage. + */ + async getAccessibleModuleIds( + tenantId: string, + userId: string, + role: Role, + ): Promise> { + if (role === 'ADMIN' || role === 'SUPER_ADMIN') { + const activations = await this.prisma.tenantModuleActivation.findMany({ + where: { tenantId, isActive: true }, + select: { moduleId: true }, + }); + return new Set(activations.map((a) => a.moduleId)); + } + + const [direct, viaGroup] = await Promise.all([ + this.prisma.moduleGrant.findMany({ + where: { tenantId, userId }, + select: { moduleId: true }, + }), + this.prisma.moduleGrant.findMany({ + where: { tenantId, group: { memberships: { some: { userId } } } }, + select: { moduleId: true }, + }), + ]); + const grantedIds = [...direct, ...viaGroup].map((g) => g.moduleId); + + if (grantedIds.length === 0) { + return new Set(); + } + + const activations = await this.prisma.tenantModuleActivation.findMany({ + where: { + tenantId, + isActive: true, + moduleId: { in: grantedIds }, + }, + select: { moduleId: true }, + }); + return new Set(activations.map((a) => a.moduleId)); + } + + /** + * Lädt die vollständigen Module-Datensätze, auf die dieser Benutzer + * Zugriff hat, sortiert nach Name. Bedient `GET /modules/active` — die + * explizite Sortierung hält die Sidebar-Reihenfolge über Aufrufe hinweg + * stabil. + */ + async findAccessibleModules(tenantId: string, userId: string, role: Role) { + const accessibleIds = await this.getAccessibleModuleIds(tenantId, userId, role); + + if (accessibleIds.size === 0) { + return []; + } + + return this.prisma.module.findMany({ + where: { id: { in: [...accessibleIds] } }, + orderBy: { name: 'asc' }, + }); + } +} diff --git a/apps/api/src/module-registry/module-registry.controller.ts b/apps/api/src/module-registry/module-registry.controller.ts index 87240f6..40f3cd0 100644 --- a/apps/api/src/module-registry/module-registry.controller.ts +++ b/apps/api/src/module-registry/module-registry.controller.ts @@ -11,13 +11,16 @@ import { Role } from '@prisma/client'; import { Request } from 'express'; import { Roles } from '../auth/decorators/roles.decorator'; import { RolesGuard } from '../auth/guards/roles.guard'; +import { ModuleAccessService } from './module-access.service'; import { ModuleRegistryService } from './module-registry.service'; /** * REST controller for the module registry. * * - GET /modules — list all registered modules (any authenticated user) - * - GET /modules/active — list active modules for current tenant + * - GET /modules/active — list modules accessible to the requesting user + * (Aktivierung UND Grant/Rolle, via ModuleAccessService — D-01: dieselbe + * Auflösung wie ModuleGuard) * - POST /modules/:moduleId/activate — activate a module (ADMIN/SUPER_ADMIN) * - POST /modules/:moduleId/deactivate — deactivate a module (ADMIN/SUPER_ADMIN) * @@ -28,6 +31,7 @@ import { ModuleRegistryService } from './module-registry.service'; export class ModuleRegistryController { constructor( private readonly moduleRegistryService: ModuleRegistryService, + private readonly moduleAccessService: ModuleAccessService, ) {} /** @@ -42,15 +46,21 @@ export class ModuleRegistryController { /** * GET /modules/active - * Returns modules that are active for the requesting tenant. + * Returns modules accessible to the requesting user (D-01): the same + * ModuleAccessService.getAccessibleModuleIds resolution ModuleGuard + * uses, not just tenant-wide activation. findActiveForTenant on + * ModuleRegistryService stays unchanged for Plan 15-03's marketplace + * catalog (mandantenweite Sicht, kein Benutzerfilter). */ @Get('active') async findActive(@Req() req: Request) { const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId; - if (!tenantId) { - throw new ForbiddenException('No tenant context'); + const userId = (req as any).user?.id; + const role = (req as any).user?.role; + if (!tenantId || !userId || !role) { + throw new ForbiddenException('No user context'); } - return this.moduleRegistryService.findActiveForTenant(tenantId); + return this.moduleAccessService.findAccessibleModules(tenantId, userId, role); } /** diff --git a/apps/api/src/module-registry/module-registry.module.ts b/apps/api/src/module-registry/module-registry.module.ts index cb9a099..e2c38b7 100644 --- a/apps/api/src/module-registry/module-registry.module.ts +++ b/apps/api/src/module-registry/module-registry.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { ModuleAccessService } from './module-access.service'; import { ModuleRegistryController } from './module-registry.controller'; import { ModuleRegistryService } from './module-registry.service'; import { ModuleGuard } from './module.guard'; @@ -8,15 +9,18 @@ import { ModuleGuard } from './module.guard'; * * Provides: * - ModuleRegistryService: CRUD for module records + per-tenant activation + * - ModuleAccessService: Single Source of Truth for user-level module + * access (D-01) — Aktivierung UND (Rolle ODER Grant) * - ModuleGuard: CanActivate guard for module-specific endpoints * - ModuleRegistryController: REST API for listing, activating, and deactivating modules * - * Exports ModuleRegistryService and ModuleGuard so downstream feature modules - * (e.g., DomaincheckModule) can inject and use them. + * Exports ModuleRegistryService, ModuleAccessService and ModuleGuard so + * downstream feature modules (e.g., DomaincheckModule, DashboardModule + * Plan 15-05, Grant-Services Plan 15-03) can inject and use them. */ @Module({ controllers: [ModuleRegistryController], - providers: [ModuleRegistryService, ModuleGuard], - exports: [ModuleRegistryService, ModuleGuard], + providers: [ModuleRegistryService, ModuleAccessService, ModuleGuard], + exports: [ModuleRegistryService, ModuleAccessService, ModuleGuard], }) export class ModuleRegistryModule {} diff --git a/apps/api/src/module-registry/module.guard.spec.ts b/apps/api/src/module-registry/module.guard.spec.ts new file mode 100644 index 0000000..84d836a --- /dev/null +++ b/apps/api/src/module-registry/module.guard.spec.ts @@ -0,0 +1,119 @@ +import { ForbiddenException } from '@nestjs/common'; +import { describe, expect, it, vi } from 'vitest'; +import { ModuleGuard } from './module.guard'; + +/** + * ModuleGuard.canActivate — deckt die vollständige Behavior-Liste aus + * 15-01-PLAN.md, Task 2 ab. ModuleAccessService und ModuleRegistryService + * sind gemockt (Guard-Unit-Test, kein DB-Zugriff). + */ + +function makeContext(request: any) { + return { + getHandler: () => ({}), + getClass: () => ({}), + switchToHttp: () => ({ + getRequest: () => request, + }), + } as any; +} + +function makeReflector(slug: string | undefined) { + return { getAllAndOverride: vi.fn(() => slug) } as any; +} + +describe('ModuleGuard.canActivate', () => { + it('gibt true zurück und ruft keinen Service auf, wenn kein @UseModule-Slug in den Metadaten steht', async () => { + const moduleRegistryService = { findBySlug: vi.fn() } as any; + const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any; + const guard = new ModuleGuard(makeReflector(undefined), moduleRegistryService, moduleAccessService); + + const result = await guard.canActivate(makeContext({})); + + expect(result).toBe(true); + expect(moduleRegistryService.findBySlug).not.toHaveBeenCalled(); + expect(moduleAccessService.getAccessibleModuleIds).not.toHaveBeenCalled(); + }); + + it('wirft ForbiddenException("No tenant context"), wenn weder request.tenantId noch request.user.tenantId gesetzt sind', async () => { + const moduleRegistryService = { findBySlug: vi.fn() } as any; + const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any; + const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService); + + await expect(guard.canActivate(makeContext({ user: {} }))).rejects.toThrow( + new ForbiddenException('No tenant context'), + ); + }); + + it('wirft ForbiddenException("No user context"), wenn tenantId gesetzt ist, aber userId/role fehlen', async () => { + const moduleRegistryService = { findBySlug: vi.fn() } as any; + const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any; + const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService); + + await expect( + guard.canActivate(makeContext({ tenantId: 't1', user: {} })), + ).rejects.toThrow(new ForbiddenException('No user context')); + }); + + it('wirft ForbiddenException bei unbekanntem Slug (findBySlug liefert null)', async () => { + const moduleRegistryService = { findBySlug: vi.fn().mockResolvedValue(null) } as any; + const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any; + const guard = new ModuleGuard(makeReflector('unknown-slug'), moduleRegistryService, moduleAccessService); + + await expect( + guard.canActivate( + makeContext({ tenantId: 't1', user: { id: 'user-1', role: 'USER' } }), + ), + ).rejects.toThrow(ForbiddenException); + expect(moduleAccessService.getAccessibleModuleIds).not.toHaveBeenCalled(); + }); + + it('USER ohne Grant auf ein aktives Modul: wirft ForbiddenException', async () => { + const moduleRegistryService = { + findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }), + } as any; + const moduleAccessService = { + getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()), + } as any; + const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService); + + await expect( + guard.canActivate( + makeContext({ tenantId: 't1', user: { id: 'user-1', role: 'USER' } }), + ), + ).rejects.toThrow(ForbiddenException); + }); + + it('ADMIN ohne Grant auf ein aktives Modul: gibt true zurück (D-03-Kurzschluss lebt in ModuleAccessService)', async () => { + const moduleRegistryService = { + findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }), + } as any; + const moduleAccessService = { + getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set(['mod-1'])), + } as any; + const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService); + const request = { tenantId: 't1', user: { id: 'admin-1', role: 'ADMIN' } }; + + const result = await guard.canActivate(makeContext(request)); + + expect(result).toBe(true); + expect(moduleAccessService.getAccessibleModuleIds).toHaveBeenCalledWith('t1', 'admin-1', 'ADMIN'); + }); + + it('USER mit Zugriff: gibt true zurück und legt das Ergebnis auf request.moduleAccessIds ab (Per-Request-Memoisierung, D-09)', async () => { + const moduleRegistryService = { + findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }), + } as any; + const accessibleIds = new Set(['mod-1']); + const moduleAccessService = { + getAccessibleModuleIds: vi.fn().mockResolvedValue(accessibleIds), + } as any; + const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService); + const request = { tenantId: 't1', user: { id: 'user-1', role: 'USER' } }; + + const result = await guard.canActivate(makeContext(request)); + + expect(result).toBe(true); + expect((request as any).moduleAccessIds).toBe(accessibleIds); + }); +}); diff --git a/apps/api/src/module-registry/module.guard.ts b/apps/api/src/module-registry/module.guard.ts index a080ca3..947dc1a 100644 --- a/apps/api/src/module-registry/module.guard.ts +++ b/apps/api/src/module-registry/module.guard.ts @@ -8,6 +8,7 @@ import { UseGuards, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; +import { ModuleAccessService } from './module-access.service'; import { ModuleRegistryService } from './module-registry.service'; /** @@ -16,17 +17,24 @@ import { ModuleRegistryService } from './module-registry.service'; export const MODULE_SLUG_KEY = 'moduleSlug'; /** - * Guard that checks whether the requesting tenant has an active - * module activation for the module identified by its slug. + * Guard that checks whether the requesting user has access to the module + * identified by its slug — Aktivierung UND (Rolle ODER Direkt-Grant ODER + * Gruppen-Grant), D-01. * - * Per T-03-04: tenantId is sourced from JWT (via TenantMiddleware), - * not from user-supplied input, preventing elevation of privilege. + * Per T-03-04/T-15-10: tenantId, userId und role stammen ausschließlich + * aus dem validierten JWT (via TenantMiddleware/JwtAuthGuard), nie aus + * Body oder Params — verhindert Elevation of Privilege. + * + * T-15-03: Ohne `@UseModule(slug)`-Metadaten gibt der Guard bewusst + * `true` zurück (Durchsetzung hängt am Dekorator) — jeder neue + * Modul-Controller MUSS `@UseModule` tragen (Projektregel seit Phase 3). */ @Injectable() export class ModuleGuard implements CanActivate { constructor( private readonly reflector: Reflector, private readonly moduleRegistryService: ModuleRegistryService, + private readonly moduleAccessService: ModuleAccessService, ) {} async canActivate(context: ExecutionContext): Promise { @@ -48,24 +56,45 @@ export class ModuleGuard implements CanActivate { throw new ForbiddenException('No tenant context'); } - const isActive = await this.moduleRegistryService.isModuleActive( - tenantId, - moduleSlug, - ); + const userId = request.user?.id; + const role = request.user?.role; - if (!isActive) { + if (!userId || !role) { + throw new ForbiddenException('No user context'); + } + + const module = await this.moduleRegistryService.findBySlug(moduleSlug); + + if (!module) { throw new ForbiddenException( `Module '${moduleSlug}' is not activated for this tenant`, ); } + const accessibleModuleIds = await this.moduleAccessService.getAccessibleModuleIds( + tenantId, + userId, + role, + ); + + if (!accessibleModuleIds.has(module.id)) { + throw new ForbiddenException( + `Module '${moduleSlug}' is not accessible for this user`, + ); + } + + // Per-Request-Memoisierung (D-09): ein nachfolgender Handler im + // selben Request bezahlt die Auflösung nicht ein zweites Mal. Über + // Request-Grenzen hinweg wird nichts zwischengespeichert. + request.moduleAccessIds = accessibleModuleIds; + return true; } } /** * Decorator that protects a controller or route handler with the ModuleGuard. - * Ensures the specified module is activated for the requesting tenant. + * Ensures the specified module is accessible for the requesting user. * * Usage: * @UseModule('domaincheck')