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
@@ -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);
}
/**