From 917220c419a91c3bce58a5c17402e57f0b285efb Mon Sep 17 00:00:00 2001 From: David Date: Mon, 7 Sep 2026 21:40:57 +0200 Subject: [PATCH] feat(api): effacement reel et export complet des donnees Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C --- .../controllers/gdpr.controller.ts | 291 ++++++----- .../src/application/dto/delete-account.dto.ts | 27 + .../src/application/gdpr/gdpr.module.ts | 24 +- .../application/services/gdpr.service.spec.ts | 249 +++++++++ .../src/application/services/gdpr.service.ts | 471 +++++++++++------- 5 files changed, 759 insertions(+), 303 deletions(-) create mode 100644 apps/backend/src/application/dto/delete-account.dto.ts create mode 100644 apps/backend/src/application/services/gdpr.service.spec.ts diff --git a/apps/backend/src/application/controllers/gdpr.controller.ts b/apps/backend/src/application/controllers/gdpr.controller.ts index ee37702..3b23bcd 100644 --- a/apps/backend/src/application/controllers/gdpr.controller.ts +++ b/apps/backend/src/application/controllers/gdpr.controller.ts @@ -1,169 +1,183 @@ /** - * GDPR Controller - * - * Endpoints for GDPR compliance (data export, deletion, consent) + * Droits des personnes (RGPD) : accès et portabilité, effacement, consentement. */ import { - Controller, - Get, - Post, - Delete, + BadRequestException, Body, - UseGuards, + Controller, + Delete, + Get, HttpCode, HttpStatus, - Res, + Post, Req, + Res, + UseGuards, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; import { Response, Request } from 'express'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; -import { CurrentUser } from '../decorators/current-user.decorator'; -import { UserPayload } from '../decorators/current-user.decorator'; -import { GDPRService } from '../services/gdpr.service'; +import { RolesGuard } from '../guards/roles.guard'; +import { Roles } from '../decorators/roles.decorator'; +import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; +import { GDPRService, GDPRDataExport, GDPRErasureReport } from '../services/gdpr.service'; import { UpdateConsentDto, ConsentResponseDto, WithdrawConsentDto } from '../dto/consent.dto'; +import { DeleteAccountDto } from '../dto/delete-account.dto'; +import { RetentionService, RetentionReport } from '../services/retention.service'; +import { RETENTION_RULES } from '@domain/services/data-retention'; @ApiTags('GDPR') @Controller('gdpr') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, RolesGuard) @ApiBearerAuth() export class GDPRController { - constructor(private readonly gdprService: GDPRService) {} + constructor( + private readonly gdprService: GDPRService, + private readonly retentionService: RetentionService + ) {} - /** - * Export user data (GDPR Right to Data Portability) - */ + /** Export de portabilité au format JSON (art. 20). */ @Get('export') - @ApiOperation({ - summary: 'Export all user data', - description: 'Export all personal data in JSON format (GDPR Article 20)', - }) - @ApiResponse({ - status: 200, - description: 'Data export successful', - }) + @ApiOperation({ summary: 'Exporter ses données personnelles (JSON)' }) + @ApiResponse({ status: 200, description: 'Export produit' }) async exportData(@CurrentUser() user: UserPayload, @Res() res: Response): Promise { - const exportData = await this.gdprService.exportUserData(user.id); + const data = await this.gdprService.exportUserData(user.id); + const day = new Date().toISOString().slice(0, 10); - // Set headers for file download - res.setHeader('Content-Type', 'application/json'); - res.setHeader( - 'Content-Disposition', - `attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.json"` - ); - - res.json(exportData); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="xpeditis-donnees-${day}.json"`); + res.json(data); } /** - * Export user data as CSV + * Même export, en tableur. + * + * Il ne reprenait que le profil et le consentement cookies, ce qui donnait + * deux exports au contenu différent selon le format demandé. Il aplatit + * désormais l'export complet. */ @Get('export/csv') - @ApiOperation({ - summary: 'Export user data as CSV', - description: 'Export personal data in CSV format for easy viewing', - }) - @ApiResponse({ - status: 200, - description: 'CSV export successful', - }) + @ApiOperation({ summary: 'Exporter ses données personnelles (CSV)' }) + @ApiResponse({ status: 200, description: 'Export produit' }) async exportDataCSV(@CurrentUser() user: UserPayload, @Res() res: Response): Promise { - const exportData = await this.gdprService.exportUserData(user.id); + const data = await this.gdprService.exportUserData(user.id); + const day = new Date().toISOString().slice(0, 10); - // Convert to CSV (simplified version) - let csv = 'Category,Field,Value\n'; + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="xpeditis-donnees-${day}.csv"`); + // BOM : sans lui Excel lit l'UTF-8 comme du latin-1 et casse les accents. + res.send('' + toCsv(data)); + } - // User data - Object.entries(exportData.userData).forEach(([key, value]) => { - csv += `User Data,${key},"${value}"\n`; - }); - - // Cookie consent data - if (exportData.cookieConsent) { - Object.entries(exportData.cookieConsent).forEach(([key, value]) => { - csv += `Cookie Consent,${key},"${value}"\n`; + /** + * Effacement (art. 17). + * + * Renvoie le détail de ce qui a été effacé et de ce qui a été anonymisé. + * L'endpoint répondait 204 : la personne obtenait une page blanche pour + * seule réponse à une demande d'effacement, sans moyen de vérifier ce qui + * avait effectivement été traité. + */ + @Delete('delete-account') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Effacer son compte et ses données' }) + @ApiResponse({ status: 200, description: 'Effacement appliqué' }) + async deleteAccount( + @CurrentUser() user: UserPayload, + @Body() body: DeleteAccountDto + ): Promise { + // Confirmation par saisie de l'adresse : l'effacement est irréversible. + // `new Error` remontait ici en « Internal server error » — une erreur de + // saisie affichée comme une panne du service. + if (body.confirmEmail.trim().toLowerCase() !== user.email.toLowerCase()) { + throw new BadRequestException({ + code: 'email_mismatch', + message: "L'adresse saisie ne correspond pas à celle du compte.", }); } - // Set headers - res.setHeader('Content-Type', 'text/csv'); - res.setHeader( - 'Content-Disposition', - `attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.csv"` - ); - - res.send(csv); + return this.gdprService.deleteUserData(user.id, body.reason); } /** - * Delete user data (GDPR Right to Erasure) + * Politique de conservation appliquée (art. 13.2.a). + * + * L'information sur les durées doit être accessible à la personne, pas + * seulement écrite dans une politique de confidentialité : elle est servie + * ici depuis la règle réellement appliquée par le code. */ - @Delete('delete-account') - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ - summary: 'Delete user account and data', - description: 'Permanently delete or anonymize user data (GDPR Article 17)', - }) - @ApiResponse({ - status: 204, - description: 'Account deletion initiated', - }) - async deleteAccount( - @CurrentUser() user: UserPayload, - @Body() body: { reason?: string; confirmEmail: string } - ): Promise { - // Verify email confirmation (security measure) - if (body.confirmEmail !== user.email) { - throw new Error('Email confirmation does not match'); - } - - await this.gdprService.deleteUserData(user.id, body.reason); + @Get('retention') + @ApiOperation({ summary: 'Durées de conservation appliquées' }) + @ApiResponse({ status: 200, description: 'Politique de conservation' }) + getRetentionPolicy(): { rules: typeof RETENTION_RULES } { + return { rules: RETENTION_RULES }; } /** - * Record consent + * Journal des demandes de droits, pour la console de conformité. + * + * Réservé aux administrateurs : c'est l'élément qu'on présente à une + * autorité de contrôle pour démontrer que les demandes sont traitées + * (art. 5.2). Les effacements y figurent sous une adresse anonymisée. */ + @Get('admin/requests') + @Roles('admin') + @ApiOperation({ summary: 'Journal des demandes de droits (administration)' }) + @ApiResponse({ status: 200, description: 'Demandes récentes' }) + async listRightsRequests(): Promise<{ requests: Record[] }> { + return { requests: await this.gdprService.listRightsRequests() }; + } + + /** + * Ce que la purge supprimerait, sans rien supprimer. + * + * Une purge est irréversible : la console la montre avant de l'autoriser. + */ + @Get('admin/retention/preview') + @Roles('admin') + @ApiOperation({ summary: 'Aperçu de la purge de conservation (administration)' }) + @ApiResponse({ status: 200, description: 'Lignes arrivées à échéance' }) + async previewRetention(): Promise { + return this.retentionService.preview(); + } + + /** + * Déclenche la purge immédiatement. + * + * Le POST est délibéré : la purge supprime définitivement des lignes, elle + * ne peut pas être déclenchée par une simple navigation. + */ + @Post('admin/retention/purge') + @Roles('admin') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Appliquer les durées de conservation (administration)' }) + @ApiResponse({ status: 200, description: 'Purge appliquée' }) + async runRetention(): Promise { + return this.retentionService.purge(); + } + + /** Recueil du consentement cookies (art. 7). */ @Post('consent') @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Record user consent', - description: 'Record consent for cookies (GDPR Article 7)', - }) - @ApiResponse({ - status: 200, - description: 'Consent recorded', - type: ConsentResponseDto, - }) + @ApiOperation({ summary: 'Enregistrer ses préférences de cookies' }) + @ApiResponse({ status: 200, type: ConsentResponseDto }) async recordConsent( @CurrentUser() user: UserPayload, @Body() body: UpdateConsentDto, @Req() req: Request ): Promise { - // Add IP and user agent from request if not provided - const consentData: UpdateConsentDto = { + return this.gdprService.recordConsent(user.id, { ...body, ipAddress: body.ipAddress || req.ip || req.socket.remoteAddress, userAgent: body.userAgent || req.headers['user-agent'], - }; - - return this.gdprService.recordConsent(user.id, consentData); + }); } - /** - * Withdraw consent - */ + /** Retrait du consentement (art. 7.3). */ @Post('consent/withdraw') @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Withdraw consent', - description: 'Withdraw consent for functional, analytics, or marketing (GDPR Article 7.3)', - }) - @ApiResponse({ - status: 200, - description: 'Consent withdrawn', - type: ConsentResponseDto, - }) + @ApiOperation({ summary: 'Retirer un consentement' }) + @ApiResponse({ status: 200, type: ConsentResponseDto }) async withdrawConsent( @CurrentUser() user: UserPayload, @Body() body: WithdrawConsentDto @@ -171,20 +185,51 @@ export class GDPRController { return this.gdprService.withdrawConsent(user.id, body.consentType); } - /** - * Get consent status - */ @Get('consent') - @ApiOperation({ - summary: 'Get current consent status', - description: 'Retrieve current consent preferences', - }) - @ApiResponse({ - status: 200, - description: 'Consent status retrieved', - type: ConsentResponseDto, - }) + @ApiOperation({ summary: 'Consulter ses préférences de cookies' }) + @ApiResponse({ status: 200, type: ConsentResponseDto }) async getConsentStatus(@CurrentUser() user: UserPayload): Promise { return this.gdprService.getConsentStatus(user.id); } } + +/** Échappement CSV : guillemets doublés, valeur toujours encadrée. */ +const cell = (value: unknown): string => { + if (value === null || value === undefined) return '""'; + const text = typeof value === 'object' ? JSON.stringify(value) : String(value); + return `"${text.replace(/"/g, '""')}"`; +}; + +/** + * Aplatit l'export en trois colonnes (section, champ, valeur). + * + * Un CSV par section serait plus lisible mais imposerait une archive ; la + * personne qui demande un CSV veut ouvrir un fichier, pas un zip. + */ +function toCsv(data: GDPRDataExport): string { + const lines = ['Section,Champ,Valeur']; + + const flat = (section: string, record: Record) => { + for (const [key, value] of Object.entries(record)) { + lines.push([cell(section), cell(key), cell(value)].join(',')); + } + }; + + flat('Compte', data.userData); + if (data.organisation) flat('Organisation', data.organisation); + if (data.cookieConsent) flat('Consentement cookies', data.cookieConsent); + + const collections: [string, Record[]][] = [ + ['Réservations', data.bookings], + ['Notifications', data.notifications], + ['Conversations assistant', data.assistantConversations], + ["Clés d'API", data.apiKeys], + ['Journal d activite', data.activityLog], + ]; + + for (const [section, rows] of collections) { + rows.forEach((row, index) => flat(`${section} ${index + 1}`, row)); + } + + return lines.join('\n'); +} diff --git a/apps/backend/src/application/dto/delete-account.dto.ts b/apps/backend/src/application/dto/delete-account.dto.ts new file mode 100644 index 0000000..07584ff --- /dev/null +++ b/apps/backend/src/application/dto/delete-account.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator'; + +/** + * Demande d'effacement (RGPD art. 17). + * + * Le corps de la requête n'était pas validé : `confirmEmail` arrivait en + * `any`, et une valeur absente déclenchait une comparaison sur `undefined` + * remontée en erreur 500. + */ +export class DeleteAccountDto { + @ApiProperty({ + example: 'personne@example.com', + description: "Adresse du compte, ressaisie pour confirmer un acte irréversible", + }) + @IsEmail({}, { message: 'Une adresse email valide est requise pour confirmer.' }) + confirmEmail: string; + + @ApiPropertyOptional({ + example: "Je n'utilise plus le service", + description: "Motif facultatif. La personne n'a pas à le justifier (art. 17.1).", + }) + @IsOptional() + @IsString() + @MaxLength(500) + reason?: string; +} diff --git a/apps/backend/src/application/gdpr/gdpr.module.ts b/apps/backend/src/application/gdpr/gdpr.module.ts index 6869942..690dd19 100644 --- a/apps/backend/src/application/gdpr/gdpr.module.ts +++ b/apps/backend/src/application/gdpr/gdpr.module.ts @@ -6,26 +6,26 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuditModule } from '../audit/audit.module'; import { GDPRController } from '../controllers/gdpr.controller'; import { GDPRService } from '../services/gdpr.service'; +import { RetentionService } from '../services/retention.service'; import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; -import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity'; -import { AuditLogOrmEntity } from '../../infrastructure/persistence/typeorm/entities/audit-log.orm-entity'; -import { NotificationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/notification.orm-entity'; import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity'; @Module({ imports: [ - TypeOrmModule.forFeature([ - UserOrmEntity, - BookingOrmEntity, - AuditLogOrmEntity, - NotificationOrmEntity, - CookieConsentOrmEntity, - ]), + // Les autres tables touchees par l'effacement (csv_bookings, audit_logs, + // notifications, trade_conversations, password_reset_tokens) sont lues en + // SQL via DataSource : la moitie d'entre elles n'a pas d'entite ORM, et + // BookingOrmEntity pointait vers une table `bookings` qui n'existe pas. + TypeOrmModule.forFeature([UserOrmEntity, CookieConsentOrmEntity]), + // Les demandes de droits sont journalisees : l'article 5.2 impose de + // pouvoir demontrer qu'elles ont ete traitees. + AuditModule, ], controllers: [GDPRController], - providers: [GDPRService], - exports: [GDPRService], + providers: [GDPRService, RetentionService], + exports: [GDPRService, RetentionService], }) export class GDPRModule {} diff --git a/apps/backend/src/application/services/gdpr.service.spec.ts b/apps/backend/src/application/services/gdpr.service.spec.ts new file mode 100644 index 0000000..7301b30 --- /dev/null +++ b/apps/backend/src/application/services/gdpr.service.spec.ts @@ -0,0 +1,249 @@ +import { NotFoundException } from '@nestjs/common'; +import { DataSource, EntityManager, Repository } from 'typeorm'; +import { GDPRService } from './gdpr.service'; +import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; +import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity'; +import { AuditService } from './audit.service'; +import { RETENTION_RULES, ANONYMISED } from '@domain/services/data-retention'; +import { AuditAction } from '@domain/entities/audit-log.entity'; + +/** + * Ces tests portent sur une promesse faite à une personne : « vos données sont + * effacées ». La version précédente la faisait sans rien effacer. Ils vérifient + * donc d'abord ce qui est réellement exécuté en base, table par table. + */ + +interface ExecutedQuery { + sql: string; + parameters: unknown[]; +} + +const USER_ID = '11111111-2222-3333-4444-555555555555'; + +const buildUser = (): UserOrmEntity => + ({ + id: USER_ID, + organizationId: 'org-1', + email: 'jean@example.com', + firstName: 'Jean', + lastName: 'Durand', + phoneNumber: '+33600000000', + passwordHash: 'argon2-hash', + totpSecret: 'TOTPSECRET', + role: 'USER', + preferredLanguage: 'fr', + isEmailVerified: true, + isActive: true, + lastLoginAt: new Date('2026-09-01T10:00:00Z'), + createdAt: new Date('2026-01-01T10:00:00Z'), + updatedAt: new Date('2026-09-01T10:00:00Z'), + }) as unknown as UserOrmEntity; + +/** Nombre de lignes renvoyé par le pilote PostgreSQL pour chaque écriture. */ +const ROWS_TOUCHED = 3; + +function buildService(options: { user?: UserOrmEntity | null } = {}) { + const executed: ExecutedQuery[] = []; + + const manager = { + // Forme réelle du pilote pour UPDATE et DELETE : [lignes, nombre]. + query: jest.fn(async (sql: string, parameters: unknown[]) => { + executed.push({ sql, parameters }); + return [[], ROWS_TOUCHED]; + }), + } as unknown as EntityManager; + + const dataSource = { + transaction: jest.fn(async (callback: (m: EntityManager) => Promise) => callback(manager)), + query: jest.fn(async (sql: string, parameters: unknown[]) => { + executed.push({ sql, parameters }); + return []; + }), + } as unknown as DataSource; + + const user = options.user === undefined ? buildUser() : options.user; + + const userRepository = { + findOne: jest.fn(async () => user), + } as unknown as Repository; + + const consentRepository = { + findOne: jest.fn(async () => null), + create: jest.fn((value: Partial) => ({ ...value })), + save: jest.fn(async (value: CookieConsentOrmEntity) => value), + } as unknown as Repository; + + const audit = { log: jest.fn(async () => undefined) } as unknown as AuditService; + + const service = new GDPRService(userRepository, consentRepository, dataSource, audit); + + return { service, executed, manager, dataSource, consentRepository, audit }; +} + +/** Toutes les instructions écrites contre une table donnée. */ +const statementsFor = (executed: ExecutedQuery[], table: string, verb: 'DELETE' | 'UPDATE') => + executed.filter(query => query.sql.includes(verb) && query.sql.includes(table)); + +describe('GDPRService — effacement (art. 17)', () => { + it('applique à chaque table le traitement décrit par la politique de conservation', async () => { + const { service, executed } = buildService(); + + await service.deleteUserData(USER_ID, 'Fin de collaboration'); + + // La politique et le code ne peuvent pas diverger sans faire échouer ce + // test : c'est la politique qui pilote l'assertion, pas une liste recopiée. + for (const rule of RETENTION_RULES) { + if (rule.onErasure === 'delete') { + expect(statementsFor(executed, rule.table, 'DELETE').length).toBeGreaterThan(0); + } + if (rule.onErasure === 'anonymise') { + expect(statementsFor(executed, rule.table, 'UPDATE').length).toBeGreaterThan(0); + } + } + }); + + it('ne supprime jamais la ligne du compte : les réservations la référencent en cascade', async () => { + const { service, executed } = buildService(); + + await service.deleteUserData(USER_ID); + + expect(statementsFor(executed, 'FROM users', 'DELETE')).toHaveLength(0); + expect(statementsFor(executed, 'csv_bookings', 'DELETE')).toHaveLength(0); + }); + + it("remplace l'identité et rend le compte inutilisable", async () => { + const { service, executed } = buildService(); + + await service.deleteUserData(USER_ID); + + const [update] = statementsFor(executed, 'UPDATE users', 'UPDATE'); + expect(update.sql).toContain('is_active = false'); + expect(update.sql).toContain('totp_secret = NULL'); + expect(update.parameters[1]).toBe(`${ANONYMISED}+${USER_ID}@invalid.local`); + // Mot de passe remplacé par une valeur aléatoire : la colonne est NOT NULL, + // et une constante partagée signerait tous les comptes effacés. + expect(update.parameters[3]).toMatch(/^erased-/); + }); + + it('compte les lignes réellement touchées, pas la forme du résultat', async () => { + const { service } = buildService(); + + const report = await service.deleteUserData(USER_ID); + + // Le pilote renvoie `[lignes, nombre]` : mesurer la longueur du tableau + // renverrait 2 partout, quel que soit le contenu de la base. + expect(report.deleted.notifications).toBe(ROWS_TOUCHED); + expect(report.anonymised.user).toBe(ROWS_TOUCHED); + expect(Object.values(report.deleted)).not.toContain(2); + }); + + it("journalise l'effacement sans y réinscrire l'identité effacée", async () => { + const { service, audit } = buildService(); + + await service.deleteUserData(USER_ID, 'Fin de collaboration'); + + const [entry] = (audit.log as jest.Mock).mock.calls[0]; + expect(entry.action).toBe(AuditAction.GDPR_ERASURE_EXECUTED); + // Journaliser avant l'effacement effacerait la trace ; y écrire l'adresse + // réelle réintroduirait l'identité qu'on vient de supprimer. + expect(entry.userEmail).toBe(`${ANONYMISED}+${USER_ID}@invalid.local`); + expect(entry.userEmail).not.toContain('jean@example.com'); + }); + + it('opère dans une transaction', async () => { + const { service, dataSource } = buildService(); + + await service.deleteUserData(USER_ID); + + expect(dataSource.transaction).toHaveBeenCalledTimes(1); + }); + + it("n'écrit rien si le compte n'existe pas", async () => { + const { service, executed } = buildService({ user: null }); + + await expect(service.deleteUserData(USER_ID)).rejects.toBeInstanceOf(NotFoundException); + expect(executed).toHaveLength(0); + }); +}); + +describe('GDPRService — portabilité (art. 20)', () => { + it("couvre l'ensemble des données rattachées au compte", async () => { + const { service, executed } = buildService(); + + const data = await service.exportUserData(USER_ID); + + const read = executed.map(query => query.sql).join(' '); + for (const table of [ + 'organizations', + 'csv_bookings', + 'notifications', + 'trade_conversations', + 'api_keys', + 'audit_logs', + ]) { + expect(read).toContain(table); + } + expect(data.userId).toBe(USER_ID); + }); + + it("n'expose aucun secret d'authentification", async () => { + const { service, executed } = buildService(); + + const data = await service.exportUserData(USER_ID); + + const serialised = JSON.stringify(data); + expect(serialised).not.toContain('argon2-hash'); + expect(serialised).not.toContain('TOTPSECRET'); + // Le condensat d'une clé d'API reste un secret d'accès. + expect(executed.map(query => query.sql).join(' ')).not.toContain('key_hash'); + }); + + it("refuse d'exporter pour un compte inconnu", async () => { + const { service } = buildService({ user: null }); + + await expect(service.exportUserData(USER_ID)).rejects.toBeInstanceOf(NotFoundException); + }); +}); + +describe('GDPRService — consentement (art. 7)', () => { + it('force les cookies essentiels et horodate le recueil', async () => { + const { service } = buildService(); + + const consent = await service.recordConsent(USER_ID, { + essential: false, + functional: true, + analytics: false, + marketing: false, + }); + + expect(consent.essential).toBe(true); + expect(consent.functional).toBe(true); + expect(consent.consentDate).toBeInstanceOf(Date); + }); + + it('retire tout ce qui est facultatif quand aucune catégorie n’est précisée', async () => { + const { service } = buildService(); + + const consent = await service.withdrawConsent(USER_ID); + + expect(consent).toMatchObject({ functional: false, analytics: false, marketing: false }); + expect(consent.essential).toBe(true); + }); + + it('ne retire que la catégorie visée', async () => { + const { service, consentRepository } = buildService(); + (consentRepository.findOne as jest.Mock).mockResolvedValue({ + userId: USER_ID, + essential: true, + functional: true, + analytics: true, + marketing: true, + }); + + const consent = await service.withdrawConsent(USER_ID, 'marketing'); + + expect(consent.marketing).toBe(false); + expect(consent.analytics).toBe(true); + expect(consent.functional).toBe(true); + }); +}); diff --git a/apps/backend/src/application/services/gdpr.service.ts b/apps/backend/src/application/services/gdpr.service.ts index d7784d2..a4706a1 100644 --- a/apps/backend/src/application/services/gdpr.service.ts +++ b/apps/backend/src/application/services/gdpr.service.ts @@ -1,26 +1,53 @@ /** - * GDPR Compliance Service + * Droits des personnes (RGPD). * - * Handles data export, deletion, and consent management - * with full database persistence + * Portabilité (art. 20), effacement (art. 17), preuve du consentement (art. 7). + * + * Les requêtes sont écrites en SQL plutôt qu'en repositories : la moitié des + * tables concernées (`trade_conversations`, `trade_messages`, + * `password_reset_tokens`) n'a pas d'entité ORM, et un effacement doit couvrir + * la base réelle, pas la partie qui a été modélisée. */ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { DataSource, EntityManager, Repository } from 'typeorm'; import { v4 as uuidv4 } from 'uuid'; import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity'; import { UpdateConsentDto, ConsentResponseDto } from '../dto/consent.dto'; +import { AuditService } from './audit.service'; +import { AuditAction, AuditStatus } from '@domain/entities/audit-log.entity'; +import { ANONYMISED, anonymisedEmail } from '@domain/services/data-retention'; export interface GDPRDataExport { exportDate: string; userId: string; - userData: any; - cookieConsent: any; - message: string; + userData: Record; + organisation: Record | null; + bookings: Record[]; + notifications: Record[]; + assistantConversations: Record[]; + apiKeys: Record[]; + activityLog: Record[]; + cookieConsent: Record | null; + notice: string; } +/** Ce qui a été effacé ou anonymisé, rendu à la personne comme preuve. */ +export interface GDPRErasureReport { + userId: string; + erasedAt: string; + deleted: Record; + anonymised: Record; +} + +/** + * Borne de l'export : seuls les journaux d'activité peuvent atteindre des + * volumes qui transformeraient l'export en vidage de base. + */ +const MAX_LOG_ROWS = 5000; + @Injectable() export class GDPRService { private readonly logger = new Logger(GDPRService.name); @@ -29,41 +56,119 @@ export class GDPRService { @InjectRepository(UserOrmEntity) private readonly userRepository: Repository, @InjectRepository(CookieConsentOrmEntity) - private readonly consentRepository: Repository + private readonly consentRepository: Repository, + private readonly dataSource: DataSource, + private readonly audit: AuditService ) {} /** - * Export all user data (GDPR Article 20 - Right to Data Portability) + * Export de portabilité (art. 20). + * + * L'export précédent ne contenait que le profil et le consentement cookies, + * en renvoyant la personne vers « les endpoints respectifs » pour le reste. + * Ce n'était pas un export : l'art. 20 porte sur l'ensemble des données + * fournies par la personne, pas sur l'échantillon le plus simple à produire. + * + * Restent volontairement dehors le hachage du mot de passe et le secret TOTP : + * ce sont des secrets d'authentification, les livrer affaiblirait le compte + * sans rien apporter à la portabilité. */ async exportUserData(userId: string): Promise { - this.logger.log(`Exporting data for user ${userId}`); - - // Fetch user data const user = await this.userRepository.findOne({ where: { id: userId } }); - if (!user) { - throw new NotFoundException('User not found'); - } + if (!user) throw new NotFoundException('User not found'); - // Fetch consent data const consent = await this.consentRepository.findOne({ where: { userId } }); - // Sanitize user data (remove password hash) - const sanitizedUser = { - id: user.id, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - role: user.role, - organizationId: user.organizationId, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - // Password hash explicitly excluded for security - }; + const [organisation] = await this.dataSource.query( + `SELECT id, name, type, siren, siret, eori, contact_email, contact_phone, + address_street, address_city, address_postal_code, address_country + FROM organizations WHERE id = $1`, + [user.organizationId] + ); - const exportData: GDPRDataExport = { + const bookings = await this.dataSource.query( + `SELECT id, booking_number, carrier_name, origin, destination, volume_cbm, weight_kg, + pallet_count, container_type, status, price_eur, price_usd, primary_currency, + freight_total, freight_currency, fob_total, fob_currency, commission_amount_eur, + transit_days, notes, rejection_reason, requested_at, responded_at, created_at + FROM csv_bookings WHERE user_id = $1 ORDER BY created_at DESC`, + [userId] + ); + + const notifications = await this.dataSource.query( + `SELECT type, priority, title, message, read, read_at, action_url, created_at + FROM notifications WHERE user_id = $1 ORDER BY created_at DESC`, + [userId] + ); + + const assistantConversations = await this.dataSource.query( + `SELECT c.id, c.title, c.created_at, + COALESCE(( + SELECT json_agg(json_build_object( + 'role', m.role, 'content', m.content, 'createdAt', m.created_at) + ORDER BY m.created_at) + FROM trade_messages m WHERE m.conversation_id = c.id + ), '[]'::json) AS messages + FROM trade_conversations c WHERE c.user_id = $1 ORDER BY c.created_at DESC`, + [userId] + ); + + // Jamais `key_hash` : seule la trace de l'existence de la clé est utile, + // et le condensat resterait un secret d'accès. + const apiKeys = await this.dataSource.query( + `SELECT name, key_prefix, is_active, last_used_at, expires_at, created_at + FROM api_keys WHERE user_id = $1 ORDER BY created_at DESC`, + [userId] + ); + + const activityLog = await this.dataSource.query( + `SELECT action, status, resource_type, resource_name, ip_address, timestamp + FROM audit_logs WHERE user_id = $1 ORDER BY timestamp DESC LIMIT $2`, + [userId, MAX_LOG_ROWS] + ); + + this.logger.log(`GDPR export produced for user ${userId}`); + + // Trace d'accountability (art. 5.2) : pouvoir démontrer que la demande a + // été honorée, et quand. + await this.audit.log({ + action: AuditAction.GDPR_DATA_EXPORTED, + status: AuditStatus.SUCCESS, + userId, + userEmail: user.email, + organizationId: user.organizationId, + resourceType: 'gdpr_request', + metadata: { + bookings: bookings.length, + notifications: notifications.length, + assistantConversations: assistantConversations.length, + activityLogEntries: activityLog.length, + }, + }); + + return { exportDate: new Date().toISOString(), userId, - userData: sanitizedUser, + userData: { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + phoneNumber: user.phoneNumber, + role: user.role, + preferredLanguage: user.preferredLanguage, + isEmailVerified: user.isEmailVerified, + isActive: user.isActive, + lastLoginAt: user.lastLoginAt, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }, + organisation: organisation ?? null, + bookings, + notifications, + assistantConversations, + apiKeys, + activityLog, cookieConsent: consent ? { essential: consent.essential, @@ -73,175 +178,205 @@ export class GDPRService { consentDate: consent.consentDate, } : null, - message: - 'User data exported successfully. Additional data (bookings, notifications) can be exported from respective endpoints.', + notice: + "Ensemble des données personnelles rattachées à ce compte. Les secrets d'authentification (mot de passe, second facteur, condensats de clés d'API) en sont exclus par sécurité. Le journal d'activité est limité aux " + + `${MAX_LOG_ROWS} entrées les plus récentes.`, }; - - this.logger.log(`Data export completed for user ${userId}`); - - return exportData; } /** - * Delete user data (GDPR Article 17 - Right to Erasure) - * Note: This is a simplified version. In production, implement full anonymization logic. + * Effacement (art. 17). + * + * L'implémentation précédente supprimait la ligne de consentement cookies, + * écrivait « Full implementation pending » dans les logs, et renvoyait un + * succès : la personne était informée que ses données étaient effacées alors + * que rien ne l'était. + * + * Deux traitements, décrits dans `domain/services/data-retention.ts` : + * ce qui n'existe que pour le confort du service est supprimé ; ce qui répond + * à une obligation de conservation (art. 17.3.b) est anonymisé, et sort donc + * du champ des données personnelles. + * + * La ligne `users` est neutralisée plutôt que supprimée : `csv_bookings`, + * `licenses` et `api_keys` la référencent en `ON DELETE CASCADE`, un vrai + * DELETE emporterait dix ans de pièces comptables avec lui. + * + * Le tout en transaction : un effacement à moitié appliqué laisserait un + * compte ni actif ni effacé, c'est-à-dire un état que rien ne rattrape. */ - async deleteUserData(userId: string, reason?: string): Promise { + async deleteUserData(userId: string, reason?: string): Promise { + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user) throw new NotFoundException('User not found'); + + this.logger.warn(`GDPR erasure starting for user ${userId} — reason: ${reason ?? 'unspecified'}`); + + const deleted: Record = {}; + const anonymised: Record = {}; + const email = user.email; + + await this.dataSource.transaction(async manager => { + const rows = (sql: string, parameters: unknown[]) => this.affected(manager, sql, parameters); + + // Supprimé : rien n'impose de le conserver. + deleted.notifications = await rows('DELETE FROM notifications WHERE user_id = $1', [userId]); + // Les messages suivent par cascade sur `conversation_id`. + deleted.assistantConversations = await rows( + 'DELETE FROM trade_conversations WHERE user_id = $1', + [userId] + ); + deleted.assistantUsage = await rows('DELETE FROM trade_assistant_usage WHERE user_id = $1', [ + userId, + ]); + deleted.apiKeys = await rows('DELETE FROM api_keys WHERE user_id = $1', [userId]); + deleted.cookieConsent = await rows('DELETE FROM cookie_consents WHERE user_id = $1', [userId]); + deleted.passwordResetTokens = await rows( + 'DELETE FROM password_reset_tokens WHERE user_id = $1', + [userId] + ); + // Une invitation non consommée porte le nom et l'adresse de la personne + // sans qu'aucun compte n'en dépende. + deleted.pendingInvitations = await rows( + 'DELETE FROM invitation_tokens WHERE lower(email) = lower($1) AND is_used = false', + [email] + ); + + // Anonymisé : conservé, sans rattachement à la personne. + // Les notes sont un champ libre : c'est le seul endroit d'une + // réservation où une donnée personnelle peut avoir été saisie. + anonymised.bookings = await rows('UPDATE csv_bookings SET notes = NULL WHERE user_id = $1', [ + userId, + ]); + anonymised.auditLogs = await rows( + `UPDATE audit_logs SET user_email = $2, ip_address = NULL, user_agent = NULL + WHERE user_id = $1`, + [userId, anonymisedEmail(userId)] + ); + // Un profil transporteur mêle données d'entreprise (conservées) et + // coordonnées d'une personne (effacées). + anonymised.carrierProfile = await rows( + `UPDATE carrier_profiles SET phone = NULL, notification_email = NULL, is_active = false + WHERE user_id = $1`, + [userId] + ); + + // Le compte : identité remplacée, accès rendu impossible. + // Le mot de passe reçoit une valeur aléatoire plutôt que NULL — la + // colonne est NOT NULL, et une valeur constante partagée par tous les + // comptes effacés serait un motif reconnaissable. + anonymised.user = await rows( + `UPDATE users SET + email = $2, first_name = $3, last_name = $3, phone_number = NULL, + password_hash = $4, totp_secret = NULL, + is_active = false, is_email_verified = false, updated_at = now() + WHERE id = $1`, + [userId, anonymisedEmail(userId), ANONYMISED, `erased-${uuidv4()}`] + ); + }); + this.logger.warn( - `Initiating data deletion for user ${userId}. Reason: ${reason || 'User request'}` + `GDPR erasure completed for user ${userId}: ${JSON.stringify({ deleted, anonymised })}` ); - // Verify user exists - const user = await this.userRepository.findOne({ where: { id: userId } }); - if (!user) { - throw new NotFoundException('User not found'); - } + // Écrite après la transaction, et avec l'adresse anonymisée : journaliser + // avant l'effacement ferait disparaître la trace par l'anonymisation des + // journaux, et y inscrire l'adresse réelle réintroduirait l'identité qu'on + // vient d'effacer. Ce qu'il faut pouvoir démontrer, c'est que la demande a + // été traitée — pas de qui elle émanait. + await this.audit.log({ + action: AuditAction.GDPR_ERASURE_EXECUTED, + status: AuditStatus.SUCCESS, + userId, + userEmail: anonymisedEmail(userId), + organizationId: user.organizationId, + resourceType: 'gdpr_request', + metadata: { reason: reason ?? null, deleted, anonymised }, + }); - try { - // Delete consent data first (will cascade with user deletion) - await this.consentRepository.delete({ userId }); - - // IMPORTANT: In production, implement full data anonymization - // For now, we just mark the account for deletion - // Real implementation should: - // 1. Anonymize bookings (keep for legal retention) - // 2. Delete notifications - // 3. Anonymize audit logs - // 4. Anonymize user record - - this.logger.warn(`User ${userId} marked for deletion. Full implementation pending.`); - this.logger.log(`Data deletion initiated for user ${userId}`); - } catch (error: any) { - this.logger.error(`Data deletion failed for user ${userId}: ${error.message}`, error.stack); - throw error; - } + return { userId, erasedAt: new Date().toISOString(), deleted, anonymised }; } /** - * Record or update consent (GDPR Article 7 - Conditions for consent) + * Nombre de lignes réellement touchées. + * + * Le pilote PostgreSQL de TypeORM renvoie `[lignes, nombre]` pour un UPDATE + * ou un DELETE, et la seule liste de lignes pour un SELECT. Compter la + * longueur du résultat donnerait donc « 2 » à chaque effacement, quel que + * soit le nombre réel — un rapport de conformité faux. + */ + private async affected( + manager: EntityManager, + sql: string, + parameters: unknown[] + ): Promise { + const result = await manager.query(sql, parameters); + return Array.isArray(result) && typeof result[1] === 'number' ? result[1] : 0; + } + + /** + * Journal des demandes de droits, pour la console de conformité. + * + * Lu en SQL depuis `audit_logs` plutôt que via le dépôt d'audit : le filtre + * porte sur un préfixe d'action, que l'interface de dépôt n'expose pas. + */ + async listRightsRequests(limit = 100): Promise[]> { + return this.dataSource.query( + `SELECT action, status, user_id, user_email, organization_id, metadata, timestamp + FROM audit_logs WHERE action LIKE 'gdpr\\_%' ORDER BY timestamp DESC LIMIT $1`, + [limit] + ); + } + + /** + * Enregistre le consentement et sa date (art. 7.1 — preuve du consentement). + * + * Sans entrée d'audit : la ligne de consentement porte déjà l'horodatage, + * l'adresse IP et le navigateur, c'est-à-dire exactement la preuve attendue. + * Un second enregistrement n'ajouterait rien qu'une écriture par visite. */ async recordConsent(userId: string, consentData: UpdateConsentDto): Promise { - this.logger.log(`Recording consent for user ${userId}`); + const existing = await this.consentRepository.findOne({ where: { userId } }); + const consent = existing ?? this.consentRepository.create({ id: uuidv4(), userId }); - // Verify user exists - const user = await this.userRepository.findOne({ where: { id: userId } }); - if (!user) { - throw new NotFoundException('User not found'); - } + consent.essential = true; // Sans elles le service ne fonctionne pas : pas de choix à recueillir. + consent.functional = consentData.functional ?? false; + consent.analytics = consentData.analytics ?? false; + consent.marketing = consentData.marketing ?? false; + consent.ipAddress = consentData.ipAddress ?? consent.ipAddress; + consent.userAgent = consentData.userAgent ?? consent.userAgent; + consent.consentDate = new Date(); - // Check if consent already exists - let consent = await this.consentRepository.findOne({ where: { userId } }); - - if (consent) { - // Update existing consent - consent.essential = true; // Always true - consent.functional = consentData.functional; - consent.analytics = consentData.analytics; - consent.marketing = consentData.marketing; - consent.ipAddress = consentData.ipAddress || consent.ipAddress; - consent.userAgent = consentData.userAgent || consent.userAgent; - consent.consentDate = new Date(); - - await this.consentRepository.save(consent); - this.logger.log(`Consent updated for user ${userId}`); - } else { - // Create new consent record - consent = this.consentRepository.create({ - id: uuidv4(), - userId, - essential: true, // Always true - functional: consentData.functional, - analytics: consentData.analytics, - marketing: consentData.marketing, - ipAddress: consentData.ipAddress, - userAgent: consentData.userAgent, - consentDate: new Date(), - }); - - await this.consentRepository.save(consent); - this.logger.log(`New consent created for user ${userId}`); - } - - return { - userId, - essential: consent.essential, - functional: consent.functional, - analytics: consent.analytics, - marketing: consent.marketing, - consentDate: consent.consentDate, - updatedAt: consent.updatedAt, - }; + await this.consentRepository.save(consent); + return this.toConsentDto(consent); } /** - * Withdraw specific consent (GDPR Article 7.3 - Withdrawal of consent) + * Retrait du consentement (art. 7.3) : aussi simple à retirer qu'à donner. + * Sans catégorie précisée, tout ce qui est facultatif est retiré. */ async withdrawConsent( userId: string, - consentType: 'functional' | 'analytics' | 'marketing' + consentType?: 'functional' | 'analytics' | 'marketing' ): Promise { - this.logger.log(`Withdrawing ${consentType} consent for user ${userId}`); + const current = await this.consentRepository.findOne({ where: { userId } }); - // Verify user exists - const user = await this.userRepository.findOne({ where: { id: userId } }); - if (!user) { - throw new NotFoundException('User not found'); - } - - // Find consent record - let consent = await this.consentRepository.findOne({ where: { userId } }); - - if (!consent) { - // Create default consent with withdrawn type - consent = this.consentRepository.create({ - id: uuidv4(), - userId, - essential: true, - functional: consentType === 'functional' ? false : false, - analytics: consentType === 'analytics' ? false : false, - marketing: consentType === 'marketing' ? false : false, - consentDate: new Date(), - }); - } else { - // Update specific consent type - consent[consentType] = false; - consent.consentDate = new Date(); - } - - await this.consentRepository.save(consent); - this.logger.log(`${consentType} consent withdrawn for user ${userId}`); - - return { - userId, - essential: consent.essential, - functional: consent.functional, - analytics: consent.analytics, - marketing: consent.marketing, - consentDate: consent.consentDate, - updatedAt: consent.updatedAt, + const next: UpdateConsentDto = { + essential: true, + functional: consentType ? consentType !== 'functional' && (current?.functional ?? false) : false, + analytics: consentType ? consentType !== 'analytics' && (current?.analytics ?? false) : false, + marketing: consentType ? consentType !== 'marketing' && (current?.marketing ?? false) : false, }; + + return this.recordConsent(userId, next); } - /** - * Get current consent status - */ async getConsentStatus(userId: string): Promise { - // Verify user exists - const user = await this.userRepository.findOne({ where: { id: userId } }); - if (!user) { - throw new NotFoundException('User not found'); - } - - // Find consent record const consent = await this.consentRepository.findOne({ where: { userId } }); + return consent ? this.toConsentDto(consent) : null; + } - if (!consent) { - // No consent recorded yet - return null to indicate user should provide consent - return null; - } - + private toConsentDto(consent: CookieConsentOrmEntity): ConsentResponseDto { return { - userId, + userId: consent.userId, essential: consent.essential, functional: consent.functional, analytics: consent.analytics,