From fa15d3527ab63b506b40ada925a830055423207c Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 19 Jun 2026 12:36:33 +0200 Subject: [PATCH] feat(03-01): add ModuleRegistry NestJS module with CRUD and activation endpoints - ModuleRegistryService with findAll, findBySlug, findActiveForTenant, activate/deactivate, seedModule - ModuleRegistryController with GET /modules, GET /modules/active, POST activate/deactivate - ModuleGuard + @UseModule() decorator for tenant-scoped module access control - ActivateModuleDto with UUID validation - Registered ModuleRegistryModule in AppModule imports --- apps/api/src/app.module.ts | 2 + .../dto/activate-module.dto.ts | 6 + .../module-registry.controller.ts | 94 +++++++++ .../module-registry/module-registry.module.ts | 22 ++ .../module-registry.service.ts | 188 ++++++++++++++++++ apps/api/src/module-registry/module.guard.ts | 81 ++++++++ 6 files changed, 393 insertions(+) create mode 100644 apps/api/src/module-registry/dto/activate-module.dto.ts create mode 100644 apps/api/src/module-registry/module-registry.controller.ts create mode 100644 apps/api/src/module-registry/module-registry.module.ts create mode 100644 apps/api/src/module-registry/module-registry.service.ts create mode 100644 apps/api/src/module-registry/module.guard.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 882ed2d..5cd3159 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -8,6 +8,7 @@ import { ForcePasswordChangeInterceptor } from './auth/interceptors/force-passwo import { HealthModule } from './health/health.module'; import { LdapModule } from './ldap/ldap.module'; import { MailModule } from './mail/mail.module'; +import { ModuleRegistryModule } from './module-registry/module-registry.module'; import { PrismaModule } from './prisma/prisma.module'; import { TenantMiddleware } from './tenant/tenant.middleware'; import { TenantModule } from './tenant/tenant.module'; @@ -23,6 +24,7 @@ import { UserModule } from './user/user.module'; HealthModule, MailModule, LdapModule, + ModuleRegistryModule, ], providers: [ // Global JWT guard: all routes require auth unless @Public() diff --git a/apps/api/src/module-registry/dto/activate-module.dto.ts b/apps/api/src/module-registry/dto/activate-module.dto.ts new file mode 100644 index 0000000..96f65bc --- /dev/null +++ b/apps/api/src/module-registry/dto/activate-module.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class ActivateModuleDto { + @IsUUID() + moduleId!: string; +} diff --git a/apps/api/src/module-registry/module-registry.controller.ts b/apps/api/src/module-registry/module-registry.controller.ts new file mode 100644 index 0000000..e10e22c --- /dev/null +++ b/apps/api/src/module-registry/module-registry.controller.ts @@ -0,0 +1,94 @@ +import { + Controller, + ForbiddenException, + Get, + Param, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { Role } from '@prisma/client'; +import { Request } from 'express'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { RolesGuard } from '../auth/guards/roles.guard'; +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 + * - POST /modules/:moduleId/activate — activate a module (ADMIN/SUPER_ADMIN) + * - POST /modules/:moduleId/deactivate — deactivate a module (ADMIN/SUPER_ADMIN) + * + * All routes require JWT auth (global JwtAuthGuard). + * Activation/deactivation require ADMIN or SUPER_ADMIN role (T-03-01). + */ +@Controller('modules') +export class ModuleRegistryController { + constructor( + private readonly moduleRegistryService: ModuleRegistryService, + ) {} + + /** + * GET /modules + * Returns all registered modules in the system. + * Available to any authenticated user (T-03-03: module catalog is non-sensitive). + */ + @Get() + async findAll() { + return this.moduleRegistryService.findAll(); + } + + /** + * GET /modules/active + * Returns modules that are active for the requesting tenant. + */ + @Get('active') + async findActive(@Req() req: Request) { + const tenantId = (req as any).tenantId; + if (!tenantId) { + throw new ForbiddenException('No tenant context'); + } + return this.moduleRegistryService.findActiveForTenant(tenantId); + } + + /** + * POST /modules/:moduleId/activate + * Activates a module for the current tenant. + * T-03-01: Only ADMIN or SUPER_ADMIN can activate modules. + * T-03-02: moduleId validated as UUID by Prisma parameterized queries. + */ + @Post(':moduleId/activate') + @UseGuards(RolesGuard) + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async activate( + @Param('moduleId') moduleId: string, + @Req() req: Request, + ) { + const tenantId = (req as any).tenantId; + if (!tenantId) { + throw new ForbiddenException('No tenant context'); + } + return this.moduleRegistryService.activateForTenant(tenantId, moduleId); + } + + /** + * POST /modules/:moduleId/deactivate + * Deactivates a module for the current tenant (soft-delete). + * T-03-01: Only ADMIN or SUPER_ADMIN can deactivate modules. + */ + @Post(':moduleId/deactivate') + @UseGuards(RolesGuard) + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async deactivate( + @Param('moduleId') moduleId: string, + @Req() req: Request, + ) { + const tenantId = (req as any).tenantId; + if (!tenantId) { + throw new ForbiddenException('No tenant context'); + } + return this.moduleRegistryService.deactivateForTenant(tenantId, moduleId); + } +} diff --git a/apps/api/src/module-registry/module-registry.module.ts b/apps/api/src/module-registry/module-registry.module.ts new file mode 100644 index 0000000..cb9a099 --- /dev/null +++ b/apps/api/src/module-registry/module-registry.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { ModuleRegistryController } from './module-registry.controller'; +import { ModuleRegistryService } from './module-registry.service'; +import { ModuleGuard } from './module.guard'; + +/** + * NestJS module for the Tessera module registry. + * + * Provides: + * - ModuleRegistryService: CRUD for module records + per-tenant activation + * - 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. + */ +@Module({ + controllers: [ModuleRegistryController], + providers: [ModuleRegistryService, ModuleGuard], + exports: [ModuleRegistryService, ModuleGuard], +}) +export class ModuleRegistryModule {} diff --git a/apps/api/src/module-registry/module-registry.service.ts b/apps/api/src/module-registry/module-registry.service.ts new file mode 100644 index 0000000..857955c --- /dev/null +++ b/apps/api/src/module-registry/module-registry.service.ts @@ -0,0 +1,188 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +/** + * Service managing the module registry and per-tenant activations. + * + * Modules are registered centrally; tenants activate/deactivate them + * independently via TenantModuleActivation records (per D-07, D-08). + */ +@Injectable() +export class ModuleRegistryService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Returns all registered modules. + */ + async findAll() { + return this.prisma.module.findMany({ + orderBy: { name: 'asc' }, + }); + } + + /** + * Finds a module by its unique slug. + */ + async findBySlug(slug: string) { + return this.prisma.module.findUnique({ + where: { slug }, + }); + } + + /** + * Returns all active modules for a given tenant. + */ + async findActiveForTenant(tenantId: string) { + const activations = await this.prisma.tenantModuleActivation.findMany({ + where: { + tenantId, + isActive: true, + }, + include: { + module: true, + }, + }); + + return activations.map((a) => a.module); + } + + /** + * Activates a module for a tenant (upsert: creates or re-activates). + * Per D-08: dynamic activation without restart. + */ + async activateForTenant(tenantId: string, moduleId: string) { + // Verify module exists + const moduleExists = await this.prisma.module.findUnique({ + where: { id: moduleId }, + }); + if (!moduleExists) { + throw new NotFoundException(`Module with id '${moduleId}' not found`); + } + + return this.prisma.tenantModuleActivation.upsert({ + where: { + tenantId_moduleId: { + tenantId, + moduleId, + }, + }, + update: { + isActive: true, + activatedAt: new Date(), + }, + create: { + tenantId, + moduleId, + isActive: true, + }, + include: { + module: true, + }, + }); + } + + /** + * Deactivates a module for a tenant (soft-delete: sets isActive=false). + * Does not remove the activation record, preserving audit trail. + */ + async deactivateForTenant(tenantId: string, moduleId: string) { + // Verify module exists + const moduleExists = await this.prisma.module.findUnique({ + where: { id: moduleId }, + }); + if (!moduleExists) { + throw new NotFoundException(`Module with id '${moduleId}' not found`); + } + + // Check if activation record exists + const activation = await this.prisma.tenantModuleActivation.findUnique({ + where: { + tenantId_moduleId: { + tenantId, + moduleId, + }, + }, + }); + + if (!activation) { + throw new NotFoundException( + `Module '${moduleId}' is not activated for this tenant`, + ); + } + + return this.prisma.tenantModuleActivation.update({ + where: { + tenantId_moduleId: { + tenantId, + moduleId, + }, + }, + data: { + isActive: false, + }, + include: { + module: true, + }, + }); + } + + /** + * Checks whether a module (by slug) is active for a given tenant. + * Used by ModuleGuard to gate access to module-specific endpoints. + */ + async isModuleActive(tenantId: string, moduleSlug: string): Promise { + const module = await this.prisma.module.findUnique({ + where: { slug: moduleSlug }, + }); + + if (!module) { + return false; + } + + const activation = await this.prisma.tenantModuleActivation.findUnique({ + where: { + tenantId_moduleId: { + tenantId, + moduleId: module.id, + }, + }, + }); + + return activation?.isActive === true; + } + + /** + * Registers or updates a module in the registry by slug (upsert). + * Used during application startup to seed built-in modules. + */ + async seedModule(manifest: { + slug: string; + name: string; + version: string; + category: string; + description: Record; + icon?: string; + isSystem?: boolean; + }) { + return this.prisma.module.upsert({ + where: { slug: manifest.slug }, + update: { + name: manifest.name, + version: manifest.version, + category: manifest.category, + description: manifest.description, + icon: manifest.icon ?? null, + isSystem: manifest.isSystem ?? false, + }, + create: { + slug: manifest.slug, + name: manifest.name, + version: manifest.version, + category: manifest.category, + description: manifest.description, + icon: manifest.icon ?? null, + isSystem: manifest.isSystem ?? false, + }, + }); + } +} diff --git a/apps/api/src/module-registry/module.guard.ts b/apps/api/src/module-registry/module.guard.ts new file mode 100644 index 0000000..608bbf6 --- /dev/null +++ b/apps/api/src/module-registry/module.guard.ts @@ -0,0 +1,81 @@ +import { + applyDecorators, + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + SetMetadata, + UseGuards, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ModuleRegistryService } from './module-registry.service'; + +/** + * Metadata key for the module slug attached by @UseModule(). + */ +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. + * + * Per T-03-04: tenantId is sourced from JWT (via TenantMiddleware), + * not from user-supplied input, preventing elevation of privilege. + */ +@Injectable() +export class ModuleGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly moduleRegistryService: ModuleRegistryService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + // Get moduleSlug from metadata (set by @UseModule decorator) + const moduleSlug = this.reflector.getAllAndOverride( + MODULE_SLUG_KEY, + [context.getHandler(), context.getClass()], + ); + + // If no module slug is set, allow (guard is not applicable) + if (!moduleSlug) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const tenantId = request.tenantId; + + // Public routes or routes without tenant context skip module check + if (!tenantId) { + return true; + } + + const isActive = await this.moduleRegistryService.isModuleActive( + tenantId, + moduleSlug, + ); + + if (!isActive) { + throw new ForbiddenException( + `Module '${moduleSlug}' is not activated for this tenant`, + ); + } + + return true; + } +} + +/** + * Decorator that protects a controller or route handler with the ModuleGuard. + * Ensures the specified module is activated for the requesting tenant. + * + * Usage: + * @UseModule('domaincheck') + * @Controller('domaincheck') + * export class DomaincheckController { ... } + */ +export function UseModule(slug: string) { + return applyDecorators( + SetMetadata(MODULE_SLUG_KEY, slug), + UseGuards(ModuleGuard), + ); +}