feat(api): effacement reel et export complet des donnees

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
This commit is contained in:
David 2026-09-07 21:40:57 +02:00
parent 5a2fb7db8c
commit 917220c419
5 changed files with 759 additions and 303 deletions

View File

@ -1,169 +1,183 @@
/** /**
* GDPR Controller * Droits des personnes (RGPD) : accès et portabilité, effacement, consentement.
*
* Endpoints for GDPR compliance (data export, deletion, consent)
*/ */
import { import {
Controller, BadRequestException,
Get,
Post,
Delete,
Body, Body,
UseGuards, Controller,
Delete,
Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Res, Post,
Req, Req,
Res,
UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { Response, Request } from 'express'; import { Response, Request } from 'express';
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { CurrentUser } from '../decorators/current-user.decorator'; import { RolesGuard } from '../guards/roles.guard';
import { UserPayload } from '../decorators/current-user.decorator'; import { Roles } from '../decorators/roles.decorator';
import { GDPRService } from '../services/gdpr.service'; 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 { 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') @ApiTags('GDPR')
@Controller('gdpr') @Controller('gdpr')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth() @ApiBearerAuth()
export class GDPRController { export class GDPRController {
constructor(private readonly gdprService: GDPRService) {} constructor(
private readonly gdprService: GDPRService,
private readonly retentionService: RetentionService
) {}
/** /** Export de portabilité au format JSON (art. 20). */
* Export user data (GDPR Right to Data Portability)
*/
@Get('export') @Get('export')
@ApiOperation({ @ApiOperation({ summary: 'Exporter ses données personnelles (JSON)' })
summary: 'Export all user data', @ApiResponse({ status: 200, description: 'Export produit' })
description: 'Export all personal data in JSON format (GDPR Article 20)',
})
@ApiResponse({
status: 200,
description: 'Data export successful',
})
async exportData(@CurrentUser() user: UserPayload, @Res() res: Response): Promise<void> { async exportData(@CurrentUser() user: UserPayload, @Res() res: Response): Promise<void> {
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; charset=utf-8');
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Disposition', `attachment; filename="xpeditis-donnees-${day}.json"`);
res.setHeader( res.json(data);
'Content-Disposition',
`attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.json"`
);
res.json(exportData);
} }
/** /**
* 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') @Get('export/csv')
@ApiOperation({ @ApiOperation({ summary: 'Exporter ses données personnelles (CSV)' })
summary: 'Export user data as CSV', @ApiResponse({ status: 200, description: 'Export produit' })
description: 'Export personal data in CSV format for easy viewing',
})
@ApiResponse({
status: 200,
description: 'CSV export successful',
})
async exportDataCSV(@CurrentUser() user: UserPayload, @Res() res: Response): Promise<void> { async exportDataCSV(@CurrentUser() user: UserPayload, @Res() res: Response): Promise<void> {
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) res.setHeader('Content-Type', 'text/csv; charset=utf-8');
let csv = 'Category,Field,Value\n'; 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]) => { * Effacement (art. 17).
csv += `User Data,${key},"${value}"\n`; *
}); * 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
// Cookie consent data * seule réponse à une demande d'effacement, sans moyen de vérifier ce qui
if (exportData.cookieConsent) { * avait effectivement été traité.
Object.entries(exportData.cookieConsent).forEach(([key, value]) => { */
csv += `Cookie Consent,${key},"${value}"\n`; @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<GDPRErasureReport> {
// 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 return this.gdprService.deleteUserData(user.id, body.reason);
res.setHeader('Content-Type', 'text/csv');
res.setHeader(
'Content-Disposition',
`attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.csv"`
);
res.send(csv);
} }
/** /**
* 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') @Get('retention')
@HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Durées de conservation appliquées' })
@ApiOperation({ @ApiResponse({ status: 200, description: 'Politique de conservation' })
summary: 'Delete user account and data', getRetentionPolicy(): { rules: typeof RETENTION_RULES } {
description: 'Permanently delete or anonymize user data (GDPR Article 17)', return { rules: RETENTION_RULES };
})
@ApiResponse({
status: 204,
description: 'Account deletion initiated',
})
async deleteAccount(
@CurrentUser() user: UserPayload,
@Body() body: { reason?: string; confirmEmail: string }
): Promise<void> {
// 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);
} }
/** /**
* 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<string, unknown>[] }> {
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<RetentionReport> {
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<RetentionReport> {
return this.retentionService.purge();
}
/** Recueil du consentement cookies (art. 7). */
@Post('consent') @Post('consent')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ @ApiOperation({ summary: 'Enregistrer ses préférences de cookies' })
summary: 'Record user consent', @ApiResponse({ status: 200, type: ConsentResponseDto })
description: 'Record consent for cookies (GDPR Article 7)',
})
@ApiResponse({
status: 200,
description: 'Consent recorded',
type: ConsentResponseDto,
})
async recordConsent( async recordConsent(
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload,
@Body() body: UpdateConsentDto, @Body() body: UpdateConsentDto,
@Req() req: Request @Req() req: Request
): Promise<ConsentResponseDto> { ): Promise<ConsentResponseDto> {
// Add IP and user agent from request if not provided return this.gdprService.recordConsent(user.id, {
const consentData: UpdateConsentDto = {
...body, ...body,
ipAddress: body.ipAddress || req.ip || req.socket.remoteAddress, ipAddress: body.ipAddress || req.ip || req.socket.remoteAddress,
userAgent: body.userAgent || req.headers['user-agent'], userAgent: body.userAgent || req.headers['user-agent'],
}; });
return this.gdprService.recordConsent(user.id, consentData);
} }
/** /** Retrait du consentement (art. 7.3). */
* Withdraw consent
*/
@Post('consent/withdraw') @Post('consent/withdraw')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ @ApiOperation({ summary: 'Retirer un consentement' })
summary: 'Withdraw consent', @ApiResponse({ status: 200, type: ConsentResponseDto })
description: 'Withdraw consent for functional, analytics, or marketing (GDPR Article 7.3)',
})
@ApiResponse({
status: 200,
description: 'Consent withdrawn',
type: ConsentResponseDto,
})
async withdrawConsent( async withdrawConsent(
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload,
@Body() body: WithdrawConsentDto @Body() body: WithdrawConsentDto
@ -171,20 +185,51 @@ export class GDPRController {
return this.gdprService.withdrawConsent(user.id, body.consentType); return this.gdprService.withdrawConsent(user.id, body.consentType);
} }
/**
* Get consent status
*/
@Get('consent') @Get('consent')
@ApiOperation({ @ApiOperation({ summary: 'Consulter ses préférences de cookies' })
summary: 'Get current consent status', @ApiResponse({ status: 200, type: ConsentResponseDto })
description: 'Retrieve current consent preferences',
})
@ApiResponse({
status: 200,
description: 'Consent status retrieved',
type: ConsentResponseDto,
})
async getConsentStatus(@CurrentUser() user: UserPayload): Promise<ConsentResponseDto | null> { async getConsentStatus(@CurrentUser() user: UserPayload): Promise<ConsentResponseDto | null> {
return this.gdprService.getConsentStatus(user.id); 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<string, unknown>) => {
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<string, unknown>[]][] = [
['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');
}

View File

@ -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;
}

View File

@ -6,26 +6,26 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from '../audit/audit.module';
import { GDPRController } from '../controllers/gdpr.controller'; import { GDPRController } from '../controllers/gdpr.controller';
import { GDPRService } from '../services/gdpr.service'; import { GDPRService } from '../services/gdpr.service';
import { RetentionService } from '../services/retention.service';
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; 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'; import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([ // Les autres tables touchees par l'effacement (csv_bookings, audit_logs,
UserOrmEntity, // notifications, trade_conversations, password_reset_tokens) sont lues en
BookingOrmEntity, // SQL via DataSource : la moitie d'entre elles n'a pas d'entite ORM, et
AuditLogOrmEntity, // BookingOrmEntity pointait vers une table `bookings` qui n'existe pas.
NotificationOrmEntity, TypeOrmModule.forFeature([UserOrmEntity, CookieConsentOrmEntity]),
CookieConsentOrmEntity, // Les demandes de droits sont journalisees : l'article 5.2 impose de
]), // pouvoir demontrer qu'elles ont ete traitees.
AuditModule,
], ],
controllers: [GDPRController], controllers: [GDPRController],
providers: [GDPRService], providers: [GDPRService, RetentionService],
exports: [GDPRService], exports: [GDPRService, RetentionService],
}) })
export class GDPRModule {} export class GDPRModule {}

View File

@ -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<void>) => 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<UserOrmEntity>;
const consentRepository = {
findOne: jest.fn(async () => null),
create: jest.fn((value: Partial<CookieConsentOrmEntity>) => ({ ...value })),
save: jest.fn(async (value: CookieConsentOrmEntity) => value),
} as unknown as Repository<CookieConsentOrmEntity>;
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);
});
});

View File

@ -1,26 +1,53 @@
/** /**
* GDPR Compliance Service * Droits des personnes (RGPD).
* *
* Handles data export, deletion, and consent management * Portabilité (art. 20), effacement (art. 17), preuve du consentement (art. 7).
* with full database persistence *
* 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 { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { DataSource, EntityManager, Repository } from 'typeorm';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity'; import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity';
import { UpdateConsentDto, ConsentResponseDto } from '../dto/consent.dto'; 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 { export interface GDPRDataExport {
exportDate: string; exportDate: string;
userId: string; userId: string;
userData: any; userData: Record<string, unknown>;
cookieConsent: any; organisation: Record<string, unknown> | null;
message: string; bookings: Record<string, unknown>[];
notifications: Record<string, unknown>[];
assistantConversations: Record<string, unknown>[];
apiKeys: Record<string, unknown>[];
activityLog: Record<string, unknown>[];
cookieConsent: Record<string, unknown> | null;
notice: string;
} }
/** Ce qui a été effacé ou anonymisé, rendu à la personne comme preuve. */
export interface GDPRErasureReport {
userId: string;
erasedAt: string;
deleted: Record<string, number>;
anonymised: Record<string, number>;
}
/**
* 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() @Injectable()
export class GDPRService { export class GDPRService {
private readonly logger = new Logger(GDPRService.name); private readonly logger = new Logger(GDPRService.name);
@ -29,41 +56,119 @@ export class GDPRService {
@InjectRepository(UserOrmEntity) @InjectRepository(UserOrmEntity)
private readonly userRepository: Repository<UserOrmEntity>, private readonly userRepository: Repository<UserOrmEntity>,
@InjectRepository(CookieConsentOrmEntity) @InjectRepository(CookieConsentOrmEntity)
private readonly consentRepository: Repository<CookieConsentOrmEntity> private readonly consentRepository: Repository<CookieConsentOrmEntity>,
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<GDPRDataExport> { async exportUserData(userId: string): Promise<GDPRDataExport> {
this.logger.log(`Exporting data for user ${userId}`);
// Fetch user data
const user = await this.userRepository.findOne({ where: { id: userId } }); const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) { if (!user) throw new NotFoundException('User not found');
throw new NotFoundException('User not found');
}
// Fetch consent data
const consent = await this.consentRepository.findOne({ where: { userId } }); const consent = await this.consentRepository.findOne({ where: { userId } });
// Sanitize user data (remove password hash) const [organisation] = await this.dataSource.query(
const sanitizedUser = { `SELECT id, name, type, siren, siret, eori, contact_email, contact_phone,
id: user.id, address_street, address_city, address_postal_code, address_country
email: user.email, FROM organizations WHERE id = $1`,
firstName: user.firstName, [user.organizationId]
lastName: user.lastName, );
role: user.role,
organizationId: user.organizationId,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
// Password hash explicitly excluded for security
};
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(), exportDate: new Date().toISOString(),
userId, 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 cookieConsent: consent
? { ? {
essential: consent.essential, essential: consent.essential,
@ -73,175 +178,205 @@ export class GDPRService {
consentDate: consent.consentDate, consentDate: consent.consentDate,
} }
: null, : null,
message: notice:
'User data exported successfully. Additional data (bookings, notifications) can be exported from respective endpoints.', "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) * Effacement (art. 17).
* Note: This is a simplified version. In production, implement full anonymization logic. *
* 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<void> { async deleteUserData(userId: string, reason?: string): Promise<GDPRErasureReport> {
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<string, number> = {};
const anonymised: Record<string, number> = {};
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( 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 // Écrite après la transaction, et avec l'adresse anonymisée : journaliser
const user = await this.userRepository.findOne({ where: { id: userId } }); // avant l'effacement ferait disparaître la trace par l'anonymisation des
if (!user) { // journaux, et y inscrire l'adresse réelle réintroduirait l'identité qu'on
throw new NotFoundException('User not found'); // 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 { return { userId, erasedAt: new Date().toISOString(), deleted, anonymised };
// 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;
}
} }
/** /**
* 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<number> {
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<Record<string, unknown>[]> {
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<ConsentResponseDto> { async recordConsent(userId: string, consentData: UpdateConsentDto): Promise<ConsentResponseDto> {
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 consent.essential = true; // Sans elles le service ne fonctionne pas : pas de choix à recueillir.
const user = await this.userRepository.findOne({ where: { id: userId } }); consent.functional = consentData.functional ?? false;
if (!user) { consent.analytics = consentData.analytics ?? false;
throw new NotFoundException('User not found'); 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 await this.consentRepository.save(consent);
let consent = await this.consentRepository.findOne({ where: { userId } }); return this.toConsentDto(consent);
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,
};
} }
/** /**
* 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( async withdrawConsent(
userId: string, userId: string,
consentType: 'functional' | 'analytics' | 'marketing' consentType?: 'functional' | 'analytics' | 'marketing'
): Promise<ConsentResponseDto> { ): Promise<ConsentResponseDto> {
this.logger.log(`Withdrawing ${consentType} consent for user ${userId}`); const current = await this.consentRepository.findOne({ where: { userId } });
// Verify user exists const next: UpdateConsentDto = {
const user = await this.userRepository.findOne({ where: { id: userId } }); essential: true,
if (!user) { functional: consentType ? consentType !== 'functional' && (current?.functional ?? false) : false,
throw new NotFoundException('User not found'); analytics: consentType ? consentType !== 'analytics' && (current?.analytics ?? false) : false,
} marketing: consentType ? consentType !== 'marketing' && (current?.marketing ?? false) : false,
// 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,
}; };
return this.recordConsent(userId, next);
} }
/**
* Get current consent status
*/
async getConsentStatus(userId: string): Promise<ConsentResponseDto | null> { async getConsentStatus(userId: string): Promise<ConsentResponseDto | null> {
// 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 } }); const consent = await this.consentRepository.findOne({ where: { userId } });
return consent ? this.toConsentDto(consent) : null;
}
if (!consent) { private toConsentDto(consent: CookieConsentOrmEntity): ConsentResponseDto {
// No consent recorded yet - return null to indicate user should provide consent
return null;
}
return { return {
userId, userId: consent.userId,
essential: consent.essential, essential: consent.essential,
functional: consent.functional, functional: consent.functional,
analytics: consent.analytics, analytics: consent.analytics,