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
This commit is contained in:
@@ -8,6 +8,7 @@ import { ForcePasswordChangeInterceptor } from './auth/interceptors/force-passwo
|
|||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { LdapModule } from './ldap/ldap.module';
|
import { LdapModule } from './ldap/ldap.module';
|
||||||
import { MailModule } from './mail/mail.module';
|
import { MailModule } from './mail/mail.module';
|
||||||
|
import { ModuleRegistryModule } from './module-registry/module-registry.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { TenantMiddleware } from './tenant/tenant.middleware';
|
import { TenantMiddleware } from './tenant/tenant.middleware';
|
||||||
import { TenantModule } from './tenant/tenant.module';
|
import { TenantModule } from './tenant/tenant.module';
|
||||||
@@ -23,6 +24,7 @@ import { UserModule } from './user/user.module';
|
|||||||
HealthModule,
|
HealthModule,
|
||||||
MailModule,
|
MailModule,
|
||||||
LdapModule,
|
LdapModule,
|
||||||
|
ModuleRegistryModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global JWT guard: all routes require auth unless @Public()
|
// Global JWT guard: all routes require auth unless @Public()
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class ActivateModuleDto {
|
||||||
|
@IsUUID()
|
||||||
|
moduleId!: string;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<boolean> {
|
||||||
|
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<string, string>;
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<boolean> {
|
||||||
|
// Get moduleSlug from metadata (set by @UseModule decorator)
|
||||||
|
const moduleSlug = this.reflector.getAllAndOverride<string>(
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user