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
+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')