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 ( +
+ + + + + + + +