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:
parent
5a2fb7db8c
commit
917220c419
@ -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.
|
||||||
// User data
|
res.send('' + toCsv(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`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete user data (GDPR Right to Erasure)
|
* 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')
|
@Delete('delete-account')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Effacer son compte et ses données' })
|
||||||
summary: 'Delete user account and data',
|
@ApiResponse({ status: 200, description: 'Effacement appliqué' })
|
||||||
description: 'Permanently delete or anonymize user data (GDPR Article 17)',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 204,
|
|
||||||
description: 'Account deletion initiated',
|
|
||||||
})
|
|
||||||
async deleteAccount(
|
async deleteAccount(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Body() body: { reason?: string; confirmEmail: string }
|
@Body() body: DeleteAccountDto
|
||||||
): Promise<void> {
|
): Promise<GDPRErasureReport> {
|
||||||
// Verify email confirmation (security measure)
|
// Confirmation par saisie de l'adresse : l'effacement est irréversible.
|
||||||
if (body.confirmEmail !== user.email) {
|
// `new Error` remontait ici en « Internal server error » — une erreur de
|
||||||
throw new Error('Email confirmation does not match');
|
// 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.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.gdprService.deleteUserData(user.id, body.reason);
|
return this.gdprService.deleteUserData(user.id, body.reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record consent
|
* 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.
|
||||||
*/
|
*/
|
||||||
|
@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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
}
|
||||||
|
|||||||
27
apps/backend/src/application/dto/delete-account.dto.ts
Normal file
27
apps/backend/src/application/dto/delete-account.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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 {}
|
||||||
|
|||||||
249
apps/backend/src/application/services/gdpr.service.spec.ts
Normal file
249
apps/backend/src/application/services/gdpr.service.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
address_street, address_city, address_postal_code, address_country
|
||||||
|
FROM organizations WHERE id = $1`,
|
||||||
|
[user.organizationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
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: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
|
phoneNumber: user.phoneNumber,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
organizationId: user.organizationId,
|
preferredLanguage: user.preferredLanguage,
|
||||||
|
isEmailVerified: user.isEmailVerified,
|
||||||
|
isActive: user.isActive,
|
||||||
|
lastLoginAt: user.lastLoginAt,
|
||||||
createdAt: user.createdAt,
|
createdAt: user.createdAt,
|
||||||
updatedAt: user.updatedAt,
|
updatedAt: user.updatedAt,
|
||||||
// Password hash explicitly excluded for security
|
},
|
||||||
};
|
organisation: organisation ?? null,
|
||||||
|
bookings,
|
||||||
const exportData: GDPRDataExport = {
|
notifications,
|
||||||
exportDate: new Date().toISOString(),
|
assistantConversations,
|
||||||
userId,
|
apiKeys,
|
||||||
userData: sanitizedUser,
|
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> {
|
||||||
this.logger.warn(
|
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||||
`Initiating data deletion for user ${userId}. Reason: ${reason || 'User request'}`
|
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]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify user exists
|
// Anonymisé : conservé, sans rattachement à la personne.
|
||||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
// Les notes sont un champ libre : c'est le seul endroit d'une
|
||||||
if (!user) {
|
// réservation où une donnée personnelle peut avoir été saisie.
|
||||||
throw new NotFoundException('User not found');
|
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]
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
// Le compte : identité remplacée, accès rendu impossible.
|
||||||
// Delete consent data first (will cascade with user deletion)
|
// Le mot de passe reçoit une valeur aléatoire plutôt que NULL — la
|
||||||
await this.consentRepository.delete({ userId });
|
// 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()}`]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// IMPORTANT: In production, implement full data anonymization
|
this.logger.warn(
|
||||||
// For now, we just mark the account for deletion
|
`GDPR erasure completed for user ${userId}: ${JSON.stringify({ deleted, anonymised })}`
|
||||||
// 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.`);
|
// Écrite après la transaction, et avec l'adresse anonymisée : journaliser
|
||||||
this.logger.log(`Data deletion initiated for user ${userId}`);
|
// avant l'effacement ferait disparaître la trace par l'anonymisation des
|
||||||
} catch (error: any) {
|
// journaux, et y inscrire l'adresse réelle réintroduirait l'identité qu'on
|
||||||
this.logger.error(`Data deletion failed for user ${userId}: ${error.message}`, error.stack);
|
// vient d'effacer. Ce qu'il faut pouvoir démontrer, c'est que la demande a
|
||||||
throw error;
|
// é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 },
|
||||||
|
});
|
||||||
|
|
||||||
|
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.
|
||||||
*/
|
*/
|
||||||
async recordConsent(userId: string, consentData: UpdateConsentDto): Promise<ConsentResponseDto> {
|
private async affected(
|
||||||
this.logger.log(`Recording consent for user ${userId}`);
|
manager: EntityManager,
|
||||||
|
sql: string,
|
||||||
// Verify user exists
|
parameters: unknown[]
|
||||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
): Promise<number> {
|
||||||
if (!user) {
|
const result = await manager.query(sql, parameters);
|
||||||
throw new NotFoundException('User not found');
|
return Array.isArray(result) && typeof result[1] === 'number' ? result[1] : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if consent already exists
|
/**
|
||||||
let consent = await this.consentRepository.findOne({ where: { userId } });
|
* 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]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (consent) {
|
/**
|
||||||
// Update existing consent
|
* Enregistre le consentement et sa date (art. 7.1 — preuve du consentement).
|
||||||
consent.essential = true; // Always true
|
*
|
||||||
consent.functional = consentData.functional;
|
* Sans entrée d'audit : la ligne de consentement porte déjà l'horodatage,
|
||||||
consent.analytics = consentData.analytics;
|
* l'adresse IP et le navigateur, c'est-à-dire exactement la preuve attendue.
|
||||||
consent.marketing = consentData.marketing;
|
* Un second enregistrement n'ajouterait rien qu'une écriture par visite.
|
||||||
consent.ipAddress = consentData.ipAddress || consent.ipAddress;
|
*/
|
||||||
consent.userAgent = consentData.userAgent || consent.userAgent;
|
async recordConsent(userId: string, consentData: UpdateConsentDto): Promise<ConsentResponseDto> {
|
||||||
|
const existing = await this.consentRepository.findOne({ where: { userId } });
|
||||||
|
const consent = existing ?? this.consentRepository.create({ id: uuidv4(), userId });
|
||||||
|
|
||||||
|
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();
|
consent.consentDate = new Date();
|
||||||
|
|
||||||
await this.consentRepository.save(consent);
|
await this.consentRepository.save(consent);
|
||||||
this.logger.log(`Consent updated for user ${userId}`);
|
return this.toConsentDto(consent);
|
||||||
} 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 } });
|
|
||||||
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,
|
essential: true,
|
||||||
functional: consentType === 'functional' ? false : false,
|
functional: consentType ? consentType !== 'functional' && (current?.functional ?? false) : false,
|
||||||
analytics: consentType === 'analytics' ? false : false,
|
analytics: consentType ? consentType !== 'analytics' && (current?.analytics ?? false) : false,
|
||||||
marketing: consentType === 'marketing' ? false : false,
|
marketing: consentType ? consentType !== 'marketing' && (current?.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) {
|
|
||||||
// No consent recorded yet - return null to indicate user should provide consent
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private toConsentDto(consent: CookieConsentOrmEntity): ConsentResponseDto {
|
||||||
return {
|
return {
|
||||||
userId,
|
userId: consent.userId,
|
||||||
essential: consent.essential,
|
essential: consent.essential,
|
||||||
functional: consent.functional,
|
functional: consent.functional,
|
||||||
analytics: consent.analytics,
|
analytics: consent.analytics,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user