feat(15-01): ModuleAccessService as single source of truth for module access (D-01)

- ModuleAccessService.getAccessibleModuleIds(tenantId, userId, role):
  ADMIN/SUPER_ADMIN bypass (D-03) via one query, otherwise a single
  Promise.all of direct + group ModuleGrant lookups intersected against
  active TenantModuleActivation (D-02) — no N+1 over the user's groups
- findAccessibleModules() adds the name-asc sort for stable sidebar order
- ModuleGuard now resolves userId/role from request.user (JWT-sourced,
  never body/params) and calls getAccessibleModuleIds instead of the
  tenant-only isModuleActive check; caches the result on
  request.moduleAccessIds for same-request reuse (D-09, no cross-request
  caching)
- ModuleRegistryController.findActive delegates to
  ModuleAccessService.findAccessibleModules instead of
  findActiveForTenant, which stays untouched for Plan 15-03's
  tenant-wide marketplace catalog
- ModuleRegistryModule exports ModuleAccessService for Plan 15-03/15-05
- module-access.service.spec.ts / module.guard.spec.ts cover every case
  in the plan's <behavior> list with a hand-rolled Prisma mock
- End-to-end verified against the running local API: a USER without a
  grant gets 403 on a @UseModule-protected endpoint and an empty
  /modules/active list; the same USER with a direct grant gets 200 plus
  the slug in the list; an ADMIN without any grant also gets 200 (D-03)
This commit is contained in:
2026-08-04 15:09:10 +02:00
parent c5c704bae9
commit 9a4ba8a33c
6 changed files with 493 additions and 19 deletions
@@ -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<string, { moduleId: string }[]> = {
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();
});
});
@@ -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<Set<string>> {
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' },
});
}
}
@@ -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);
}
/**
@@ -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 {}
@@ -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);
});
});
+39 -10
View File
@@ -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<boolean> {
@@ -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')