/** * Politique de conservation et d'effacement. * * ⚠️ Ce fichier traduit en code des choix **juridiques**, pas techniques. Les * durées ci-dessous doivent être validées par un conseil avant mise en * production : elles sont regroupées ici précisément pour être relues d'un * seul tenant, plutôt que dispersées dans les services. * * L'effacement (RGPD art. 17) ne peut pas être un `DELETE` généralisé : une * partie des données répond à une obligation légale de conservation qui prime * sur la demande d'effacement (art. 17.3.b). D'où deux traitements distincts : * * - **Effacé** : ce qui n'est conservé que pour le service. Disparaît. * - **Anonymisé** : ce qui doit être conservé, mais peut l'être sans rattachement * à une personne. Les pièces comptables gardent leur valeur probante sans * l'identité du demandeur. * * Une donnée anonymisée n'est plus une donnée personnelle : la conserver * ensuite ne relève plus du RGPD. C'est ce qui rend l'arbitrage tenable. */ export interface RetentionRule { /** Table concernée. */ table: string; /** Ce que devient la donnée à la demande d'effacement. */ onErasure: 'delete' | 'anonymise' | 'keep'; /** * Durée de conservation en mois, `null` si liée à la vie du compte. * * À ne pas confondre avec `onErasure` : celui-ci décrit la réponse à une * demande de la personne, celle-ci la limite au-delà de laquelle la donnée * n'a plus de raison d'être conservée, même sans demande (art. 5.1.e). */ months: number | null; /** * Colonne horodatée qui fait courir le délai. `null` lorsque la durée est * liée à la vie du compte : il n'y a alors rien à purger dans le temps. */ timestampColumn: string | null; /** * Condition SQL désignant les lignes que la purge ne doit jamais emporter, * même une fois le délai écoulé. `null` quand toute la table suit la règle. */ keepWhere: string | null; /** Pourquoi cette durée — la justification attendue par l'art. 30. */ basis: string; } export const RETENTION_RULES: readonly RetentionRule[] = [ { table: 'users', keepWhere: null, timestampColumn: null, onErasure: 'anonymise', months: null, basis: "Le compte est anonymisé plutôt que supprimé : les réservations y font référence et doivent rester rattachables à une pièce comptable, sans l'identité de la personne.", }, { table: 'csv_bookings', keepWhere: null, timestampColumn: 'created_at', onErasure: 'anonymise', months: 120, basis: 'Pièce commerciale et comptable. Le code de commerce français impose dix ans de conservation des documents comptables (art. L123-22).', }, { table: 'audit_logs', keepWhere: "action NOT LIKE 'gdpr\\_%'", timestampColumn: 'timestamp', onErasure: 'anonymise', months: 12, basis: "Journal de sécurité : nécessaire à la détection d'accès illégitimes (art. 32), et attendu par la CNIL avec une durée de six mois à un an.", }, { table: 'notifications', keepWhere: null, timestampColumn: 'created_at', onErasure: 'delete', months: 12, basis: 'Confort de service, sans valeur probante : rien ne justifie de les conserver.', }, { table: 'trade_conversations', keepWhere: null, timestampColumn: 'updated_at', onErasure: 'delete', months: 12, basis: "Échanges avec l'assistant IA. Conservés pour que la personne retrouve ses conversations, sans obligation légale : ils s'effacent à la demande.", }, { table: 'api_keys', keepWhere: null, timestampColumn: null, onErasure: 'delete', months: null, basis: "Moyen d'accès : il disparaît avec le compte.", }, { table: 'cookie_consents', keepWhere: null, timestampColumn: 'consent_date', onErasure: 'delete', months: 13, basis: 'Preuve du consentement (art. 7.1). La CNIL recommande de conserver cette preuve tant que le consentement est valable, soit treize mois.', }, ]; /** Règle dont le délai peut être appliqué dans le temps, sans demande. */ export interface PurgeableRule extends RetentionRule { months: number; timestampColumn: string; } /** * Règles que la purge périodique peut appliquer. * * Les tables dont la durée est liée à la vie du compte en sont exclues : leur * point de départ n'est pas une date en base, mais la fermeture du compte, que * l'effacement traite déjà. */ export function purgeableRules(rules: readonly RetentionRule[] = RETENTION_RULES): PurgeableRule[] { return rules.filter( (rule): rule is PurgeableRule => rule.months !== null && rule.timestampColumn !== null ); } /** * Les noms de table et de colonne sont interpolés dans du SQL — un paramètre * lié ne peut pas porter un identifiant. Ils viennent de constantes, mais le * jour où une règle sera renseignée depuis une configuration, cette barrière * sera déjà là. */ const SAFE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/; export function assertSafeIdentifier(value: string): string { if (!SAFE_IDENTIFIER.test(value)) { throw new Error(`Identifiant SQL refusé par la politique de conservation : ${value}`); } return value; } /** Valeur substituée aux données identifiantes lors d'une anonymisation. */ export const ANONYMISED = 'anonymised'; /** * Adresse de remplacement d'un compte effacé. * * Unique par compte : la colonne `email` porte une contrainte d'unicité, et * deux effacements successifs échoueraient sur une valeur constante. Elle ne * permet aucun rattachement — l'identifiant technique existait déjà en base. */ export const anonymisedEmail = (userId: string): string => `${ANONYMISED}+${userId}@invalid.local`; /** Le compte portant cette adresse a-t-il été effacé ? */ export const isAnonymisedEmail = (email: string): boolean => email.startsWith(`${ANONYMISED}+`) && email.endsWith('@invalid.local');