xpeditis2.0/apps/backend/src/domain/services/data-retention.ts
David 5a2fb7db8c feat(domain): politique de conservation et d effacement
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
2026-09-07 21:40:57 +02:00

158 lines
5.7 KiB
TypeScript

/**
* 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`;