diff --git a/apps/backend/src/domain/entities/audit-log.entity.ts b/apps/backend/src/domain/entities/audit-log.entity.ts index bb5e922..a5831bb 100644 --- a/apps/backend/src/domain/entities/audit-log.entity.ts +++ b/apps/backend/src/domain/entities/audit-log.entity.ts @@ -46,6 +46,15 @@ export enum AuditAction { // Agent actions — toute capacite invoquee par un agent, via MCP ou via // l'assistant integre. Le nom de la capacite est dans `resourceName`. AGENT_CAPABILITY_INVOKED = 'agent_capability_invoked', + + // Droits des personnes (RGPD). L'article 5.2 impose de pouvoir demontrer + // qu'une demande a ete traitee : sans trace, honorer un droit et l'ignorer + // se ressemblent. La trace d'un effacement porte l'identifiant technique et + // l'adresse anonymisee, jamais l'identite effacee. + GDPR_DATA_EXPORTED = 'gdpr_data_exported', + GDPR_ERASURE_EXECUTED = 'gdpr_erasure_executed', + GDPR_CONSENT_RECORDED = 'gdpr_consent_recorded', + GDPR_RETENTION_PURGE = 'gdpr_retention_purge', } export enum AuditStatus { diff --git a/apps/backend/src/domain/services/data-retention.ts b/apps/backend/src/domain/services/data-retention.ts new file mode 100644 index 0000000..fb0bc97 --- /dev/null +++ b/apps/backend/src/domain/services/data-retention.ts @@ -0,0 +1,157 @@ +/** + * 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`;