From 99d96dd695f619f55b45866c53cf2f570ba14904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20TR=C3=89BEL=20=28Perso=29?= Date: Fri, 20 Mar 2026 16:26:53 +0100 Subject: [PATCH 1/4] feat(server-nestjs): add new ServiceChain (OpenCDS) module --- apps/server-nestjs/.env.docker-example | 8 + apps/server-nestjs/README.md | 101 ++++++++++++ .../src/cpin-module/cpin.module.ts | 2 + .../auth/admin-permission.decorator.ts | 8 + .../auth/admin-permission.guard.spec.ts | 81 ++++++++++ .../auth/admin-permission.guard.ts | 45 ++++++ .../infrastructure/auth/auth.module.ts | 11 ++ .../infrastructure/auth/auth.service.spec.ts | 141 +++++++++++++++++ .../infrastructure/auth/auth.service.ts | 94 +++++++++++ .../configuration/configuration.service.ts | 5 + .../service-chain.controller.spec.ts | 86 +++++++++++ .../service-chain/service-chain.controller.ts | 46 ++++++ .../service-chain/service-chain.module.ts | 12 ++ .../service-chain.service.spec.ts | 146 ++++++++++++++++++ .../service-chain/service-chain.service.ts | 65 ++++++++ .../src/prisma/schema/admin.prisma | 1 + 16 files changed, 852 insertions(+) create mode 100644 apps/server-nestjs/src/cpin-module/infrastructure/auth/admin-permission.decorator.ts create mode 100644 apps/server-nestjs/src/cpin-module/infrastructure/auth/admin-permission.guard.spec.ts create mode 100644 apps/server-nestjs/src/cpin-module/infrastructure/auth/admin-permission.guard.ts create mode 100644 apps/server-nestjs/src/cpin-module/infrastructure/auth/auth.module.ts create mode 100644 apps/server-nestjs/src/cpin-module/infrastructure/auth/auth.service.spec.ts create mode 100644 apps/server-nestjs/src/cpin-module/infrastructure/auth/auth.service.ts create mode 100644 apps/server-nestjs/src/cpin-module/service-chain/service-chain.controller.spec.ts create mode 100644 apps/server-nestjs/src/cpin-module/service-chain/service-chain.controller.ts create mode 100644 apps/server-nestjs/src/cpin-module/service-chain/service-chain.module.ts create mode 100644 apps/server-nestjs/src/cpin-module/service-chain/service-chain.service.spec.ts create mode 100644 apps/server-nestjs/src/cpin-module/service-chain/service-chain.service.ts diff --git a/apps/server-nestjs/.env.docker-example b/apps/server-nestjs/.env.docker-example index 0dc790476..f7092b31d 100644 --- a/apps/server-nestjs/.env.docker-example +++ b/apps/server-nestjs/.env.docker-example @@ -26,3 +26,11 @@ SERVER_PORT=3001 DB_URL=postgresql://admin:admin@postgres:5432/dso-console-db?schema=public # Adresse e-mail de contact affichée dans l'interface CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr + +# --- Configuration OpenCDS --- +# URL de l'API OpenCDS (laisser vide pour désactiver) +OPENCDS_URL= +# Token d'authentification pour l'API OpenCDS +OPENCDS_API_TOKEN=token +# Vérification du certificat TLS de l'API OpenCDS (true | false) +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=true diff --git a/apps/server-nestjs/README.md b/apps/server-nestjs/README.md index 20f11e54d..62673164c 100644 --- a/apps/server-nestjs/README.md +++ b/apps/server-nestjs/README.md @@ -370,6 +370,107 @@ flowchart TD ApplicationInitializationService --> LoggerService ``` +## Architecture du module ServiceChain (Vague 1) + +Le module ServiceChain est le **premier module métier migré** depuis le legacy +vers server-nestjs via le pattern Strangler Fig. Il sert de proxy HTTP vers +l'API externe OpenCDS (gestion des chaînes de service réseau). + +### Flux de proxying OpenCDS + +``` +┌──────────┐ ┌─────────────────┐ ┌──────────────────┐ ┌──────────────┐ +│ Client │────▶│ nginx-strangler │────▶│ server-nestjs │────▶│ API OpenCDS │ +│ (browser/ │ │ │ │ │ │ (externe) │ +│ script) │ │ /api/v1/ │ │ ServiceChain │ │ │ +│ │◀────│ service-chains │◀────│ Controller │◀────│ /requests │ +│ │ │ ──▶ nestjs │ │ ──▶ Service │ │ /validate │ +└──────────┘ │ │ │ ──▶ axios │ │ /flows │ + │ /api/* (reste) │ └──────────────────┘ └──────────────┘ + │ ──▶ legacy │ + └─────────────────┘ +``` + +### Structure du module + +``` +src/cpin-module/ +├── infrastructure/ +│ └── auth/ # Auth transverse (réutilisable) +│ ├── auth.module.ts # Module NestJS +│ ├── auth.service.ts # Lookup token SHA256 → Prisma +│ ├── admin-permission.guard.ts # Guard : vérifie x-dso-token + permissions +│ ├── admin-permission.decorator.ts @RequireAdminPermission('ListSystem') +│ └── admin-permission.guard.spec.ts +│ +└── service-chain/ # Module métier + ├── service-chain.module.ts # Imports: ConfigurationModule, AuthModule + ├── service-chain.controller.ts # 5 endpoints sous /api/v1/service-chains + ├── service-chain.service.ts # Proxy axios → OpenCDS + validation Zod + ├── service-chain.controller.spec.ts + └── service-chain.service.spec.ts +``` + +### Authentification + +Seule l'**auth par token** (`x-dso-token`) est supportée pour l'instant. +L'auth par session Keycloak sera ajoutée lors de la migration globale du +mécanisme de session vers NestJS (les cookies de session Fastify ne sont pas +déchiffrables par Express). + +``` + Requête HTTP + │ + ▼ + ┌──────────────────────┐ + │ AdminPermissionGuard │ + │ │ + │ 1. Lire header │ + │ x-dso-token │──── absent ──▶ 401 Unauthorized + │ │ + │ 2. SHA256(token) │ + │ → Prisma lookup │──── invalide ──▶ 401 Unauthorized + │ (PersonalAccess │ (expiré, révoqué, introuvable) + │ Token ou Admin │ + │ Token) │ + │ │ + │ 3. Vérifier perms │ + │ bitwise via │──── insuffisant ──▶ 403 Forbidden + │ AdminAuthorized │ + │ │ + │ 4. OK → continuer │──────────────────▶ Controller + └──────────────────────┘ +``` + +### Endpoints + +| Méthode | Route | Permission | Description | +|---------|-------|------------|-------------| +| `GET` | `/api/v1/service-chains` | `ListSystem` | Liste toutes les chaînes | +| `GET` | `/api/v1/service-chains/:id` | `ListSystem` | Détails d'une chaîne | +| `GET` | `/api/v1/service-chains/:id/flows` | `ListSystem` | Flux d'une chaîne | +| `POST` | `/api/v1/service-chains/:id/retry` | `ManageSystem` | Relancer une chaîne | +| `POST` | `/api/v1/service-chains/validate/:id` | `ManageSystem` | Valider une chaîne | + +### Différences avec le legacy + +- **403 systématique** : le legacy retournait `[]` sur `GET /` sans permission ; + le NestJS retourne 403 pour tous les endpoints sans permission. +- **Pas d'auth session** : seul `x-dso-token` fonctionne (le legacy supportait + aussi les sessions Keycloak). +- **Validation UUID** : les paramètres d'URL sont validés via `ParseUUIDPipe` + (400 si format invalide). + +### Variables d'environnement + +| Variable | Description | Défaut | +|----------|-------------|--------| +| `OPENCDS_URL` | URL de base de l'API OpenCDS | _(vide = désactivé)_ | +| `OPENCDS_API_TOKEN` | Token d'API OpenCDS (header `X-API-Key`) | — | +| `OPENCDS_API_TLS_REJECT_UNAUTHORIZED` | Vérification TLS (`true`/`false`) | `true` | + +--- + Pour mettre à jour `old-server` (après avoir rebasé sur `origin/master`, par exemple) : ```bash diff --git a/apps/server-nestjs/src/cpin-module/cpin.module.ts b/apps/server-nestjs/src/cpin-module/cpin.module.ts index f70ed80f1..6b41ead6b 100644 --- a/apps/server-nestjs/src/cpin-module/cpin.module.ts +++ b/apps/server-nestjs/src/cpin-module/cpin.module.ts @@ -3,6 +3,7 @@ import { Module } from '@nestjs/common' import { ApplicationInitializationModule } from './application-initialization/application-initialization.module' import { CoreModule } from './core/core.module' import { InfrastructureModule } from './infrastructure/infrastructure.module' +import { ServiceChainModule } from './service-chain/service-chain.module' // This module host the old "server code" of our backend. // It it means to be empty in the future, by extracting from it @@ -12,6 +13,7 @@ import { InfrastructureModule } from './infrastructure/infrastructure.module' ApplicationInitializationModule, CoreModule, InfrastructureModule, + ServiceChainModule, ], }) export class CpinModule {} diff --git a/apps/server-nestjs/src/cpin-module/infrastructure/auth/admin-permission.decorator.ts b/apps/server-nestjs/src/cpin-module/infrastructure/auth/admin-permission.decorator.ts new file mode 100644 index 000000000..ab7085217 --- /dev/null +++ b/apps/server-nestjs/src/cpin-module/infrastructure/auth/admin-permission.decorator.ts @@ -0,0 +1,8 @@ +import type { AdminAuthorized } from '@cpn-console/shared' +import { SetMetadata } from '@nestjs/common' + +export const ADMIN_PERMISSIONS_KEY = 'admin-permissions' + +export function RequireAdminPermission(...permissions: (keyof typeof AdminAuthorized)[]) { + return SetMetadata(ADMIN_PERMISSIONS_KEY, permissions) +} diff --git a/apps/server-nestjs/src/cpin-module/infrastructure/auth/admin-permission.guard.spec.ts b/apps/server-nestjs/src/cpin-module/infrastructure/auth/admin-permission.guard.spec.ts new file mode 100644 index 000000000..1b7d16f98 --- /dev/null +++ b/apps/server-nestjs/src/cpin-module/infrastructure/auth/admin-permission.guard.spec.ts @@ -0,0 +1,81 @@ +import type { AuthService } from './auth.service' +import { ForbiddenException, UnauthorizedException } from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AdminPermissionGuard } from './admin-permission.guard' + +function makeContext(headers: Record = {}) { + return { + switchToHttp: () => ({ + getRequest: () => ({ headers }), + }), + getHandler: () => ({}), + } as any +} + +describe('adminPermissionGuard', () => { + let guard: AdminPermissionGuard + let authService: AuthService + let reflector: Reflector + + beforeEach(() => { + authService = { + validateToken: vi.fn(), + } as any + reflector = new Reflector() + guard = new AdminPermissionGuard(authService, reflector) + }) + + it('should throw 401 when x-dso-token header is missing', async () => { + const ctx = makeContext({}) + + await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException) + }) + + it('should throw 401 when token validation fails', async () => { + vi.mocked(authService.validateToken).mockRejectedValue(new UnauthorizedException('Not authenticated')) + const ctx = makeContext({ 'x-dso-token': 'bad-token' }) + + await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException) + }) + + it('should pass when no permission metadata is set', async () => { + vi.mocked(authService.validateToken).mockResolvedValue({ userId: 'u1', adminPermissions: 0n }) + vi.spyOn(reflector, 'get').mockReturnValue(undefined) + const ctx = makeContext({ 'x-dso-token': 'tok' }) + + const result = await guard.canActivate(ctx) + + expect(result).toBe(true) + }) + + it('should pass when user has required permission (ListSystem)', async () => { + // LIST_SYSTEM = bit(15) = 32768n + vi.mocked(authService.validateToken).mockResolvedValue({ userId: 'u1', adminPermissions: 32768n }) + vi.spyOn(reflector, 'get').mockReturnValue(['ListSystem']) + const ctx = makeContext({ 'x-dso-token': 'tok' }) + + const result = await guard.canActivate(ctx) + + expect(result).toBe(true) + }) + + it('should throw 403 when user lacks required permission', async () => { + vi.mocked(authService.validateToken).mockResolvedValue({ userId: 'u1', adminPermissions: 0n }) + vi.spyOn(reflector, 'get').mockReturnValue(['ManageSystem']) + const ctx = makeContext({ 'x-dso-token': 'tok' }) + + await expect(guard.canActivate(ctx)).rejects.toThrow(ForbiddenException) + }) + + it('should pass when user has MANAGE (superadmin override)', async () => { + // MANAGE = bit(1) = 2n — overrides all permissions + vi.mocked(authService.validateToken).mockResolvedValue({ userId: 'u1', adminPermissions: 2n }) + vi.spyOn(reflector, 'get').mockReturnValue(['ManageSystem']) + const ctx = makeContext({ 'x-dso-token': 'tok' }) + + const result = await guard.canActivate(ctx) + + expect(result).toBe(true) + }) +}) diff --git a/apps/server-nestjs/src/cpin-module/infrastructure/auth/admin-permission.guard.ts b/apps/server-nestjs/src/cpin-module/infrastructure/auth/admin-permission.guard.ts new file mode 100644 index 000000000..ea1d875d5 --- /dev/null +++ b/apps/server-nestjs/src/cpin-module/infrastructure/auth/admin-permission.guard.ts @@ -0,0 +1,45 @@ +import type { CanActivate, ExecutionContext } from '@nestjs/common' +import type { Reflector } from '@nestjs/core' +import type { FastifyRequest } from 'fastify' +import type { AuthService } from './auth.service' +import { AdminAuthorized, tokenHeaderName } from '@cpn-console/shared' +import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common' +import { ADMIN_PERMISSIONS_KEY } from './admin-permission.decorator' + +@Injectable() +export class AdminPermissionGuard implements CanActivate { + constructor( + private readonly authService: AuthService, + private readonly reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest() + const tokenValue = request.headers[tokenHeaderName] + + if (typeof tokenValue !== 'string') { + throw new UnauthorizedException() + } + + const { adminPermissions } = await this.authService.validateToken(tokenValue) + + const requiredPermissions = this.reflector.get<(keyof typeof AdminAuthorized)[]>( + ADMIN_PERMISSIONS_KEY, + context.getHandler(), + ) + + if (!requiredPermissions?.length) { + return true + } + + const hasPermission = requiredPermissions.every( + permName => AdminAuthorized[permName](adminPermissions), + ) + + if (!hasPermission) { + throw new ForbiddenException() + } + + return true + } +} diff --git a/apps/server-nestjs/src/cpin-module/infrastructure/auth/auth.module.ts b/apps/server-nestjs/src/cpin-module/infrastructure/auth/auth.module.ts new file mode 100644 index 000000000..ca40e0cd6 --- /dev/null +++ b/apps/server-nestjs/src/cpin-module/infrastructure/auth/auth.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { DatabaseModule } from '../database/database.module' +import { AdminPermissionGuard } from './admin-permission.guard' +import { AuthService } from './auth.service' + +@Module({ + imports: [DatabaseModule], + providers: [AuthService, AdminPermissionGuard], + exports: [AuthService, AdminPermissionGuard], +}) +export class AuthModule {} diff --git a/apps/server-nestjs/src/cpin-module/infrastructure/auth/auth.service.spec.ts b/apps/server-nestjs/src/cpin-module/infrastructure/auth/auth.service.spec.ts new file mode 100644 index 000000000..edd055bda --- /dev/null +++ b/apps/server-nestjs/src/cpin-module/infrastructure/auth/auth.service.spec.ts @@ -0,0 +1,141 @@ +import { createHash } from 'node:crypto' +import { UnauthorizedException } from '@nestjs/common' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AuthService } from './auth.service' + +function sha256(value: string) { + return createHash('sha256').update(value).digest('hex') +} + +const mockUser = { id: 'user-1', adminRoleIds: ['role-1'] } + +function makePrisma() { + return { + personalAccessToken: { + findFirst: vi.fn(), + update: vi.fn(), + }, + adminToken: { + findFirst: vi.fn(), + update: vi.fn(), + }, + adminRole: { + findMany: vi.fn().mockResolvedValue([]), + }, + user: { + update: vi.fn(), + }, + } +} + +describe('authService', () => { + let service: AuthService + let prisma: ReturnType + + beforeEach(() => { + prisma = makePrisma() + service = new AuthService(prisma as any) + }) + + it('should validate a PersonalAccessToken and return permissions from user roles', async () => { + const rawToken = 'my-token' + prisma.personalAccessToken.findFirst.mockResolvedValue({ + id: 'pat-1', + status: 'active', + expirationDate: new Date(Date.now() + 86400000), + owner: mockUser, + }) + prisma.adminRole.findMany + .mockResolvedValueOnce([]) // global roles + .mockResolvedValueOnce([{ permissions: 4n }]) // user roles + + const result = await service.validateToken(rawToken) + + expect(prisma.personalAccessToken.findFirst).toHaveBeenCalledWith({ + where: { hash: sha256(rawToken) }, + include: { owner: true }, + }) + expect(result.userId).toBe('user-1') + expect(result.adminPermissions).toBe(4n) + }) + + it('should validate an AdminToken and use token.permissions directly', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue(null) + prisma.adminToken.findFirst.mockResolvedValue({ + id: 'at-1', + status: 'active', + expirationDate: null, + permissions: 256n, // MANAGE_SYSTEM + owner: mockUser, + }) + prisma.adminRole.findMany.mockResolvedValue([]) // global roles + + const result = await service.validateToken('admin-tok') + + expect(result.adminPermissions).toBe(256n) + }) + + it('should OR global role permissions with token permissions', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue(null) + prisma.adminToken.findFirst.mockResolvedValue({ + id: 'at-1', + status: 'active', + expirationDate: null, + permissions: 256n, + owner: mockUser, + }) + prisma.adminRole.findMany.mockResolvedValue([{ permissions: 1n }]) // global role with LIST + + const result = await service.validateToken('tok') + + expect(result.adminPermissions).toBe(257n) // 256n | 1n + }) + + it('should throw 401 when no token found', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue(null) + prisma.adminToken.findFirst.mockResolvedValue(null) + + await expect(service.validateToken('unknown')).rejects.toThrow(UnauthorizedException) + }) + + it('should throw 401 when token is inactive', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue({ + id: 'pat-1', + status: 'revoked', + expirationDate: null, + owner: mockUser, + }) + + await expect(service.validateToken('revoked-tok')).rejects.toThrow(UnauthorizedException) + }) + + it('should throw 401 when token is expired', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue({ + id: 'pat-1', + status: 'active', + expirationDate: new Date(Date.now() - 1000), + owner: mockUser, + }) + + await expect(service.validateToken('expired-tok')).rejects.toThrow(UnauthorizedException) + }) + + it('should update lastUse and lastLogin', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue({ + id: 'pat-1', + status: 'active', + expirationDate: null, + owner: mockUser, + }) + prisma.adminRole.findMany.mockResolvedValue([]) + + await service.validateToken('tok') + + expect(prisma.personalAccessToken.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'pat-1' }, data: { lastUse: expect.any(String) } }), + ) + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'user-1' }, data: { lastLogin: expect.any(String) } }), + ) + }) +}) diff --git a/apps/server-nestjs/src/cpin-module/infrastructure/auth/auth.service.ts b/apps/server-nestjs/src/cpin-module/infrastructure/auth/auth.service.ts new file mode 100644 index 000000000..3add3675d --- /dev/null +++ b/apps/server-nestjs/src/cpin-module/infrastructure/auth/auth.service.ts @@ -0,0 +1,94 @@ +import type { PrismaService } from '../database/prisma.service' +import { createHash } from 'node:crypto' +import { Injectable, UnauthorizedException } from '@nestjs/common' + +export interface TokenValidationResult { + userId: string + adminPermissions: bigint +} + +@Injectable() +export class AuthService { + constructor(private readonly prisma: PrismaService) {} + + async validateToken(rawToken: string): Promise { + const hash = createHash('sha256').update(rawToken).digest('hex') + + const result = await this.findAndValidateToken(hash) + if (!result) { + throw new UnauthorizedException('Not authenticated') + } + + const globalRoles = await this.prisma.adminRole.findMany({ + where: { type: 'global' }, + select: { permissions: true }, + }) + const globalPerms = globalRoles.reduce((acc, curr) => acc | curr.permissions, 0n) + + const tokenPerms = await this.resolveTokenPermissions(result) + + return { + userId: result.userId, + adminPermissions: globalPerms | tokenPerms, + } + } + + private async findAndValidateToken(hash: string) { + // Try PersonalAccessToken first, then AdminToken + const pat = await this.prisma.personalAccessToken.findFirst({ + where: { hash }, + include: { owner: true }, + }) + if (pat) { + this.assertTokenValid(pat) + await this.updateLastUse('personalAccessToken', pat.id, pat.owner.id) + return { kind: 'personal' as const, userId: pat.owner.id, ownerAdminRoleIds: pat.owner.adminRoleIds } + } + + const adminToken = await this.prisma.adminToken.findFirst({ + where: { hash }, + include: { owner: true }, + }) + if (adminToken) { + this.assertTokenValid(adminToken) + await this.updateLastUse('adminToken', adminToken.id, adminToken.owner.id) + return { kind: 'admin' as const, userId: adminToken.owner.id, permissions: adminToken.permissions } + } + + return undefined + } + + private assertTokenValid(token: { status: string, expirationDate: Date | null }) { + if (token.status !== 'active') { + throw new UnauthorizedException('Not active') + } + if (token.expirationDate && Date.now() > token.expirationDate.getTime()) { + throw new UnauthorizedException('Expired') + } + } + + private async updateLastUse(model: 'personalAccessToken' | 'adminToken', tokenId: string, userId: string) { + const now = new Date().toISOString() + if (model === 'personalAccessToken') { + await this.prisma.personalAccessToken.update({ where: { id: tokenId }, data: { lastUse: now } }) + } else { + await this.prisma.adminToken.update({ where: { id: tokenId }, data: { lastUse: now } }) + } + await this.prisma.user.update({ where: { id: userId }, data: { lastLogin: now } }) + } + + private async resolveTokenPermissions( + result: { kind: 'admin', permissions: bigint } | { kind: 'personal', ownerAdminRoleIds: string[] }, + ): Promise { + if (result.kind === 'admin') { + return result.permissions + } + if (!result.ownerAdminRoleIds.length) { + return 0n + } + const roles = await this.prisma.adminRole.findMany({ + where: { id: { in: result.ownerAdminRoleIds } }, + }) + return roles.reduce((acc, curr) => acc | curr.permissions, 0n) + } +} diff --git a/apps/server-nestjs/src/cpin-module/infrastructure/configuration/configuration.service.ts b/apps/server-nestjs/src/cpin-module/infrastructure/configuration/configuration.service.ts index b3af13bcb..b41a8e257 100644 --- a/apps/server-nestjs/src/cpin-module/infrastructure/configuration/configuration.service.ts +++ b/apps/server-nestjs/src/cpin-module/infrastructure/configuration/configuration.service.ts @@ -33,6 +33,11 @@ export class ConfigurationService { = process.env.CONTACT_EMAIL ?? 'cloudpinative-relations@interieur.gouv.fr' + // opencds + openCdsUrl = process.env.OPENCDS_URL + openCdsApiToken = process.env.OPENCDS_API_TOKEN + openCdsApiTlsRejectUnauthorized = process.env.OPENCDS_API_TLS_REJECT_UNAUTHORIZED !== 'false' + // plugins mockPlugins = process.env.MOCK_PLUGINS === 'true' projectRootDir = process.env.PROJECTS_ROOT_DIR diff --git a/apps/server-nestjs/src/cpin-module/service-chain/service-chain.controller.spec.ts b/apps/server-nestjs/src/cpin-module/service-chain/service-chain.controller.spec.ts new file mode 100644 index 000000000..2e895b8af --- /dev/null +++ b/apps/server-nestjs/src/cpin-module/service-chain/service-chain.controller.spec.ts @@ -0,0 +1,86 @@ +import type { ServiceChainService } from './service-chain.service' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ServiceChainController } from './service-chain.controller' + +vi.mock('../infrastructure/telemetry/telemetry.decorator', () => ({ + StartActiveSpan: () => (_target: any, _key: string, descriptor: PropertyDescriptor) => descriptor, +})) + +describe('serviceChainController', () => { + let controller: ServiceChainController + let service: ServiceChainService + + const uuid = '550e8400-e29b-41d4-a716-446655440000' + + beforeEach(() => { + service = { + list: vi.fn(), + getDetails: vi.fn(), + retry: vi.fn(), + validate: vi.fn(), + getFlows: vi.fn(), + } as unknown as ServiceChainService + + controller = new ServiceChainController(service) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + describe('list', () => { + it('should call service.list()', async () => { + const mockResult = [{ id: uuid }] + vi.mocked(service.list).mockResolvedValue(mockResult as any) + + const result = await controller.list() + + expect(service.list).toHaveBeenCalled() + expect(result).toEqual(mockResult) + }) + }) + + describe('getDetails', () => { + it('should call service.getDetails() with id', async () => { + const mockResult = { id: uuid } + vi.mocked(service.getDetails).mockResolvedValue(mockResult as any) + + const result = await controller.getDetails(uuid) + + expect(service.getDetails).toHaveBeenCalledWith(uuid) + expect(result).toEqual(mockResult) + }) + }) + + describe('retry', () => { + it('should call service.retry() with id', async () => { + vi.mocked(service.retry).mockResolvedValue() + + await controller.retry(uuid) + + expect(service.retry).toHaveBeenCalledWith(uuid) + }) + }) + + describe('validate', () => { + it('should call service.validate() with validationId', async () => { + vi.mocked(service.validate).mockResolvedValue() + + await controller.validate(uuid) + + expect(service.validate).toHaveBeenCalledWith(uuid) + }) + }) + + describe('getFlows', () => { + it('should call service.getFlows() with id', async () => { + const mockResult = { reserve_ip: {} } + vi.mocked(service.getFlows).mockResolvedValue(mockResult as any) + + const result = await controller.getFlows(uuid) + + expect(service.getFlows).toHaveBeenCalledWith(uuid) + expect(result).toEqual(mockResult) + }) + }) +}) diff --git a/apps/server-nestjs/src/cpin-module/service-chain/service-chain.controller.ts b/apps/server-nestjs/src/cpin-module/service-chain/service-chain.controller.ts new file mode 100644 index 000000000..7f38f8c01 --- /dev/null +++ b/apps/server-nestjs/src/cpin-module/service-chain/service-chain.controller.ts @@ -0,0 +1,46 @@ +import type { ServiceChainService } from './service-chain.service' +import { Controller, Get, HttpCode, Param, ParseUUIDPipe, Post, UseGuards } from '@nestjs/common' +import { RequireAdminPermission } from '../infrastructure/auth/admin-permission.decorator' +import { AdminPermissionGuard } from '../infrastructure/auth/admin-permission.guard' + +@Controller('api/v1/service-chains') +export class ServiceChainController { + constructor(private readonly serviceChainService: ServiceChainService) {} + + @Post('validate/:validationId') + @HttpCode(204) + @UseGuards(AdminPermissionGuard) + @RequireAdminPermission('ManageSystem') + async validate(@Param('validationId', ParseUUIDPipe) validationId: string) { + await this.serviceChainService.validate(validationId) + } + + @Get() + @UseGuards(AdminPermissionGuard) + @RequireAdminPermission('ListSystem') + async list() { + return this.serviceChainService.list() + } + + @Get(':serviceChainId') + @UseGuards(AdminPermissionGuard) + @RequireAdminPermission('ListSystem') + async getDetails(@Param('serviceChainId', ParseUUIDPipe) id: string) { + return this.serviceChainService.getDetails(id) + } + + @Post(':serviceChainId/retry') + @HttpCode(204) + @UseGuards(AdminPermissionGuard) + @RequireAdminPermission('ManageSystem') + async retry(@Param('serviceChainId', ParseUUIDPipe) id: string) { + await this.serviceChainService.retry(id) + } + + @Get(':serviceChainId/flows') + @UseGuards(AdminPermissionGuard) + @RequireAdminPermission('ListSystem') + async getFlows(@Param('serviceChainId', ParseUUIDPipe) id: string) { + return this.serviceChainService.getFlows(id) + } +} diff --git a/apps/server-nestjs/src/cpin-module/service-chain/service-chain.module.ts b/apps/server-nestjs/src/cpin-module/service-chain/service-chain.module.ts new file mode 100644 index 000000000..a6d520b1f --- /dev/null +++ b/apps/server-nestjs/src/cpin-module/service-chain/service-chain.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { ServiceChainController } from './service-chain.controller' +import { ServiceChainService } from './service-chain.service' + +@Module({ + imports: [ConfigurationModule, AuthModule], + controllers: [ServiceChainController], + providers: [ServiceChainService], +}) +export class ServiceChainModule {} diff --git a/apps/server-nestjs/src/cpin-module/service-chain/service-chain.service.spec.ts b/apps/server-nestjs/src/cpin-module/service-chain/service-chain.service.spec.ts new file mode 100644 index 000000000..d947e007f --- /dev/null +++ b/apps/server-nestjs/src/cpin-module/service-chain/service-chain.service.spec.ts @@ -0,0 +1,146 @@ +import type { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import axios from 'axios' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { ServiceChainService } from './service-chain.service' + +vi.mock('../infrastructure/telemetry/telemetry.decorator', () => ({ + StartActiveSpan: () => (_target: any, _key: string, descriptor: PropertyDescriptor) => descriptor, +})) + +vi.mock('axios', () => ({ + default: { + create: vi.fn(), + }, +})) + +const mockAxiosCreate = vi.mocked(axios.create) + +describe('serviceChainService', () => { + let service: ServiceChainService + let config: ConfigurationService + + beforeEach(() => { + vi.clearAllMocks() + + config = { + openCdsUrl: 'https://opencds.example.com', + openCdsApiToken: 'test-token', + openCdsApiTlsRejectUnauthorized: true, + } as unknown as ConfigurationService + + service = new ServiceChainService(config) + }) + + function mockClient(response: any) { + const client = { get: vi.fn().mockResolvedValue(response), post: vi.fn().mockResolvedValue(response) } + mockAxiosCreate.mockReturnValue(client as any) + return client + } + + const uuid = '550e8400-e29b-41d4-a716-446655440000' + + const mockServiceChain = { + id: uuid, + state: 'opened', + commonName: 'test.example.com', + pai: 'test-pai', + network: 'RIE', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + } + + describe('list', () => { + it('should call GET /requests and parse response', async () => { + const client = mockClient({ data: [mockServiceChain] }) + + const result = await service.list() + + expect(client.get).toHaveBeenCalledWith('/requests') + expect(result).toHaveLength(1) + expect(result[0].id).toBe(uuid) + }) + }) + + describe('getDetails', () => { + it('should call GET /requests/:id and parse response', async () => { + const detailsData = { + ...mockServiceChain, + validationId: uuid, + validatedBy: null, + ref: null, + location: 'SIR', + targetAddress: '10.0.0.1', + projectId: uuid, + env: 'INT', + subjectAlternativeName: [], + redirect: false, + antivirus: null, + websocket: false, + ipWhiteList: [], + sslOutgoing: false, + } + const client = mockClient({ data: detailsData }) + + const result = await service.getDetails(uuid) + + expect(client.get).toHaveBeenCalledWith(`/requests/${uuid}`) + expect(result.id).toBe(uuid) + expect(result.location).toBe('SIR') + }) + }) + + describe('retry', () => { + it('should call POST /requests/:id/retry', async () => { + const client = mockClient({ data: null }) + + await service.retry(uuid) + + expect(client.post).toHaveBeenCalledWith(`/requests/${uuid}/retry`) + }) + }) + + describe('validate', () => { + it('should call POST /validate/:validationId', async () => { + const client = mockClient({ data: null }) + + await service.validate(uuid) + + expect(client.post).toHaveBeenCalledWith(`/validate/${uuid}`) + }) + }) + + describe('getFlows', () => { + it('should call GET /requests/:id/flows and parse response', async () => { + const flowDetails = { + state: 'success', + input: '{}', + output: '{}', + updatedAt: '2026-01-01T00:00:00.000Z', + } + const flowsData = { + reserve_ip: flowDetails, + create_cert: null, + call_exec: flowDetails, + activate_ip: flowDetails, + dns_request: flowDetails, + } + const client = mockClient({ data: flowsData }) + + const result = await service.getFlows(uuid) + + expect(client.get).toHaveBeenCalledWith(`/requests/${uuid}/flows`) + expect(result.reserve_ip.state).toBe('success') + expect(result.create_cert).toBeNull() + }) + }) + + describe('when OPENCDS_URL is not set', () => { + it('should throw an error', async () => { + config.openCdsUrl = undefined + service = new ServiceChainService(config) + + await expect(service.list()).rejects.toThrow('OpenCDS is disabled') + }) + }) +}) diff --git a/apps/server-nestjs/src/cpin-module/service-chain/service-chain.service.ts b/apps/server-nestjs/src/cpin-module/service-chain/service-chain.service.ts new file mode 100644 index 000000000..26ec7242f --- /dev/null +++ b/apps/server-nestjs/src/cpin-module/service-chain/service-chain.service.ts @@ -0,0 +1,65 @@ +import type { ServiceChain, ServiceChainDetails, ServiceChainFlows } from '@cpn-console/shared' +import type { AxiosInstance } from 'axios' +import type { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import https from 'node:https' +import { + ServiceChainDetailsSchema, + ServiceChainFlowsSchema, + ServiceChainListSchema, +} from '@cpn-console/shared' +import { Injectable } from '@nestjs/common' +import axios from 'axios' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' + +const openCdsDisabledMessage = 'OpenCDS is disabled, please set OPENCDS_URL in your relevant .env file. See .env-example' + +@Injectable() +export class ServiceChainService { + constructor(private readonly config: ConfigurationService) {} + + private getClient(): AxiosInstance { + if (!this.config.openCdsUrl) { + throw new Error(openCdsDisabledMessage) + } + return axios.create({ + baseURL: this.config.openCdsUrl, + httpsAgent: new https.Agent({ + rejectUnauthorized: this.config.openCdsApiTlsRejectUnauthorized, + }), + headers: { + 'X-API-Key': this.config.openCdsApiToken, + }, + }) + } + + @StartActiveSpan() + async list(): Promise { + return ServiceChainListSchema.parse( + (await this.getClient().get('/requests')).data, + ) + } + + @StartActiveSpan() + async getDetails(id: string): Promise { + return ServiceChainDetailsSchema.parse( + (await this.getClient().get(`/requests/${id}`)).data, + ) + } + + @StartActiveSpan() + async retry(id: string): Promise { + await this.getClient().post(`/requests/${id}/retry`) + } + + @StartActiveSpan() + async validate(validationId: string): Promise { + await this.getClient().post(`/validate/${validationId}`) + } + + @StartActiveSpan() + async getFlows(id: string): Promise { + return ServiceChainFlowsSchema.parse( + (await this.getClient().get(`/requests/${id}/flows`)).data, + ) + } +} diff --git a/apps/server-nestjs/src/prisma/schema/admin.prisma b/apps/server-nestjs/src/prisma/schema/admin.prisma index 71cfb1754..c4ca38ccb 100644 --- a/apps/server-nestjs/src/prisma/schema/admin.prisma +++ b/apps/server-nestjs/src/prisma/schema/admin.prisma @@ -12,6 +12,7 @@ model AdminRole { permissions BigInt position Int @db.SmallInt oidcGroup String @default("") + type String @default("managed") } model SystemSetting { From 446fbdf7fe25719f66e07287d17bb5a69c5c2da6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20TR=C3=89BEL=20=28Perso=29?= Date: Fri, 20 Mar 2026 16:27:18 +0100 Subject: [PATCH 2/4] chore(nginx-strangler): change service-chain routing from server to server-nestjs --- apps/nginx-strangler/conf.d/routing.conf | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/nginx-strangler/conf.d/routing.conf b/apps/nginx-strangler/conf.d/routing.conf index a5ffb7e09..48fdb7ec0 100644 --- a/apps/nginx-strangler/conf.d/routing.conf +++ b/apps/nginx-strangler/conf.d/routing.conf @@ -45,12 +45,26 @@ server { # proxy_set_header X-Forwarded-Proto $scheme; # } + # Healthcheck de nginx-strangler lui-même location = /health { access_log off; add_header 'Content-Type' 'application/json'; return 200 '{"status":"UP"}'; } + ################################################# + # BASCULE DES ROUTES LEGACY->NESTJS + # + # [Vague 1 - service-chains/opencds] 2026-03-20 + location /api/v1/service-chains { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # ── Fallback : tout le reste vers le server legacy ──────────────────────── location /api/ { proxy_pass http://server-legacy; From 6df1a4ba1f56c9e38177e83cd568b56a05b1a4b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20TR=C3=89BEL=20=28Perso=29?= Date: Fri, 20 Mar 2026 16:29:12 +0100 Subject: [PATCH 3/4] fix(server-nestjs): ensure shared is built before server-nestjs in Docker image --- apps/server-nestjs/Dockerfile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/server-nestjs/Dockerfile b/apps/server-nestjs/Dockerfile index 28b1c5926..81cf1c118 100644 --- a/apps/server-nestjs/Dockerfile +++ b/apps/server-nestjs/Dockerfile @@ -36,6 +36,12 @@ COPY --chown=node:root plugins/ ./plugins/ COPY --chown=node:root packages/ ./packages/ COPY --chown=node:root apps/server-nestjs/ ./apps/server-nestjs/ +# Build shared (nécessaire pour que les imports @cpn-console/shared fonctionnent) +RUN pnpm --filter @cpn-console/shared run build + +# Réinjecter shared buildé dans node_modules (injected workspace packages) +RUN pnpm --filter @cpn-console/server-nestjs install --frozen-lockfile + # Générer le client Prisma (schéma multi-fichiers : pointer sur le dossier) RUN pnpm --filter @cpn-console/server-nestjs exec prisma generate \ --schema=src/prisma/schema From 8f6bfdc3561035fcd8f5ac2080fca95bf0d3b809 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20TR=C3=89BEL=20=28Perso=29?= Date: Thu, 26 Mar 2026 16:17:55 +0100 Subject: [PATCH 4/4] docs(server-nestjs): update modularization documents --- .../00-OVERVIEW.md | 10 +- .../MODULARISATION-CARTOGRAPHIE.md | 145 ++++++++++-------- .../MODULARISATION-STATUT.md | 74 ++++++--- 3 files changed, 146 insertions(+), 83 deletions(-) diff --git a/apps/server-nestjs/documentation/Modularisation-de-console-server/00-OVERVIEW.md b/apps/server-nestjs/documentation/Modularisation-de-console-server/00-OVERVIEW.md index 908cbf1d0..f959090d3 100644 --- a/apps/server-nestjs/documentation/Modularisation-de-console-server/00-OVERVIEW.md +++ b/apps/server-nestjs/documentation/Modularisation-de-console-server/00-OVERVIEW.md @@ -175,6 +175,12 @@ Cette modularisation est documentée dans plusieurs fichiers : - **S10** : 80% migré (Plugins, comme ArgoCD, Gitlab, etc.) - **S12** : 100% migré +### Migrations anticipées +- **ServiceChain (OpenCDS)** : Migré le 2026-03-26, en avance sur le planning + initial (prévu V3/S8). Ce module isolé (proxy HTTP vers API externe, aucune + dépendance entrante) a servi de premier test de bout en bout du pipeline de + migration : module NestJS + AuthGuard + nginx-strangler + Docker. + ## 🗓️ Dates clés - **S1-S2** : Cartographie et setup @@ -202,6 +208,6 @@ Cette modularisation est documentée dans plusieurs fichiers : --- -**Version** : 1.0 -**Dernière mise à jour** : 2026-01-07 +**Version** : 1.1 +**Dernière mise à jour** : 2026-03-26 **Prochaine revue** : Fin S2 diff --git a/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md b/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md index e22dfc9ce..1d1ecf031 100644 --- a/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md +++ b/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md @@ -1,6 +1,6 @@ # Cartographie des modules - Modularisation Backend -> Derniere mise a jour : **2026-02-23** (Sprint 2 - Cartographie finalisee) +> Derniere mise a jour : **2026-03-26** (Migration ServiceChain finalisee) --- @@ -52,7 +52,7 @@ plus **5 couches transverses** et **7 plugins** a encapsuler. +--------------------------------------------------------------+ | Couche 4 : Evenements (EventEmitter, remplacement hooks) | A CREER +--------------------------------------------------------------+ -| Couche 3 : Securite (AuthGuard, PermissionsGuard, Filters) | A CREER +| Couche 3 : Securite (AuthGuard, PermissionsGuard, Filters) | PARTIEL +--------------------------------------------------------------+ | Couche 2 : Core (AppService, FastifyService) | FAIT +--------------------------------------------------------------+ @@ -60,9 +60,11 @@ plus **5 couches transverses** et **7 plugins** a encapsuler. +--------------------------------------------------------------+ ``` -Les couches 1 et 2 sont en place. Les couches 3 et 4 sont les **pre-requis -critiques** avant toute migration de module metier. C'est la priorite absolue -de la Vague 0. +Les couches 1 et 2 sont en place. La couche 3 est **partiellement** en place : +l'auth par token (`x-dso-token`) fonctionne via `AdminPermissionGuard` + +`AuthService`. Il reste a implementer l'auth par session Keycloak +(`@CurrentUser()`), le `PermissionsGuard` projet, et le `GlobalExceptionFilter`. +Les couches 4 et 5 restent a creer. --- @@ -74,18 +76,26 @@ qui suivent. Les creer en premier permet que chaque migration de module soit un exercice de "remplissage" des couches superieures, sans reinventer l'infrastructure a chaque fois. -### 0a. AuthGuard + decorateur @CurrentUser() +### 0a. AuthGuard + decorateur @CurrentUser() — PARTIEL **Sprint** : S3 (Dev A) **Remplace** : `authUser()` dans `apps/server/src/utils/controller.ts` -Fonctionnalites a porter : +**Etat actuel (2026-03-26)** : La partie auth par token est implementee dans +`infrastructure/auth/` : +- ✅ `AuthService` : validation token SHA256, lookup Prisma + (PersonalAccessToken + AdminToken), verification status/expiration, calcul + permissions bitwise (y compris roles globaux) +- ✅ `AdminPermissionGuard` : lit `x-dso-token`, verifie permissions via + `AdminAuthorized` de `@cpn-console/shared` +- ✅ `@RequireAdminPermission()` : decorateur de metadata pour les permissions admin + +**Reste a faire** : - Validation de session Keycloak (`req.session.user`) -- Fallback sur token PAT (header `x-dso-token`) +- Fallback session → token (actuellement token uniquement) - Chargement optionnel du projet (par slug, id, environmentId, repositoryId) - Injection du profil utilisateur dans la request via `@CurrentUser()` - -Types a definir : `UserProfile`, `UserProjectProfile`, `ProjectPermState` +- Types a definir : `UserProfile`, `UserProjectProfile`, `ProjectPermState` ### 0b. GlobalExceptionFilter @@ -164,36 +174,36 @@ Plus le score est eleve, plus le module est prioritaire. ### Resultats ordonnes par score -| Rang | Module | Type | Score | Vague | Sprint | -|------|--------|------|-------|-------|--------| -| 1 | vault (encapsulation) | Plugin | 8.5 | V3 | S7-S8 | -| 2 | system (health/version) | Metier | 7.8 | V1 | S3 | -| 3 | system/settings | Metier | 7.4 | V1 | S3 | -| 4 | system/config | Metier | 7.4 | V1 | S3-S4 | -| 5 | keycloak (encapsulation) | Plugin | 7.4 | V3 | S8 | -| 6 | admin-token | Metier | 7.1 | V1 | S3-S4 | -| 7 | user/tokens | Metier | 7.1 | V1 | S3-S4 | -| 8 | gitlab (encapsulation) | Plugin | 6.7 | V4 | S9 | -| 9 | service-monitor | Metier | 6.6 | V2 | S5 | -| 10 | user | Metier | 6.6 | V2 | S5 | -| 11 | stage | Metier | 6.5 | V2 | S5-S6 | -| 12 | log | Metier | 6.5 | V1 | S4 | -| 13 | zone | Metier | 6.4 | V2 | S6 | -| 14 | environment | Metier | 6.3 | V3 | S7 | -| 15 | admin-role | Metier | 6.1 | V2 | S5 | -| 16 | project-core | Metier | 5.8 | V4 | S9 | -| 17 | service-chain | Metier | 5.9 | V3 | S8 | -| 18 | repository | Metier | 5.8 | V3 | S7-S8 | -| 19 | cluster | Metier | 5.7 | V3 | S7 | -| 20 | harbor (encapsulation) | Plugin | 5.6 | V4 | S9-S10 | -| 21 | project-service | Metier | 5.6 | V3 | S8 | -| 22 | argocd (encapsulation) | Plugin | 5.3 | V5 | S10-S11 | -| 23 | project-role | Metier | 5.2 | V3 | S7-S8 | -| 24 | nexus (encapsulation) | Plugin | 5.1 | V4 | S10 | -| 25 | project-member | Metier | 4.7 | V3 | S8 | -| 26 | project-secrets | Metier | 4.6 | V4 | S9 | -| 27 | project-bulk | Metier | 4.2 | V4 | S9-S10 | -| 28 | sonarqube (encapsulation) | Plugin | 4.2 | V5 | S11 | +| Rang | Module | Type | Score | Vague | Sprint | Statut | +|------|--------|------|-------|-------|--------|--------| +| 1 | vault (encapsulation) | Plugin | 8.5 | V3 | S7-S8 | | +| 2 | system (health/version) | Metier | 7.8 | V1 | S3 | | +| 3 | system/settings | Metier | 7.4 | V1 | S3 | | +| 4 | system/config | Metier | 7.4 | V1 | S3-S4 | | +| 5 | keycloak (encapsulation) | Plugin | 7.4 | V3 | S8 | | +| 6 | admin-token | Metier | 7.1 | V1 | S3-S4 | | +| 7 | user/tokens | Metier | 7.1 | V1 | S3-S4 | | +| 8 | gitlab (encapsulation) | Plugin | 6.7 | V4 | S9 | | +| 9 | service-monitor | Metier | 6.6 | V2 | S5 | | +| 10 | user | Metier | 6.6 | V2 | S5 | | +| 11 | stage | Metier | 6.5 | V2 | S5-S6 | | +| 12 | log | Metier | 6.5 | V1 | S4 | | +| 13 | zone | Metier | 6.4 | V2 | S6 | | +| 14 | environment | Metier | 6.3 | V3 | S7 | | +| 15 | admin-role | Metier | 6.1 | V2 | S5 | | +| 16 | project-core | Metier | 5.8 | V4 | S9 | | +| 17 | service-chain | Metier | 5.9 | V3 | S8 | ✅ MIGRE | +| 18 | repository | Metier | 5.8 | V3 | S7-S8 | | +| 19 | cluster | Metier | 5.7 | V3 | S7 | | +| 20 | harbor (encapsulation) | Plugin | 5.6 | V4 | S9-S10 | | +| 21 | project-service | Metier | 5.6 | V3 | S8 | | +| 22 | argocd (encapsulation) | Plugin | 5.3 | V5 | S10-S11 | | +| 23 | project-role | Metier | 5.2 | V3 | S7-S8 | | +| 24 | nexus (encapsulation) | Plugin | 5.1 | V4 | S10 | | +| 25 | project-member | Metier | 4.7 | V3 | S8 | | +| 26 | project-secrets | Metier | 4.6 | V4 | S9 | | +| 27 | project-bulk | Metier | 4.2 | V4 | S9-S10 | | +| 28 | sonarqube (encapsulation) | Plugin | 4.2 | V5 | S11 | | **Note** : Le score brut ne dicte pas directement l'ordre de migration. L'ordre reel est contraint par le graphe de dependances (bottom-up), les @@ -707,34 +717,47 @@ NestJS injectables. --- -### 18. service-chain +### 18. service-chain — ✅ MIGRE (2026-03-26) | Attribut | Valeur | |----------|--------| | **Routes** | 5 | | **Score** | 5.9 | -| **Sprint** | S8 | -| **Dev** | B | - -**Routes** : -- `GET /api/v1/projects/:projectId/service-chains` - Liste des chaines de service -- `GET /api/v1/projects/:projectId/service-chains/:chainId` - Details d'une chaine -- `POST /api/v1/projects/:projectId/service-chains/:chainId/retry` - Relance d'une chaine -- `POST /api/v1/projects/:projectId/service-chains/:chainId/validate` - Validation -- `GET /api/v1/projects/:projectId/service-chains/:chainId/flows` - Flux de la chaine - -**Dependances sortantes** : `queries-index` (queries propres), API externe OpenCDS (HTTP via axios) +| **Sprint prevu** | S8 | +| **Migration effective** | 2026-03-26 (en avance de phase) | +| **Dev** | @stephane.trebel | + +**Routes (implementation reelle)** : +- `GET /api/v1/service-chains` - Liste des chaines de service +- `GET /api/v1/service-chains/:id` - Details d'une chaine +- `GET /api/v1/service-chains/:id/flows` - Flux de la chaine +- `POST /api/v1/service-chains/:id/retry` - Relance d'une chaine +- `POST /api/v1/service-chains/validate/:id` - Validation + +> **Note** : Les routes n'ont pas de scope projet (`/projects/:projectId/`), +> contrairement a ce qui etait initialement documente. Le module est un proxy +> admin vers l'API OpenCDS, pas un module lie a un projet specifique. + +**Dependances sortantes** : API externe OpenCDS (HTTP via axios) **Dependances entrantes** : Aucune -**Points d'attention** : -- Module isole du reste du codebase. Appelle une API externe (OpenCDS) via HTTP -- Pas de hooks -- Utilise axios directement : a remplacer par HttpClientService (HttpModule NestJS) -- Bon candidat pour une extraction en module NestJS optionnel (ThirdPartyModule) - comme envisage dans le README de server-nestjs -- Migrable a tout moment, place ici par opportunite - -**Estimation** : 1.5 jours +**Implementation** : +- Module place dans `ThirdPartyModules` (module optionnel CPin) comme envisage +- Auth par token uniquement (`x-dso-token`) via `AdminPermissionGuard` +- Validation UUID sur les parametres d'URL (`ParseUUIDPipe`) +- Validation des reponses OpenCDS via schemas Zod de `@cpn-console/shared` +- Telemetrie via `@StartActiveSpan()` (OpenTelemetry) + +**Differences avec le legacy** : +- 403 systematique si permissions insuffisantes (le legacy renvoyait `[]` sur GET /) +- Pas d'auth session Keycloak (token uniquement) +- Validation UUID stricte (400 si format invalide) + +**Fichiers** : +- `src/cpin-module/service-chain/service-chain.module.ts` +- `src/cpin-module/service-chain/service-chain.controller.ts` +- `src/cpin-module/service-chain/service-chain.service.ts` +- `src/cpin-module/infrastructure/auth/` (AuthModule partage) --- diff --git a/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md b/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md index f6aa36b8c..88e35ed3f 100644 --- a/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md +++ b/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md @@ -1,15 +1,15 @@ # État de la modularisation Backend → NestJS > 📋 **Ce fichier est mis à jour en temps réel** -> Dernière mise à jour : **2026-01-07** (Sprint 0 de la modularisation) +> Dernière mise à jour : **2026-03-26** --- ## 🎯 Progression globale -![Progress](https://progress-bar.dev/0/?title=modularisation&width=400) +![Progress](https://progress-bar.dev/7/?title=modularisation&width=400) -**0%** complété (0/xxx modules migrés, xxx à déterminer) +**~7%** complété (1/18 modules métier migrés, 5/75 routes) --- @@ -17,18 +17,52 @@ | Statut | Nombre de modules | % du total | |--------|-------------------|------------| -| ✅ Migré | 0 | 0% | +| ✅ Migré | 1 (ServiceChain) | ~6% | | 🚧 En cours | 0 | 0% | -| 📅 Planifié | 0 | 0% | -| ⏳ En attente de cartographie | xxx | 100% | +| 📅 Planifié | 17 | ~94% | +| ⏳ En attente de cartographie | 0 | 0% | --- ## ✅ Modules migrés -### Aucun module migré pour le moment +### ServiceChain (OpenCDS) — migré le 2026-03-26 -La modularisation commencera en sprint 3 (S3-S4) avec le module **Auth**. +Module proxy HTTP vers l'API externe OpenCDS (gestion des chaînes de service +réseau). Migré en avance de phase par rapport au planning initial (prévu V3/S8), +profitant de son isolement complet vis-à-vis du reste du codebase. + +- **Routes** : 5 (`/api/v1/service-chains/...`) +- **Auth** : Token uniquement (`x-dso-token`), pas de session Keycloak +- **Nginx** : Bascule effectuée dans `nginx-strangler/conf.d/routing.conf` +- **Tests** : Controller + Service couverts (Vitest) +- **Différences avec le legacy** : + - 403 systématique si permissions insuffisantes (le legacy renvoyait `[]` sur GET /) + - Validation UUID sur les paramètres d'URL (400 si format invalide) + +| Méthode | Route | Permission | +|---------|-------|------------| +| `GET` | `/api/v1/service-chains` | `ListSystem` | +| `GET` | `/api/v1/service-chains/:id` | `ListSystem` | +| `GET` | `/api/v1/service-chains/:id/flows` | `ListSystem` | +| `POST` | `/api/v1/service-chains/:id/retry` | `ManageSystem` | +| `POST` | `/api/v1/service-chains/validate/:id` | `ManageSystem` | + +### Infrastructure transverse déployée + +En support de cette migration, les éléments d'infrastructure suivants ont été +créés : + +- **AuthModule** (`infrastructure/auth/`) : `AuthService` (validation token + SHA256 via Prisma) + `AdminPermissionGuard` + décorateur + `@RequireAdminPermission()` +- **Nginx strangler** : Reverse proxy configuré pour router les routes migrées + vers server-nestjs, le reste vers le legacy +- **Docker** : Build order corrigé (shared avant server-nestjs) + +> **Limitation connue** : Seule l'auth par token (`x-dso-token`) est supportée. +> L'auth par session Keycloak (`@CurrentUser()`) sera ajoutée lors de la +> migration de la couche auth complète (Couche 0a de la cartographie). --- @@ -36,14 +70,6 @@ La modularisation commencera en sprint 3 (S3-S4) avec le module **Auth**. ### Aucune modularisation en cours -**Sprint actuel** : S1-S2 (Fondations et cartographie) - -**Objectifs S1-S2** : -- Cartographie complète de l'existant -- Setup de l'infrastructure (Docker Compose + Nginx) -- Formation de l'équipe -- Validation de l'approche - --- ## 📅 Modules planifiés @@ -109,10 +135,10 @@ La modularisation commencera en sprint 3 (S3-S4) avec le module **Auth**. ### Routes par statut -- **Total** : ~100 routes -- **Migrés** : 0 (0%) +- **Total** : ~75 routes métier +- **Migrés** : 5 (~7%) - **En cours** : 0 (0%) -- **Restants** : ~100 (100%) +- **Restants** : ~70 (~93%) --- @@ -125,6 +151,7 @@ La modularisation commencera en sprint 3 (S3-S4) avec le module **Auth**. | 27/01/2026 | Début modularisation Auth (S3) | | 09/02/2026 | Fin modularisation Auth (S4) - 20% complété | | 09/03/2026 | Point mi-parcours - 60% complété | +| 26/03/2026 | Migration ServiceChain (OpenCDS) finalisée — 1er module métier migré | | 06/04/2026 | Fin de modularisation - 100% complété | --- @@ -157,6 +184,13 @@ La modularisation commencera en sprint 3 (S3-S4) avec le module **Auth**. ## 🔄 Historique des changements +### 2026-03-26 +- ✅ Migration du module **ServiceChain (OpenCDS)** — 5 routes, proxy HTTP vers API externe +- ✅ Création de l'**AuthModule** (infrastructure/auth/) : auth par token `x-dso-token` +- ✅ Configuration **nginx-strangler** pour les routes service-chain +- ✅ Fix Docker : build order shared → server-nestjs +- ✅ Mise à jour de ce fichier de suivi + ### 2026-01-07 (S1) - ✅ Création du fichier de suivi - ✅ Initialisation de la documentation @@ -172,5 +206,5 @@ La modularisation commencera en sprint 3 (S3-S4) avec le module **Auth**. --- -**Version du fichier** : 1.0 +**Version du fichier** : 1.1 **Responsable de mise à jour** : Lead technique backend