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 a5e6b10c1..2ed0fe534 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/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index adcd9691a..a21051005 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common' import { ScheduleModule } from '@nestjs/schedule' +import { AdminRoleModule } from './modules/admin-role/admin-role.module' import { DeploymentModule } from './modules/deployment/deployment.module' import { EnvironmentModule } from './modules/environment/environment.module' import { HealthzModule } from './modules/healthz/healthz.module' @@ -19,6 +20,7 @@ import { VersionModule } from './modules/version/version.module' @Module({ imports: [ + AdminRoleModule, InfrastructureModule, HealthzModule, KeycloakModule, diff --git a/apps/server-nestjs/src/modules/admin-role/admin-role-queries.utils.ts b/apps/server-nestjs/src/modules/admin-role/admin-role-queries.utils.ts new file mode 100644 index 000000000..5ff02cf0c --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-role/admin-role-queries.utils.ts @@ -0,0 +1,70 @@ +import type { Prisma } from '@prisma/client' + +export const adminRoleSelect = { + id: true, + name: true, + permissions: true, + position: true, + oidcGroup: true, + type: true, +} satisfies Prisma.AdminRoleSelect + +export type AdminRole = Prisma.AdminRoleGetPayload<{ + select: typeof adminRoleSelect +}> + +export async function getAdminRoleMaxPosition(tx: Prisma.TransactionClient): Promise { + const role = await tx.adminRole.findFirst({ + orderBy: { position: 'desc' }, + select: { position: true }, + }) + + return role?.position ?? -1 +} + +export async function createAdminRole(tx: Prisma.TransactionClient, name: string): Promise<{ role: AdminRole, members: { id: string, email: string, firstName: string, lastName: string }[] }> { + const maxPosition = await getAdminRoleMaxPosition(tx) + const role = await tx.adminRole.create({ + data: { + name, + permissions: 0n, + position: maxPosition + 1, + }, + select: adminRoleSelect, + }) + + const members = await tx.user.findMany({ + where: { adminRoleIds: { has: role.id } }, + select: { id: true, email: true, firstName: true, lastName: true }, + }) + + return { role, members } +} + +export async function getAdminRoleMemberCounts(tx: Prisma.TransactionClient): Promise> { + const roles = await tx.adminRole.findMany({ + where: { oidcGroup: { equals: '' } }, + select: { id: true }, + }) + const roleIds = roles.map(role => role.id) + const users = await tx.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]++ + } + } + + return counts +} + +export async function getRoles(tx: Prisma.TransactionClient): Promise { + return await tx.adminRole.findMany({ + orderBy: { position: 'asc' }, + select: adminRoleSelect, + }) +} diff --git a/apps/server-nestjs/src/modules/admin-role/admin-role-testing.utils.ts b/apps/server-nestjs/src/modules/admin-role/admin-role-testing.utils.ts new file mode 100644 index 000000000..dddd27d45 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-role/admin-role-testing.utils.ts @@ -0,0 +1,58 @@ +import type { AdminRole } from './admin-role-queries.utils' +import type { AdminRoleService } from './admin-role.service' +import type { CreateAdminRoleBody, PatchAdminRolesBody } from './admin-role.utils' + +export type AdminRoleContract = Parameters[0][number] +export type AdminRoleResponse = NonNullable>>[number] + +export interface AdminRoleMember { + id: string + email: string + firstName: string + lastName: string +} + +export function makeAdminRole(overrides: Partial = {}): AdminRole { + return { + id: overrides.id ?? crypto.randomUUID(), + name: overrides.name ?? 'New role', + permissions: overrides.permissions ?? 0n, + position: overrides.position ?? 0, + oidcGroup: overrides.oidcGroup ?? '', + type: overrides.type ?? 'managed', + ...overrides, + } +} + +export function makeAdminRoleMember(overrides: Partial = {}): AdminRoleMember { + return { + id: crypto.randomUUID(), + email: 'user@example.com', + firstName: 'First', + lastName: 'Last', + ...overrides, + } +} + +export function makeCreateAdminRoleBody(overrides: { name?: string } = {}): CreateAdminRoleBody { + return { + name: overrides.name ?? 'New role', + } +} + +export function makePatchAdminRoleBody( + role: AdminRole, + overrides: Partial = {}, +): PatchAdminRolesBody[number] { + return { + id: role.id, + name: overrides.name ?? role.name, + permissions: + overrides.permissions + ?? (typeof role.permissions === 'bigint' ? role.permissions.toString() : String(role.permissions)), + position: overrides.position ?? role.position, + oidcGroup: overrides.oidcGroup ?? role.oidcGroup, + type: overrides.type ?? role.type, + ...overrides, + } +} 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 000000000..e0e09f05e --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-role/admin-role.controller.ts @@ -0,0 +1,61 @@ +import type { AdminRole } from '@cpn-console/shared' +import type { CreateAdminRoleBody, PatchAdminRolesBody } from './admin-role.utils' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, 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' +import { CreateAdminRoleBodySchema, PatchAdminRolesBodySchema } from './admin-role.utils' + +@Controller('api/v1/admin/roles') +export class AdminRoleController { + constructor( + @Inject(AdminRoleService) private readonly adminRoleService: AdminRoleService, + ) {} + + @Get('') + @UseGuards(UserGuard) + // TODO: ListRoles is intentionally not protected by admin permission because of + // certain behaviours of the legacy client + // @RequireAdminPermission('ListRoles') + async listAdminRoles(): Promise { + return this.adminRoleService.list() + } + + @Post('') + @HttpCode(HttpStatus.CREATED) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageRoles') + async createAdminRole( + @Body(new ZodValidationPipe(CreateAdminRoleBodySchema)) body: CreateAdminRoleBody, + ): Promise { + return this.adminRoleService.create(body) + } + + @Patch('') + @HttpCode(HttpStatus.OK) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageRoles') + async patchAdminRoles( + @Body(new ZodValidationPipe(PatchAdminRolesBodySchema)) body: PatchAdminRolesBody, + ): Promise { + return this.adminRoleService.patch(body) + } + + @Get('member-counts') + @UseGuards(UserGuard) + @RequireAdminPermission('ManageRoles') + async adminRoleMemberCounts(): Promise> { + return this.adminRoleService.memberCounts() + } + + @Delete(':roleId') + @HttpCode(HttpStatus.NO_CONTENT) + @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 000000000..099f69d65 --- /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 000000000..0d15bd11f --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-role/admin-role.service.spec.ts @@ -0,0 +1,78 @@ +import type { EventEmitter2 } from '@nestjs/event-emitter' +import type { PrismaService } from '../infrastructure/database/prisma.service' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { + makeAdminRole, + makeCreateAdminRoleBody, +} from './admin-role-testing.utils' +import { AdminRoleService } from './admin-role.service' + +describe('adminRoleService', () => { + let prisma: ReturnType> + let eventEmitter: ReturnType> + + beforeEach(() => { + vi.clearAllMocks() + prisma = mockDeep() + eventEmitter = mockDeep() + eventEmitter.emitAsync.mockResolvedValue([]) + }) + + it('creates a role at the next position and returns the created role', async () => { + const existingRole = makeAdminRole({ + position: 5, + permissions: 4n, + type: 'managed', + }) + + const createdRole = makeAdminRole({ + ...existingRole, + name: 'New role', + position: 6, + permissions: 0n, + }) + + prisma.adminRole.findFirst.mockResolvedValue(existingRole) + prisma.adminRole.create.mockResolvedValue(createdRole) + prisma.adminRole.findUnique.mockResolvedValue(createdRole) + prisma.user.findMany.mockResolvedValue([]) + prisma.$transaction.mockImplementation(async callback => callback(prisma)) + + const createBody = makeCreateAdminRoleBody({ name: 'New role' }) + const service = new AdminRoleService(prisma, eventEmitter) + const result = await service.create(createBody) + + expect(result).toEqual( + expect.objectContaining({ + id: existingRole.id, + permissions: '0', + position: 6, + }), + ) + expect(prisma.adminRole.create).toHaveBeenCalledWith({ + data: { + name: 'New role', + permissions: 0n, + position: 6, + }, + select: { + id: true, + name: true, + oidcGroup: true, + permissions: true, + position: true, + type: true, + }, + }) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('adminRole.upsert', { + id: existingRole.id, + name: 'New role', + oidcGroup: '', + permissions: 0n, + position: 6, + type: 'managed', + members: [], + }) + }) +}) 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 000000000..eb2d553be --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-role/admin-role.service.ts @@ -0,0 +1,191 @@ +import type { AdminRole } from '@cpn-console/shared' +import type { Prisma } from '@prisma/client' +import type { CreateAdminRoleBody, PatchAdminRolesBody } from './admin-role.utils' +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 { + adminRoleSelect, + createAdminRole, + getAdminRoleMemberCounts, + getRoles, +} from './admin-role-queries.utils' +import { toAdminRole, toAdminRoles } from './admin-role.utils' + +@Injectable() +export class AdminRoleService { + private readonly logger = new Logger(AdminRoleService.name) + + constructor( + @Inject(PrismaService) public readonly prisma: PrismaService, + @Inject(EventEmitter2) public readonly eventEmitter: EventEmitter2, + ) {} + + @StartActiveSpan() + async list(): Promise { + const span = trace.getActiveSpan() + this.logger.log('adminRole.list started') + const roles = await getRoles(this.prisma) + span?.setAttribute('admin_role.count', roles.length) + this.logger.log(`adminRole.list completed (count=${roles.length})`) + return toAdminRoles(roles) + } + + @StartActiveSpan() + async create(body: CreateAdminRoleBody): Promise { + const span = trace.getActiveSpan() + this.logger.log(`adminRole.create started (name=${body.name})`) + + const { role: createdRole, members: createdMembers } = await this.prisma.$transaction(async (tx) => { + return createAdminRole(tx, body.name) + }) + + await this.eventEmitter.emitAsync('adminRole.upsert', { + ...createdRole, + members: createdMembers.map(({ id, email, firstName, lastName }) => ({ + id, + email, + firstName, + lastName, + })), + }) + + span?.setAttribute('admin_role.id', createdRole.id) + this.logger.log(`adminRole.create completed (id=${createdRole.id})`) + return toAdminRole(createdRole) + } + + @StartActiveSpan() + async patch( + roles: PatchAdminRolesBody, + ): Promise { + const span = trace.getActiveSpan() + this.logger.log(`adminRole.patch started (count=${roles.length})`) + + const patchResult = await this.prisma.$transaction(async (tx) => { + const dbRoles = await getRoles(tx) + 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 (matchingRole.position !== undefined && !positionsAvailable.includes(matchingRole.position)) { + positionsAvailable.push(matchingRole.position) + } + + updatedRoles.push({ + id: dbRole.id, + data: { + name: matchingRole.name ?? dbRole.name, + permissions: matchingRole.permissions === undefined ? dbRole.permissions : BigInt(matchingRole.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') + } + + for (const { id, data } of updatedRoles) { + await tx.adminRole.update({ where: { id }, data }) + } + + const results = await Promise.all(updatedRoles.map(async ({ id }) => { + const role = await tx.adminRole.findFirst({ + where: { id }, + select: adminRoleSelect, + }) + if (!role) { + throw new NotFoundException(`Role with id ${id} not found`) + } + const members = await tx.user.findMany({ + where: { adminRoleIds: { has: id } }, + select: { id: true, email: true, firstName: true, lastName: true }, + }) + + return { role, members: members.map(({ id: memberId, email, firstName, lastName }) => ({ + id: memberId, + email, + firstName, + lastName, + })) } + })) + + return results + }) + + await Promise.all(patchResult.map(({ role, members }) => this.eventEmitter.emitAsync('adminRole.upsert', { + ...role, + members, + }))) + + span?.setAttribute('admin_role.updated.count', patchResult.length) + this.logger.log(`adminRole.patch completed (updated=${patchResult.length})`) + return this.list() + } + + @StartActiveSpan() + async memberCounts() { + const span = trace.getActiveSpan() + this.logger.log('adminRole.memberCounts started') + const counts = await this.prisma.$transaction(async tx => getAdminRoleMemberCounts(tx)) + + 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})`) + + await this.prisma.$transaction(async (tx) => { + const [role] = await tx.adminRole.findMany({ + where: { id: roleId }, + select: adminRoleSelect, + }) + if (!role) { + throw new NotFoundException() + } + + const members = await tx.user.findMany({ + where: { adminRoleIds: { has: roleId } }, + select: { id: true, email: true, firstName: true, lastName: true }, + }) + + const users = await tx.user.findMany({ + where: { adminRoleIds: { has: roleId } }, + select: { id: true, adminRoleIds: true }, + }) + + await this.eventEmitter.emitAsync('adminRole.delete', { + ...role, + members: members.map(({ id, email, firstName, lastName }) => ({ + id, + email, + firstName, + lastName, + })), + }) + + 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 000000000..ffa3d04e3 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-role/admin-role.utils.ts @@ -0,0 +1,24 @@ +import type { AdminRole as AdminRoleContract } from '@cpn-console/shared' +import type { AdminRole as PrismaAdminRole } from './admin-role-queries.utils' +import { adminRoleContract } from '@cpn-console/shared' + +export type CreateAdminRoleBody = typeof adminRoleContract.createAdminRole.body._type +export type PatchAdminRolesBody = typeof adminRoleContract.patchAdminRoles.body._type + +export const CreateAdminRoleBodySchema = adminRoleContract.createAdminRole.body +export const PatchAdminRolesBodySchema = adminRoleContract.patchAdminRoles.body + +export function toAdminRole(role: PrismaAdminRole): AdminRoleContract { + 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[]): AdminRoleContract[] { + return roles.map(toAdminRole) +}