Suppression RGPD
Un citoyen peut demander l'effacement de son compte au titre de l'article 17 du RGPD. La suppression se fait en deux temps pour éviter une suppression accidentelle ou malveillante : une demande authentifiée produit un jeton à usage unique envoyé par email, puis la confirmation avec ce jeton anonymise le compte et révoque toutes ses sessions. Le compte n'est pas supprimé physiquement : ses identifiants personnels sont effacés et la ligne est marquée deletedAt.
Diagramme de séquence
sequenceDiagram autonumber participant C as Client web ou mobile participant API as API user participant US as UserService participant UR as UserRepository participant DB as PostgreSQL participant Q as File emails-queue participant SS as SessionService participant R as Redis C->>API: POST /user/gdpr/delete-request avec Bearer API->>US: requestGdprAccountDeletion(userId) US->>UR: getUserFullProfile US->>US: generateRandomToken 32 octets US->>UR: createDeletionToken, sha256 du jeton, 24 h UR->>DB: Token ACCOUNT_DELETION US->>Q: enqueueEmail gdpr-deletion avec le jeton US-->>C: 200 confirmationRequired, confirmationToken hors production C->>API: POST /user/gdpr/confirm-delete token, sans authentification API->>US: confirmGdprAccountDeletion(token) US->>UR: findValidDeletionToken, sha256, type, échéance US->>UR: anonymizeUserAccount(userId, tokenId) UR->>DB: transaction : anonymisation du User, suppression de IdentityDocument et du Token US->>SS: revokeAllForUser(userId) SS->>R: auth:revoked-before:userId, 30 jours US-->>C: 200 deleted, anonymized, deletedAt
Étape 1 : demande
Interface. La page frontend-web/src/app/(citoyen)/profil/rgpd/page.tsx et l'écran Données personnelles du mobile (mobile-app/src/screens/settings/PersonalDataScreen.tsx) appellent /user/gdpr/delete-request puis proposent de coller le jeton. Hors production, la page web préremplit le jeton renvoyé par l'API.
Route. POST /api/v1/user/gdpr/delete-request dans backend/src/api/user/user.routes.ts, protégée par authenticate seulement : pas de schéma de corps, pas d'idempotence.
Service. userService.requestGdprAccountDeletion (backend/src/api/user/user.services.ts) :
userRepository.getUserFullProfile: 404 si le compte n'existe pas.generateRandomToken(32)(backend/src/utils/crypto.util.ts) tire 32 octets aléatoires encodés en base64url.userRepository.createDeletionToken(backend/src/api/user/user.repo.ts) enregistre dansTokenl'empreinte SHA-256 du jeton (jamais le jeton en clair), le typeACCOUNT_DELETIONet une échéance de 24 h.- Si le compte a un email, l'email
gdpr-deletion(backend/src/templates/emails/gdpr-deletion.html) est mis en file avec le jeton et un lienCONFIRM_URL. - La réponse contient
confirmationRequired: true,emailSentet, quandNODE_ENVn'est pasproduction,confirmationTokenen clair pour faciliter les tests.
Attention
Le lien de l'email pointe vers ${FRONTEND_URL}/gdpr/confirm-delete?token=.... Cette route n'existe pas dans le frontend web, qui affiche alors sa page 404. La confirmation ne fonctionne qu'en collant le jeton dans la page profil/rgpd ou dans l'écran mobile.
Étape 2 : confirmation
Route. POST /api/v1/user/gdpr/confirm-delete avec validate(confirmGdprDeletionSchema) : token de 10 à 128 caractères. Cette route n'exige pas d'être connecté : la possession du jeton suffit, ce qui permet de confirmer depuis un autre appareil.
Service. userService.confirmGdprAccountDeletion :
findValidDeletionTokencherche unTokendont l'empreinte SHA-256 correspond, de typeACCOUNT_DELETIONet non expiré. Sinon : 400 « Jeton de suppression RGPD invalide ou expiré ».anonymizeUserAccount(userId, tokenId)exécute une transaction qui met à jour le compte, supprime sa pièce d'identité et supprime le jeton utilisé.sessionService.revokeAllForUser(backend/src/services/session.service.ts) écritauth:revoked-before:<userId>pour 30 jours : tout jeton émis avant cet instant est refusé parauthenticate.- Réponse 200 avec
deleted: true,anonymized: true,userIdetdeletedAt.
Ce qui est effacé et ce qui reste
| Donnée | Après confirmation |
|---|---|
User.email |
null |
User.walletAddress |
Remplacée par 0xdeleted_gdpr_ suivi des 8 premiers caractères de l'identifiant |
User.walletEncryptedKey, welcomeGrantTxHash |
null : la clé privée du portefeuille intégré est détruite |
twoFactorSecret, twoFactorMethod, twoFactorBackupCodes, isTwoFactorEnabled |
Effacés ou désactivés |
User.passwordHash, firstName, lastName, ageVerificationNote |
null |
IdentityDocument du compte |
Supprimé, avec les images si la Mairie n'avait pas encore statué |
ageVerificationStatus, ageVerifiedAt |
Conservés |
User.deletedAt |
Date de la suppression ; getProfile répond alors 404 et requireRoles refuse le compte |
| Jeton de suppression utilisé | Supprimé |
role, pointsBalance, referralCode, referredById, dates |
Conservés |
Ticket, PointTransaction, ClaimedReward, Analytics |
Conservés, toujours rattachés à l'identifiant du compte ; les entrées d'audit de la Mairie sur la pièce d'identité, motif de refus compris, restent aussi |
Clés Redis user:prefs:<id>, user:quests:<id> |
Conservées |
| Données on-chain : adresse du portefeuille dans les événements, tickets, badges POAP | Conservées : la blockchain est immuable |
Les tickets gardent dans payerWallet l'adresse d'origine du portefeuille, et la chaîne relie cette adresse à tous les achats. L'anonymisation est donc partielle : c'est une pseudonymisation du compte, la portée juridique est discutée dans Blockchain et données personnelles.
Danger
Les ethers fictifs restant sur le portefeuille intégré deviennent irrécupérables, puisque la clé est détruite. Si le citoyen gagne un tour encore en cours ou attend un remboursement, le job CLAIM_PRIZE ou CLAIM_REFUND ne trouve plus de compte pour son adresse et se termine sans rien faire : les fonds restent bloqués dans le contrat. Sur un réseau principal, il faudrait proposer au citoyen de vider son portefeuille vers une adresse de son choix avant la suppression.
Erreurs possibles
| Étape | Code | Cause |
|---|---|---|
| Demande | 401 | Jeton de session absent ou révoqué |
| Demande | 404 | Compte introuvable |
| Confirmation | 400 | Jeton de moins de 10 caractères, inconnu, expiré ou déjà utilisé |
| Confirmation | 409 | Adresse anonymisée déjà prise par un autre compte supprimé (voir ci-dessous) |
| Les deux | 429 | Rate limit global : 300 requêtes par 15 minutes et par IP |
Attention
L'adresse anonymisée n'est pas unique en pratique. Les identifiants sont des UUID version 7 (@default(uuid(7)) dans backend/prisma/schema.prisma), dont les 8 premiers caractères hexadécimaux codent l'horodatage de création et ne changent que toutes les 65 secondes environ. Deux comptes créés dans la même minute, ce qui est le cas de tous les comptes du seed, reçoivent la même adresse anonymisée. La suppression du second échoue alors sur l'unicité de walletAddress et répond 409, sans rien effacer.
Idempotence
- Chaque demande crée un nouveau jeton sans invalider les précédents : plusieurs jetons peuvent être valides en même temps pendant 24 h.
- Un jeton ne sert qu'une fois : il est supprimé dans la même transaction que l'anonymisation. Rejouer la confirmation renvoie 400.
- Un autre jeton encore valide du même compte relance l'anonymisation, qui réécrit les mêmes valeurs et réussit.
- La demande n'est pas protégée par
Idempotency-Key: un double envoi produit deux jetons et deux emails.
Export avant suppression
Le droit à la portabilité est servi par GET /api/v1/user/gdpr/export, qui renvoie en JSON le profil (sans la clé chiffrée ni le secret 2FA), les tickets avec leur tour, le journal des EtherPoints, les récompenses échangées et les événements analytiques. Voir Utilisateur et RGPD et RGPD.