From 6f3b630ced0cd3b10fb105055fe211da1bb1c660 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 07:43:30 +0000 Subject: [PATCH] =?UTF-8?q?D=C3=A9clarer=20et=20appliquer=20les=20dur?= =?UTF-8?q?=C3=A9es=20de=20conservation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La table RetentionPolicy existait et cinq durées y étaient semées depuis la matrice ; rien ne les appliquait. Une durée déclarée que personne n'exécute est une conformité de papier. Aucune durée par défaut n'est appliquée, et c'est le point central : la matrice interdit explicitement d'aligner tout sur cinq ans. Un objet sans politique déclarée se conserve, et l'écran le signale plutôt que de le taire. Symétrie inverse, tout aussi importante : effacer faute de règle serait aussi fautif que garder indéfiniment. La justification est obligatoire au niveau du serveur. Une durée sans motif est une durée qu'on ne saura pas défendre le jour d'un contrôle. Les politiques sont effectif-datées comme le reste de l'application : une pièce déposée en mars relève de la règle en vigueur en mars. Sans cela, un durcissement rétroactif purgerait ce que la règle du moment autorisait à garder. La résolution va du précis au général — Document:SICK_NOTE avant Document — car un arrêt de travail et un contrat n'ont aucune raison de se conserver aussi longtemps. Trois refus distincts plutôt qu'un seul : absence de politique, conservation suspendue à titre probatoire, échéance non atteinte. Les confondre sous « rien à purger » empêcherait de vérifier que la conservation est réellement tenue. Un quatrième existe : employee_departure est déclaré mais non calculable, PlanFlow ne modélisant pas de date de départ — purger sur une date inventée serait pire que ne pas purger, et l'écran l'affiche comme tel. La purge s'exécute en ligne de commande pour une tâche planifiée, la matrice demandant des purges automatiques ; un bouton qu'il faut penser à presser n'en est pas une. Elle passe par le client scopé et la RLS, compte par compte. Les tables append-only en sont exclues par construction : le journal d'audit doit survivre aux données qu'il décrit, sans quoi on ne pourrait plus démontrer que la purge a eu lieu. L'échéance affichée est dérivée de la politique, jamais stockée — même discipline que la péremption d'un export. Deux pièges rencontrés : un objet de composants exporté depuis un module client ne survit pas au passage par un composant serveur, React n'en recevant qu'un undefined ; et le minLength du navigateur masquait le contrôle serveur de la justification, que des espaces suffisent à contourner. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv --- README.md | 13 ++ package.json | 1 + scripts/retention-purge.ts | 58 ++++++ src/app/(app)/reglages/conservation/page.tsx | 170 ++++++++++++++++ src/components/settings/RetentionForms.tsx | 187 +++++++++++++++++ src/components/shell/navigation.ts | 5 + src/domain/retention/policy.ts | 157 ++++++++++++++ src/server/documents/queries.ts | 38 +++- src/server/retention/actions.ts | 186 +++++++++++++++++ src/server/retention/purge.ts | 129 ++++++++++++ tests/e2e/conservation.spec.ts | 202 +++++++++++++++++++ tests/e2e/support/db.ts | 29 +++ tests/unit/retention.test.ts | 202 +++++++++++++++++++ 13 files changed, 1371 insertions(+), 6 deletions(-) create mode 100644 scripts/retention-purge.ts create mode 100644 src/app/(app)/reglages/conservation/page.tsx create mode 100644 src/components/settings/RetentionForms.tsx create mode 100644 src/domain/retention/policy.ts create mode 100644 src/server/retention/actions.ts create mode 100644 src/server/retention/purge.ts create mode 100644 tests/e2e/conservation.spec.ts create mode 100644 tests/unit/retention.test.ts diff --git a/README.md b/README.md index fb21299..fbdbae5 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,19 @@ pnpm mfa:reset adresse@example.fr Le retrait révoque les sessions ouvertes et s'inscrit au journal d'audit. Il n'est délibérément pas exposé dans l'application : l'exécuter demande déjà un accès au serveur, c'est-à-dire davantage que ce que le second facteur protège. +### Durées de conservation + +Aucune durée n'est appliquée par défaut : la matrice interdit d'aligner tout sur cinq ans, et un objet sans politique déclarée se conserve. Les durées se déclarent dans Réglages → Durées de conservation, chacune avec sa justification — elle devra être défendue lors d'un contrôle. + +La purge doit tourner périodiquement, par exemple en `cron` : + +```bash +pnpm retention:purge --dry # inventaire, n'efface rien +pnpm retention:purge # efface les pièces échues, compte par compte +``` + +Une conservation à titre probatoire (*legal hold*) suspend la purge des objets qu'elle vise, quelle que soit leur échéance. Les journaux d'audit y échappent par construction : ils doivent survivre aux données qu'ils décrivent, sans quoi il deviendrait impossible de démontrer que la purge a eu lieu. + ## Vérifier ```bash diff --git a/package.json b/package.json index 6c34425..a92abe3 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "db:seed": "tsx prisma/seed.ts", "db:studio": "prisma studio", "mfa:reset": "tsx scripts/mfa-reset.ts", + "retention:purge": "tsx scripts/retention-purge.ts", "verify": "pnpm typecheck && pnpm lint && pnpm test" }, "prisma": { diff --git a/scripts/retention-purge.ts b/scripts/retention-purge.ts new file mode 100644 index 0000000..7690097 --- /dev/null +++ b/scripts/retention-purge.ts @@ -0,0 +1,58 @@ +/** + * Purge périodique — PLAN.md §12.5, matrice n° 21. + * + * La matrice demande des purges **automatiques**. Un bouton qu'il faut penser à + * presser n'en est pas une : ce script est fait pour une tâche planifiée. + * + * pnpm retention:purge # tous les comptes + * pnpm retention:purge --dry # inventaire seulement + * + * Il passe par le client scopé et la RLS, compte par compte : une purge qui + * contournerait l'isolation serait le pire endroit où la perdre. + */ +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; + +import { inspectRetention, runRetentionPurge } from '../src/server/retention/purge'; +import { withTenant } from '../src/server/tenant'; + +const dryRun = process.argv.includes('--dry'); + +async function main(): Promise { + const prisma = new PrismaClient({ + adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), + }); + + const accounts = await prisma.account.findMany({ + select: { id: true, name: true }, + }); + + for (const account of accounts) { + await withTenant(account.id, async (db) => { + if (dryRun) { + const candidates = await inspectRetention(db); + const due = candidates.filter((entry) => entry.verdict === 'DUE'); + console.log( + `${account.name} — ${due.length} pièce(s) échue(s) sur ${candidates.length}`, + ); + for (const entry of due) { + console.log(` ${entry.name} (${entry.category}) — ${entry.dueVia}`); + } + return; + } + + // L'acteur est la purge elle-même : aucun humain ne décide pièce par + // pièce, et attribuer l'effacement à la dernière personne connectée + // fausserait le journal. + const report = await runRetentionPurge(db, 'retention'); + console.log(`${account.name} — ${report.purged} pièce(s) effacée(s)`); + }); + } + + await prisma.$disconnect(); +} + +main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/src/app/(app)/reglages/conservation/page.tsx b/src/app/(app)/reglages/conservation/page.tsx new file mode 100644 index 0000000..f9a2de1 --- /dev/null +++ b/src/app/(app)/reglages/conservation/page.tsx @@ -0,0 +1,170 @@ +import { + LegalHoldForm, + PurgeForm, + RetentionPolicyForm, +} from '@/components/settings/RetentionForms'; +import { PageBody, PageHeader } from '@/components/shell/PageHeader'; +import { Badge } from '@/components/ui/Badge'; +import { Card, CardHeader } from '@/components/ui/Card'; +import { + isComputable, + START_POINT_LABELS, + VERDICT_LABELS, + type StartPoint, +} from '@/domain/retention/policy'; +import { query } from '@/server/context'; +import { inspectRetention } from '@/server/retention/purge'; + +export const metadata = { title: 'Conservation · PlanFlow' }; +export const dynamic = 'force-dynamic'; + +const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }); + +export default async function RetentionPage() { + const { policies, candidates } = await query( + 'settings.access', + async (db) => ({ + policies: await db.retentionPolicy.findMany({ + orderBy: [{ objectType: 'asc' }, { effectiveFrom: 'desc' }], + }), + candidates: await inspectRetention(db), + }), + ); + + const due = candidates.filter((entry) => entry.verdict === 'DUE'); + const unpolicied = candidates.filter((entry) => entry.verdict === 'NO_POLICY'); + + return ( + + 0 ? ( + {due.length} pièce(s) échue(s) + ) : ( + Rien d’échu + ) + } + /> + +
+

+ Les minima légaux ne sont ni universels ni une autorisation de tout + garder. PlanFlow n’applique aucune durée par défaut : + un objet sans politique déclarée se conserve, et cet écran le signale. +

+

+ Les journaux d’audit échappent à la purge : ils doivent survivre aux + données qu’ils décrivent, sans quoi il deviendrait impossible de + démontrer que la purge a bien eu lieu. +

+
+ + + {policies.length}} + /> +
+ + + + + + + + + + + + + {policies.map((policy) => ( + + + + + + + + + ))} + +
ObjetDuréePoint de départJustificationEn vigueur leConservation probatoire
{policy.objectType} + {policy.durationMonths} mois + + {START_POINT_LABELS[policy.startPoint as StartPoint] ?? + policy.startPoint} + {!isComputable(policy.startPoint) ? ( + // Le dire plutôt que de laisser croire que la politique + // s'applique : PlanFlow ne modèle pas encore cette date. + non calculable + ) : null} + + {policy.justification} + + {dateFormat.format(policy.effectiveFrom)} + + +
+
+
+ + + +
+ +
+
+ + + {candidates.length}} + /> +
+ {candidates.length === 0 ? ( +

Aucune pièce déposée.

+ ) : ( +
    + {candidates.slice(0, 40).map((candidate) => ( +
  • + + {VERDICT_LABELS[candidate.verdict]} + + {candidate.name} + + {candidate.category} + + + déposée le {dateFormat.format(candidate.uploadedAt)} + +
  • + ))} +
+ )} + + {unpolicied.length > 0 ? ( +

+ {unpolicied.length} pièce(s) sans politique déclarée : elles ne + seront pas purgées tant qu’aucune durée ne les vise. +

+ ) : null} + + +
+
+
+ ); +} diff --git a/src/components/settings/RetentionForms.tsx b/src/components/settings/RetentionForms.tsx new file mode 100644 index 0000000..c1e4e6e --- /dev/null +++ b/src/components/settings/RetentionForms.tsx @@ -0,0 +1,187 @@ +'use client'; + +import { useActionState } from 'react'; + +import { Button } from '@/components/ui/Button'; +import { + START_POINT_LABELS, + START_POINTS, +} from '@/domain/retention/policy'; +import { + runPurgeAction, + saveRetentionPolicyAction, + toggleLegalHoldAction, + type RetentionState, +} from '@/server/retention/actions'; + +const empty: RetentionState = {}; + +export function RetentionPolicyForm() { + const [state, action, pending] = useActionState( + saveRetentionPolicyAction, + empty, + ); + + return ( +
+ + + + + + + +