Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/nginx-strangler/conf.d/routing.conf
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ server {
# proxy_set_header X-Forwarded-Proto $scheme;
# }

# [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;
Expand Down
8 changes: 8 additions & 0 deletions apps/server-nestjs/.env.docker-example
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions apps/server-nestjs/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 101 additions & 0 deletions apps/server-nestjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/server-nestjs/src/cpin-module/cpin.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -12,6 +13,7 @@ import { InfrastructureModule } from './infrastructure/infrastructure.module'
ApplicationInitializationModule,
CoreModule,
InfrastructureModule,
ServiceChainModule,
],
})
export class CpinModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ServiceChainController } from './service-chain.controller'
import type { ServiceChainService } from './service-chain.service'

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)
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Controller, Get, HttpCode, Param, ParseUUIDPipe, Post, UseGuards } from '@nestjs/common'
import { AdminPermissionGuard } from '../infrastructure/auth/admin-permission.guard'
import { RequireAdminPermission } from '../infrastructure/auth/admin-permission.decorator'
import { ServiceChainService } from './service-chain.service'

@Controller('api/v1/service-chains')
export class ServiceChainController {
constructor(private readonly serviceChainService: ServiceChainService) {}

// validate/ MUST be declared BEFORE :serviceChainId to avoid routing ambiguity
@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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common'
import { ConfigurationModule } from '../infrastructure/configuration/configuration.module'
import { AuthModule } from '../infrastructure/auth/auth.module'
import { ServiceChainController } from './service-chain.controller'
import { ServiceChainService } from './service-chain.service'

@Module({
imports: [ConfigurationModule, AuthModule],
controllers: [ServiceChainController],
providers: [ServiceChainService],
})
export class ServiceChainModule {}
Loading
Loading