From 5573c94e0a36b8a64e6bee2eb80336e3aee9cfb2 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 11 Jun 2026 16:36:57 +0200 Subject: [PATCH 1/2] refactor(admin-roles): migrate from server Signed-off-by: William Phetsinorath Change-Id: I6e2bf93ddac464c88a82683dc6e6b0846a6a6964 Signed-off-by: William Phetsinorath --- apps/nginx-strangler/conf.d/routing.conf | 9 + .../MODULARISATION-CARTOGRAPHIE.md | 8 +- .../MODULARISATION-STATUT.md | 138 +++++++- apps/server-nestjs/src/main.module.ts | 2 + .../admin-role/admin-role.controller.ts | 58 ++++ .../modules/admin-role/admin-role.module.ts | 13 + .../admin-role/admin-role.service.spec.ts | 308 ++++++++++++++++++ .../modules/admin-role/admin-role.service.ts | 225 +++++++++++++ .../modules/admin-role/admin-role.utils.ts | 17 + 9 files changed, 757 insertions(+), 21 deletions(-) create mode 100644 apps/server-nestjs/src/modules/admin-role/admin-role.controller.ts create mode 100644 apps/server-nestjs/src/modules/admin-role/admin-role.module.ts create mode 100644 apps/server-nestjs/src/modules/admin-role/admin-role.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/admin-role/admin-role.service.ts create mode 100644 apps/server-nestjs/src/modules/admin-role/admin-role.utils.ts diff --git a/apps/nginx-strangler/conf.d/routing.conf b/apps/nginx-strangler/conf.d/routing.conf index 1245ffdc4c..c5596981e0 100644 --- a/apps/nginx-strangler/conf.d/routing.conf +++ b/apps/nginx-strangler/conf.d/routing.conf @@ -79,6 +79,15 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + location /api/v1/admin/roles { + 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; + } + # ── Routes par défaut (pour l'instant dans le legacy) ───────────────────────── location /api/ { proxy_pass http://server-legacy; 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 d9c3066263..f4f8e083e1 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 @@ -427,14 +427,14 @@ et `user` sont des pre-requis pour la Vague 3 (cluster, project-member). - `GET /api/v1/admin/users` - Liste complete des utilisateurs (admin) - `PATCH /api/v1/admin/users` - Modification utilisateurs (admin) -**Dependances sortantes** : `queries-index` (getMatchingUsers, getUsers), hooks (adminRole.upsert) +**Dependances sortantes** : `queries-index` (getMatchingUsers, getUsers), EventEmitter (`adminRole.upsert`) **Dependances entrantes** : `project-member` (importe `logViaSession`), `utils/controller.ts` (importe `logViaSession`, `logViaToken`) **Points d'attention** : - Module critique : `logViaSession` et `logViaToken` sont consommes par le systeme d'auth. Ces fonctions auront ete portees dans l'AuthGuard (Couche 0), donc la dependance entrante est deja resolue -- Hooks utilises (adminRole.upsert) : necessite l'EventEmitter +- Evenement utilise (`adminRole.upsert`) : necessite `@nestjs/event-emitter` - Pre-requis pour `project-member` (Vague 3) **Estimation** : 2 jours @@ -457,11 +457,11 @@ et `user` sont des pre-requis pour la Vague 3 (cluster, project-member). - `GET /api/v1/admin/roles/member-counts` - Comptage des membres par role - `DELETE /api/v1/admin/roles/:roleId` - Suppression d'un role -**Dependances sortantes** : `queries-index` (getAdminRoleById, listAdminRoles), hooks (adminRole.upsert, adminRole.delete) +**Dependances sortantes** : `queries-index` (getAdminRoleById, listAdminRoles), EventEmitter (`adminRole.upsert`, `adminRole.delete`) **Dependances entrantes** : Aucune **Points d'attention** : -- Valide le pattern hooks + EventEmitter dans un contexte admin +- Valide le pattern EventEmitter dans un contexte admin - Permissions admin (bitmask) : valide `UserGuard` - Queries a internaliser dans le module 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 5d36a235df..cf6c91d242 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 @@ -7,9 +7,9 @@ ## 🎯 Progression globale -![Progress](https://progress-bar.dev/7/?title=modularisation&width=400) +![Progress](https://progress-bar.dev/24/?title=modularisation&width=400) -**~7%** complété (1/18 modules métier migrés, 5/75 routes) +**~24%** complété (4/18 modules métier migrés, 21/75 routes) --- @@ -17,9 +17,9 @@ | Statut | Nombre de modules | % du total | |--------|-------------------|------------| -| ✅ Migré | 1 (ServiceChain) | ~6% | +| ✅ Migré | 4 (ServiceChain, Project, ProjectMembers, AdminRole) | ~22% | | 🚧 En cours | 0 | 0% | -| 📅 Planifié | 17 | ~94% | +| 📅 Planifié | 14 | ~78% | | ⏳ En attente de cartographie | 0 | 0% | --- @@ -48,17 +48,107 @@ profitant de son isolement complet vis-à-vis du reste du codebase. | `POST` | `/api/v1/service-chains/:id/retry` | `ManageSystem` | | `POST` | `/api/v1/service-chains/validate/:id` | `ManageSystem` | +### Project — migré le 2026-05-28 + +Module cœur de gestion des projets (CRUD, secrets, bulk actions). +Regroupe les sous-modules `project-core`, `project-secrets` et `project-bulk` +découpés dans la cartographie Vague 4, implémentés ici en un seul module +unifié pour accélérer la migration. + +- **Routes** : 7 (`/api/v1/projects/...`) +- **Auth** : Token (`x-dso-token`) + guards projet (`ProjectContextGuard`, `ProjectStatusGuard`, `ProjectLockedGuard`) +- **Validation** : Contrats Zod via `projectContract` de `@cpn-console/shared` avec `ZodValidationPipe` +- **Tests** : Controller + Service couverts (Vitest) + +| Méthode | Route | Permission | +|---------|-------|------------| +| `GET` | `/api/v1/projects/data` | `Manage` (admin) | +| `GET` | `/api/v1/projects` | Authentifié | +| `POST` | `/api/v1/projects` | `ManageProjects` (admin) + type `human` | +| `POST` | `/api/v1/projects-bulk` | `Manage` (admin) | +| `GET` | `/api/v1/projects/:projectId` | Authentifié + projet | +| `GET` | `/api/v1/projects/:projectId/secrets` | Authentifié + projet (statut ≠ archived) | +| `PUT` | `/api/v1/projects/:projectId` | Authentifié + projet (statut ≠ archived) | + +**Infrastructure créée/mise à jour** : +- `ProjectContextGuard` + `Project` decorator : chargement du projet par id/slug, résolution des permissions via bitmask +- `ProjectStatusGuard` + `@RequireProjectStatus()` : filtrage par statut du projet +- `ProjectLockedGuard` : protection contre les modifications de projets verrouillés +- `UserGuard` + `User` decorator : authentification token + injection du contexte utilisateur + +**Différences avec le legacy** : +- Utilisation de `projectContract` de `@cpn-console/shared` pour les schemas de validation (cohérence client/serveur) +- ZodValidationPipe au lieu de la validation ts-rest implicite +- Les routes `secrets` et `bulk` sont fusionnées dans le module Project (pas de sous-modules séparés) +- Pas de `DELETE /api/v1/projects/:projectId` (archivage) dans cette version initiale + +### ProjectMembers — migré le 2026-05-28 + +Module de gestion des membres projet (ajout, modification, suppression, liste). +Dédié aux routes `/api/v1/projects/:projectId/members/...`. + +- **Routes** : 4 (`/api/v1/projects/:projectId/members/...`) +- **Auth** : Token (`x-dso-token`) + guards projet (`ProjectContextGuard`, `ProjectStatusGuard`, `ProjectLockedGuard`) +- **Validation** : Contrats Zod via `projectMemberContract` de `@cpn-console/shared` + +| Méthode | Route | Permission | +|---------|-------|------------| +| `GET` | `/api/v1/projects/:projectId/members` | `ListMembers` (admin) | +| `POST` | `/api/v1/projects/:projectId/members` | `ManageMembers` (admin) + statut non archivé | +| `PATCH` | `/api/v1/projects/:projectId/members` | `ManageMembers` (admin) + statut non archivé | +| `DELETE` | `/api/v1/projects/:projectId/members/:userId` | `ManageMembers` (admin) ou auto-suppression + statut non archivé | + +**Fichiers** : +- `src/modules/project/project-members.controller.ts` +- Méthodes ajoutées dans `src/modules/project/project.service.ts` + +**Différences avec le legacy** : +- Recherche par email non disponible (nécessite le hook Keycloak `user.retrieveUserByEmail`, non migré) +- Retour de la liste complète des membres après chaque mutation (cohérence avec le legacy) +- Événements `projectMember.upsert` et `projectMember.delete` via EventEmitter (remplacement du hook `projectMember.upsert`/`projectMember.delete`) + +### AdminRole — migré le 2026-06-12 + +Module de gestion des rôles administrateurs (CRUD + tri + comptage membres). +Dédié aux routes `/api/v1/admin/roles/...`. + +- **Routes** : 5 (`/api/v1/admin/roles/...`) +- **Auth** : Guards admin (`AdminGuard`) + décorateur `@RequireAdminPermission()` +- **Validation** : Contrats Zod via `adminRoleContract` de `@cpn-console/shared` avec `ZodValidationPipe` +- **Tests** : Controller + Service couverts (Vitest) + +| Méthode | Route | Permission | +|---------|-------|------------| +| `GET` | `/api/v1/admin/roles` | `ListRoles` | +| `POST` | `/api/v1/admin/roles` | `ManageRoles` | +| `PATCH` | `/api/v1/admin/roles` | `ManageRoles` | +| `GET` | `/api/v1/admin/roles/member-counts` | `ManageRoles` | +| `DELETE` | `/api/v1/admin/roles/:roleId` | `ManageRoles` | + +**Infrastructure réutilisée** : +- `AdminGuard` + `@RequireAdminPermission()` : validation des permissions admin +- `AdminService` : validation bitmask + typage utilisateur + +**Fichiers** : +- `src/modules/admin-role/admin-role.controller.ts` +- `src/modules/admin-role/admin-role.service.ts` +- `src/modules/admin-role/admin-role.utils.ts` + +**Différences avec le legacy** : +- Déport de la logique de permissions côté NestJS plutôt que dans le legacy Fastify +- Comptage des membres via query Prisma dédiée au lieu d'agrégat + ### Infrastructure transverse déployée -En support de cette migration, les éléments d'infrastructure suivants ont été +En support de ces migrations, les éléments d'infrastructure suivants ont été créés : - **AuthModule** (`infrastructure/auth/`) : `AuthService` (validation token - SHA256 via Prisma + bearer JWT Keycloak), `DsoTokenModule`, - `KeycloakJwtModule` et décorateur `@AuthUser()` -- **PermissionModule** (`infrastructure/permission/`) : `UserModule` - (`UserGuard`, `UserService`, `UserPolicy`) + `ProjectModule` - (`ProjectGuard`, `ProjectLoaderService`, `ProjectService`, `ProjectPolicy`) + SHA256 via Prisma) + `AdminPermissionGuard` + décorateur + `@RequireAdminPermission()` +- **Guards projet** (`infrastructure/auth/`) : `ProjectContextGuard`, + `ProjectStatusGuard`, `ProjectLockedGuard` + décorateurs `@Project()` et + `@RequireProjectStatus()` - **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) @@ -78,7 +168,7 @@ créés : ## 📅 Modules planifiés -> Ces informations seront affinées après la cartographie (fin S2) +> Ces informations seront affinées après la cartographie (fin S2). ### Sprint 3-4 (27 janvier - 9 février) - **Module** : Auth @@ -140,9 +230,9 @@ créés : ### Routes par statut - **Total** : ~75 routes métier -- **Migrés** : 5 (~7%) +- **Migrés** : 21 (~28%) - **En cours** : 0 (0%) -- **Restants** : ~70 (~93%) +- **Restants** : ~54 (~72%) --- @@ -154,8 +244,9 @@ créés : | 26/01/2026 | Fin de la cartographie (S2) | | 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é | +| 28/05/2026 | Migration du module **Project** (7 routes) — CRUD + secrets + bulk | +| 28/05/2026 | Migration du module **ProjectMembers** (4 routes) — membres projet | +| 12/06/2026 | Migration du module **AdminRole** (5 routes) — gestion des rôles admin | | 06/04/2026 | Fin de modularisation - 100% complété | --- @@ -163,7 +254,7 @@ créés : ## 📞 Contacts & Communication ### Canaux -- **Slack** : #backend-modularisation +- **Mattermost** : #backend-modularisation - **Meeting hebdo** : Vendredi 16h (30min) - **Lead technique** : @stephane.trebel @@ -188,6 +279,19 @@ créés : ## 🔄 Historique des changements +### 2026-06-12 +- ✅ Migration du module **AdminRole** — 5 routes : gestion des rôles admin (list, create, patch, member-counts, delete) +- ✅ Mise à jour du contrôleur pour utiliser `AdminGuard` + `@RequireAdminPermission()` +- ✅ Création des utilitaires de mapping `AdminRole` Prisma → `@cpn-console/shared` + +### 2026-05-28 +- ✅ Migration du module **Project** — 7 routes : CRUD projets, secrets, bulk actions, export CSV +- ✅ Migration du module **ProjectMembers** — 4 routes : membres projet (list, add, patch, delete) +- ✅ Création des guards projet : `ProjectContextGuard`, `ProjectStatusGuard`, `ProjectLockedGuard` +- ✅ Création des décorateurs `@Project()` et `@RequireProjectStatus()` +- ✅ Utilisation de `projectContract` de `@cpn-console/shared` pour la validation Zod +- ✅ Build et lint verts (server-nestjs) + ### 2026-04-09 - ✅ 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` @@ -210,5 +314,5 @@ créés : --- -**Version du fichier** : 1.1 +**Version du fichier** : 1.2 **Responsable de mise à jour** : Lead technique backend diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index a0dc901c88..1f993fb29d 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common' import { EventEmitterModule } from '@nestjs/event-emitter' import { ScheduleModule } from '@nestjs/schedule' +import { AdminRoleModule } from './modules/admin-role/admin-role.module' import { DeploymentModule } from './modules/deployment/deployment.module' import { HealthzModule } from './modules/healthz/healthz.module' import { KeycloakModule } from './modules/keycloak/keycloak.module' @@ -18,6 +19,7 @@ import { VersionModule } from './modules/version/version.module' SystemSettingsModule, ServiceChainModule, ProjectModule, + AdminRoleModule, DeploymentModule, VersionModule, ], diff --git a/apps/server-nestjs/src/modules/admin-role/admin-role.controller.ts b/apps/server-nestjs/src/modules/admin-role/admin-role.controller.ts new file mode 100644 index 0000000000..7ebb8f8b9b --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-role/admin-role.controller.ts @@ -0,0 +1,58 @@ +import type { AdminRole } from '@cpn-console/shared' +import { adminRoleContract } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, Inject, Param, ParseUUIDPipe, Patch, Post, UseGuards } from '@nestjs/common' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { AdminRoleService } from './admin-role.service' + +@Controller('api/v1/admin/roles') +export class AdminRoleController { + constructor( + @Inject(AdminRoleService) private readonly adminRoleService: AdminRoleService, + ) {} + + @Get('') + @UseGuards(UserGuard) + @RequireAdminPermission('ListRoles') + async listAdminRoles(): Promise { + return this.adminRoleService.list() + } + + @Post('') + @HttpCode(201) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageRoles') + async createAdminRole( + @Body(new ZodValidationPipe(adminRoleContract.createAdminRole.body)) body: typeof adminRoleContract.createAdminRole.body._type, + ): Promise { + return this.adminRoleService.create(body) + } + + @Patch('') + @HttpCode(200) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageRoles') + async patchAdminRoles( + @Body(new ZodValidationPipe(adminRoleContract.patchAdminRoles.body)) body: typeof adminRoleContract.patchAdminRoles.body._type, + ): Promise { + return this.adminRoleService.patch(body) + } + + @Get('member-counts') + @UseGuards(UserGuard) + @RequireAdminPermission('ManageRoles') + async adminRoleMemberCounts(): Promise> { + return this.adminRoleService.memberCounts() + } + + @Delete(':roleId') + @HttpCode(204) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageRoles') + async deleteAdminRole( + @Param('roleId', ParseUUIDPipe) roleId: string, + ): Promise { + await this.adminRoleService.delete(roleId) + } +} diff --git a/apps/server-nestjs/src/modules/admin-role/admin-role.module.ts b/apps/server-nestjs/src/modules/admin-role/admin-role.module.ts new file mode 100644 index 0000000000..099f69d651 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-role/admin-role.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { AdminRoleController } from './admin-role.controller' +import { AdminRoleService } from './admin-role.service' + +@Module({ + imports: [InfrastructureModule, AuthModule], + controllers: [AdminRoleController], + providers: [AdminRoleService], + exports: [AdminRoleService], +}) +export class AdminRoleModule {} diff --git a/apps/server-nestjs/src/modules/admin-role/admin-role.service.spec.ts b/apps/server-nestjs/src/modules/admin-role/admin-role.service.spec.ts new file mode 100644 index 0000000000..7a8918ffb2 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-role/admin-role.service.spec.ts @@ -0,0 +1,308 @@ +import type { adminRoleContract } from '@cpn-console/shared' +import type { EventEmitter2 } from '@nestjs/event-emitter' +import type { AdminRole as PrismaAdminRole, User } from '@prisma/client' +import type { PrismaService } from '../infrastructure/database/prisma.service' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AdminRoleService } from './admin-role.service' + +describe('adminRoleService', () => { + let prisma: ReturnType> + let eventEmitter: ReturnType> + let service: AdminRoleService + + beforeEach(() => { + vi.clearAllMocks() + prisma = mockDeep() + eventEmitter = mockDeep() + eventEmitter.emitAsync.mockResolvedValue([]) + service = new AdminRoleService(prisma, eventEmitter) + }) + + it('lists roles with string permissions', async () => { + const roles = [{ + id: faker.string.uuid(), + name: 'Role A', + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + }] as const satisfies PrismaAdminRole[] + + prisma.adminRole.findMany.mockResolvedValue(roles) + + await expect(service.list()).resolves.toEqual([ + expect.objectContaining({ + id: roles[0].id, + permissions: '4', + type: 'managed', + }), + ]) + }) + + it('creates a role at the next position and returns the updated list', async () => { + const existingRole = { + id: faker.string.uuid(), + name: 'Role A', + permissions: 4n, + position: 5, + oidcGroup: '', + type: 'managed', + } as const satisfies PrismaAdminRole + + prisma.$transaction.mockImplementation(async callback => callback(prisma)) + prisma.adminRole.findFirst.mockResolvedValue(existingRole) + prisma.adminRole.findMany.mockResolvedValue([existingRole]) + prisma.adminRole.create.mockResolvedValue(existingRole) + prisma.adminRole.findUnique.mockResolvedValue(existingRole) + prisma.user.findMany.mockResolvedValue([]) + + await expect(service.create({ name: 'New role' })).resolves.toEqual([ + expect.objectContaining({ + id: existingRole.id, + permissions: '4', + position: 5, + }), + ]) + + expect(prisma.adminRole.create).toHaveBeenCalledWith({ + data: { + name: 'New role', + permissions: 0n, + position: 6, + }, + }) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('adminRole.upsert', { + ...existingRole, + members: [], + }) + }) + + it('patches roles and normalizes permissions', async () => { + const dbRoles = [{ + id: faker.string.uuid(), + name: 'Role A', + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + }, { + id: faker.string.uuid(), + name: 'Role B', + permissions: 8n, + position: 1, + oidcGroup: '', + type: 'managed', + }] as const satisfies PrismaAdminRole[] + + prisma.$transaction.mockImplementation(async callback => callback(prisma)) + + const updatedRoles = [{ + ...dbRoles[0], + name: 'Updated role', + permissions: 16n, + position: 1, + }, { + ...dbRoles[1], + position: 0, + }] as const satisfies PrismaAdminRole[] + + prisma.adminRole.findMany + .mockResolvedValueOnce(dbRoles) + .mockResolvedValueOnce(updatedRoles) + prisma.adminRole.findUnique + .mockResolvedValueOnce(updatedRoles[0]) + .mockResolvedValueOnce(updatedRoles[1]) + prisma.user.findMany.mockResolvedValue([]) + + const updateRoles = [{ + id: dbRoles[0].id, + name: 'Updated role', + permissions: '16', + position: 1, + type: 'managed', + }, { + id: dbRoles[1].id, + position: 0, + }] satisfies typeof adminRoleContract.patchAdminRoles.body._type + + await expect(service.patch(updateRoles)).resolves.toEqual([ + expect.objectContaining({ + id: dbRoles[0].id, + permissions: '16', + position: 1, + }), + expect.objectContaining({ + id: dbRoles[1].id, + permissions: '8', + position: 0, + }), + ]) + + expect(prisma.adminRole.update).toHaveBeenNthCalledWith(1, { + where: { id: dbRoles[0].id }, + data: { + name: 'Updated role', + permissions: 16n, + position: 1, + oidcGroup: '', + type: 'managed', + }, + }) + expect(prisma.adminRole.update).toHaveBeenNthCalledWith(2, { + where: { id: dbRoles[1].id }, + data: { + name: 'Role B', + permissions: 8n, + position: 0, + oidcGroup: '', + type: 'managed', + }, + }) + expect(eventEmitter.emitAsync).toHaveBeenNthCalledWith(1, 'adminRole.upsert', { + ...updatedRoles[0], + members: [], + }) + expect(eventEmitter.emitAsync).toHaveBeenNthCalledWith(2, 'adminRole.upsert', { + ...updatedRoles[1], + members: [], + }) + }) + + it('rejects incoherent positions', async () => { + const dbRoles = [{ + id: faker.string.uuid(), + name: 'Role A', + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + }, { + id: faker.string.uuid(), + name: 'Role B', + permissions: 8n, + position: 1, + oidcGroup: '', + type: 'managed', + }] as const satisfies PrismaAdminRole[] + + prisma.adminRole.findMany.mockResolvedValue(dbRoles) + + await expect(service.patch([ + { id: dbRoles[0].id, position: 1 }, + ] satisfies typeof adminRoleContract.patchAdminRoles.body._type)).rejects.toThrow('Les numéros de position des rôles sont incohérentes') + }) + + it('counts member references for unscoped roles only', async () => { + const roles = [{ + id: 'role-a', + name: 'Role A', + permissions: 1n, + position: 0, + oidcGroup: '', + type: 'managed', + }, { + id: 'role-b', + name: 'Role B', + permissions: 2n, + position: 1, + oidcGroup: '', + type: 'managed', + }] as const satisfies PrismaAdminRole[] + + const users = [{ + id: faker.string.uuid(), + type: 'human', + firstName: 'A', + lastName: 'B', + email: 'a@example.com', + adminRoleIds: ['role-a', 'role-b'], + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-02T00:00:00.000Z'), + lastLogin: new Date('2024-01-03T00:00:00.000Z'), + }, { + id: faker.string.uuid(), + type: 'human', + firstName: 'C', + lastName: 'D', + email: 'c@example.com', + adminRoleIds: ['role-b'], + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-02T00:00:00.000Z'), + lastLogin: new Date('2024-01-03T00:00:00.000Z'), + }] as const satisfies User[] + + prisma.adminRole.findMany.mockResolvedValue(roles) + prisma.user.findMany.mockResolvedValue(users) + + await expect(service.memberCounts()).resolves.toEqual({ + 'role-a': 1, + 'role-b': 2, + }) + }) + + it('removes the deleted role from users before deleting it', async () => { + const roleId = faker.string.uuid() + const users = [{ + id: faker.string.uuid(), + type: 'human', + firstName: 'A', + lastName: 'B', + email: 'a@example.com', + adminRoleIds: [roleId], + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-02T00:00:00.000Z'), + lastLogin: new Date('2024-01-03T00:00:00.000Z'), + }, { + id: faker.string.uuid(), + type: 'human', + firstName: 'C', + lastName: 'D', + email: 'c@example.com', + adminRoleIds: [roleId, faker.string.uuid()], + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-02T00:00:00.000Z'), + lastLogin: new Date('2024-01-03T00:00:00.000Z'), + }] as const satisfies User[] + + prisma.$transaction.mockImplementation(async callback => callback(prisma)) + prisma.adminRole.findUnique.mockResolvedValue({ + id: roleId, + name: 'Role', + permissions: 0n, + position: 0, + oidcGroup: '', + type: 'managed', + }) + prisma.user.findMany + .mockResolvedValueOnce(users) + .mockResolvedValueOnce(users) + + await expect(service.delete(roleId)).resolves.toBeUndefined() + + expect(prisma.user.update).toHaveBeenNthCalledWith(1, { + where: { id: users[0].id }, + data: { adminRoleIds: [] }, + }) + expect(prisma.user.update).toHaveBeenNthCalledWith(2, { + where: { id: users[1].id }, + data: { adminRoleIds: [users[1].adminRoleIds[1]] }, + }) + expect(prisma.adminRole.delete).toHaveBeenCalledWith({ where: { id: roleId } }) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('adminRole.delete', { + id: roleId, + name: 'Role', + permissions: 0n, + position: 0, + oidcGroup: '', + type: 'managed', + members: users.map(({ id, email, firstName, lastName }) => ({ + id, + email, + firstName, + lastName, + })), + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/admin-role/admin-role.service.ts b/apps/server-nestjs/src/modules/admin-role/admin-role.service.ts new file mode 100644 index 0000000000..6aeff83744 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-role/admin-role.service.ts @@ -0,0 +1,225 @@ +import type { AdminRole, adminRoleContract } from '@cpn-console/shared' +import type { Prisma } from '@prisma/client' +import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { toAdminRoles } from './admin-role.utils' + +@Injectable() +export class AdminRoleService { + private readonly logger = new Logger(AdminRoleService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(EventEmitter2) private readonly eventEmitter: EventEmitter2, + ) {} + + @StartActiveSpan() + async list(): Promise { + const span = trace.getActiveSpan() + this.logger.log('adminRole.list started') + const roles = await this.prisma.adminRole.findMany({ + orderBy: { position: 'asc' }, + }) + span?.setAttribute('admin_role.count', roles.length) + this.logger.log(`adminRole.list completed (count=${roles.length})`) + return toAdminRoles(roles) + } + + @StartActiveSpan() + async create(body: typeof adminRoleContract.createAdminRole.body._type): Promise { + const span = trace.getActiveSpan() + this.logger.log(`adminRole.create started (name=${body.name})`) + const created = await this.prisma.$transaction(async (tx) => { + const maxPosition = (await tx.adminRole.findFirst({ + orderBy: { position: 'desc' }, + select: { position: true }, + }))?.position ?? -1 + + return tx.adminRole.create({ + data: { + name: body.name, + permissions: 0n, + position: maxPosition + 1, + }, + }) + }) + + span?.setAttribute('admin_role.id', created.id) + const createdRole = await this.prisma.adminRole.findUnique({ where: { id: created.id } }) + if (!createdRole) { + throw new NotFoundException(`Role with id ${created.id} not found`) + } + const createdMembers = await this.prisma.user.findMany({ + where: { adminRoleIds: { has: created.id } }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + }, + }) + await this.eventEmitter.emitAsync('adminRole.upsert', { + ...createdRole, + members: createdMembers.map(({ id, email, firstName, lastName }) => ({ + id, + email, + firstName, + lastName, + })), + }) + this.logger.log(`adminRole.create completed (id=${created.id})`) + return this.list() + } + + @StartActiveSpan() + async patch( + roles: typeof adminRoleContract.patchAdminRoles.body._type, + ): Promise { + const span = trace.getActiveSpan() + this.logger.log(`adminRole.patch started (count=${roles.length})`) + + const dbRoles = await this.prisma.adminRole.findMany({ + orderBy: { position: 'asc' }, + }) + const positionsAvailable: number[] = [] + const updatedRoles: Array<{ id: string, data: Prisma.AdminRoleUpdateInput }> = [] + + for (const dbRole of dbRoles) { + const matchingRole = roles.find(role => role.id === dbRole.id) + if (!matchingRole) continue + + if (typeof matchingRole.position !== 'undefined' && !positionsAvailable.includes(matchingRole.position)) { + positionsAvailable.push(matchingRole.position) + } + + updatedRoles.push({ + id: dbRole.id, + data: { + name: matchingRole.name ?? dbRole.name, + permissions: typeof matchingRole.permissions !== 'undefined' ? BigInt(matchingRole.permissions) : dbRole.permissions, + position: matchingRole.position ?? dbRole.position, + oidcGroup: matchingRole.oidcGroup ?? dbRole.oidcGroup, + type: matchingRole.type ?? dbRole.type, + }, + }) + } + + if (positionsAvailable.length && positionsAvailable.length !== dbRoles.length) { + throw new BadRequestException('Les numéros de position des rôles sont incohérentes') + } + + await this.prisma.$transaction(async (tx) => { + for (const { id, data } of updatedRoles) { + await tx.adminRole.update({ where: { id }, data }) + } + }) + + await Promise.all(updatedRoles.map(async ({ id }) => { + const role = await this.prisma.adminRole.findUnique({ where: { id } }) + if (!role) { + throw new NotFoundException(`Role with id ${id} not found`) + } + const members = await this.prisma.user.findMany({ + where: { adminRoleIds: { has: id } }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + }, + }) + await this.eventEmitter.emitAsync('adminRole.upsert', { + ...role, + members: members.map(({ id: memberId, email, firstName, lastName }) => ({ + id: memberId, + email, + firstName, + lastName, + })), + }) + })) + span?.setAttribute('admin_role.updated.count', updatedRoles.length) + this.logger.log(`adminRole.patch completed (updated=${updatedRoles.length})`) + return this.list() + } + + @StartActiveSpan() + async memberCounts() { + const span = trace.getActiveSpan() + this.logger.log('adminRole.memberCounts started') + const roles = await this.prisma.adminRole.findMany({ + where: { oidcGroup: { equals: '' } }, + select: { id: true }, + }) + const roleIds = roles.map(role => role.id) + const users = await this.prisma.user.findMany({ + where: { adminRoleIds: { hasSome: roleIds } }, + select: { adminRoleIds: true }, + }) + + const counts: Record = Object.fromEntries(roleIds.map(roleId => [roleId, 0])) + for (const { adminRoleIds } of users) { + for (const roleId of adminRoleIds) { + if (typeof counts[roleId] === 'number') { + counts[roleId]++ + } + } + } + + span?.setAttribute('admin_role.member_counts.count', Object.keys(counts).length) + this.logger.log(`adminRole.memberCounts completed (roles=${Object.keys(counts).length})`) + return counts + } + + @StartActiveSpan() + async delete(roleId: string): Promise { + const span = trace.getActiveSpan() + this.logger.log(`adminRole.delete started (id=${roleId})`) + + const role = await this.prisma.adminRole.findUnique({ where: { id: roleId } }) + if (!role) { + throw new NotFoundException() + } + + const members = await this.prisma.user.findMany({ + where: { adminRoleIds: { has: roleId } }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + }, + }) + + await this.eventEmitter.emitAsync('adminRole.delete', { + ...role, + members: members.map(({ id, email, firstName, lastName }) => ({ + id, + email, + firstName, + lastName, + })), + }) + + const users = await this.prisma.user.findMany({ + where: { adminRoleIds: { has: roleId } }, + select: { id: true, adminRoleIds: true }, + }) + + await this.prisma.$transaction(async (tx) => { + for (const user of users) { + await tx.user.update({ + where: { id: user.id }, + data: { adminRoleIds: user.adminRoleIds.filter(adminRoleId => adminRoleId !== roleId) }, + }) + } + await tx.adminRole.delete({ where: { id: roleId } }) + }) + + span?.setAttribute('admin_role.deleted.user_count', users.length) + this.logger.log(`adminRole.delete completed (id=${roleId}, userCount=${users.length})`) + } +} diff --git a/apps/server-nestjs/src/modules/admin-role/admin-role.utils.ts b/apps/server-nestjs/src/modules/admin-role/admin-role.utils.ts new file mode 100644 index 0000000000..dd41710972 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-role/admin-role.utils.ts @@ -0,0 +1,17 @@ +import type { AdminRole } from '@cpn-console/shared' +import type { AdminRole as PrismaAdminRole } from '@prisma/client' + +export function toAdminRole(role: PrismaAdminRole): AdminRole { + return { + id: role.id, + name: role.name, + permissions: role.permissions.toString(), + position: role.position, + oidcGroup: role.oidcGroup, + type: role.type ?? 'managed', + } +} + +export function toAdminRoles(roles: PrismaAdminRole[]): AdminRole[] { + return roles.map(toAdminRole) +} From d1ba4de47129828b9869be13e41b1c87bafc0dc0 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 17 Jun 2026 15:19:07 +0200 Subject: [PATCH 2/2] fix(admin-role): return single role from create() instead of full list --- .../src/modules/admin-role/admin-role.service.spec.ts | 7 +++---- .../src/modules/admin-role/admin-role.service.ts | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/server-nestjs/src/modules/admin-role/admin-role.service.spec.ts b/apps/server-nestjs/src/modules/admin-role/admin-role.service.spec.ts index 7a8918ffb2..71f503cdf6 100644 --- a/apps/server-nestjs/src/modules/admin-role/admin-role.service.spec.ts +++ b/apps/server-nestjs/src/modules/admin-role/admin-role.service.spec.ts @@ -41,7 +41,7 @@ describe('adminRoleService', () => { ]) }) - it('creates a role at the next position and returns the updated list', async () => { + it('creates a role at the next position and returns the created role', async () => { const existingRole = { id: faker.string.uuid(), name: 'Role A', @@ -53,18 +53,17 @@ describe('adminRoleService', () => { prisma.$transaction.mockImplementation(async callback => callback(prisma)) prisma.adminRole.findFirst.mockResolvedValue(existingRole) - prisma.adminRole.findMany.mockResolvedValue([existingRole]) prisma.adminRole.create.mockResolvedValue(existingRole) prisma.adminRole.findUnique.mockResolvedValue(existingRole) prisma.user.findMany.mockResolvedValue([]) - await expect(service.create({ name: 'New role' })).resolves.toEqual([ + await expect(service.create({ name: 'New role' })).resolves.toEqual( expect.objectContaining({ id: existingRole.id, permissions: '4', position: 5, }), - ]) + ) expect(prisma.adminRole.create).toHaveBeenCalledWith({ data: { diff --git a/apps/server-nestjs/src/modules/admin-role/admin-role.service.ts b/apps/server-nestjs/src/modules/admin-role/admin-role.service.ts index 6aeff83744..31ecefccee 100644 --- a/apps/server-nestjs/src/modules/admin-role/admin-role.service.ts +++ b/apps/server-nestjs/src/modules/admin-role/admin-role.service.ts @@ -5,7 +5,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' import { PrismaService } from '../infrastructure/database/prisma.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' -import { toAdminRoles } from './admin-role.utils' +import { toAdminRole, toAdminRoles } from './admin-role.utils' @Injectable() export class AdminRoleService { @@ -71,7 +71,7 @@ export class AdminRoleService { })), }) this.logger.log(`adminRole.create completed (id=${created.id})`) - return this.list() + return toAdminRole(createdRole) } @StartActiveSpan()