diff --git a/.gitignore b/.gitignore index bcfa61f..d259b6d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ blob-report/ *.log .tmp/ .pgdata/ + +# Pièces du dossier salarié, chiffrées au repos +storage/ diff --git a/prisma/migrations/20260809072154_documents/migration.sql b/prisma/migrations/20260809072154_documents/migration.sql new file mode 100644 index 0000000..0713c02 --- /dev/null +++ b/prisma/migrations/20260809072154_documents/migration.sql @@ -0,0 +1,38 @@ +-- CreateEnum +CREATE TYPE "DocumentCategory" AS ENUM ('IDENTITY', 'BANK', 'CONTRACT', 'AMENDMENT', 'SICK_NOTE', 'WORK_PERMIT', 'REGISTER', 'OTHER'); + +-- CreateTable +CREATE TABLE "Document" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "membershipId" TEXT, + "category" "DocumentCategory" NOT NULL, + "name" TEXT NOT NULL, + "fileKey" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "sizeBytes" INTEGER NOT NULL, + "checksum" TEXT NOT NULL, + "isSensitive" BOOLEAN NOT NULL DEFAULT false, + "retentionUntil" TIMESTAMP(3), + "uploadedBy" TEXT NOT NULL, + "uploadedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" TIMESTAMP(3), + "deletedBy" TEXT, + + CONSTRAINT "Document_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Document_fileKey_key" ON "Document"("fileKey"); + +-- CreateIndex +CREATE INDEX "Document_accountId_idx" ON "Document"("accountId"); + +-- CreateIndex +CREATE INDEX "Document_membershipId_idx" ON "Document"("membershipId"); + +-- CreateIndex +CREATE INDEX "Document_retentionUntil_idx" ON "Document"("retentionUntil"); + +-- AddForeignKey +ALTER TABLE "Document" ADD CONSTRAINT "Document_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "Membership"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260809072300_documents_rls/migration.sql b/prisma/migrations/20260809072300_documents_rls/migration.sql new file mode 100644 index 0000000..ec0eab3 --- /dev/null +++ b/prisma/migrations/20260809072300_documents_rls/migration.sql @@ -0,0 +1,17 @@ +-- --------------------------------------------------------------------------- +-- Isolation par compte — même défense en profondeur que les autres tables. +-- +-- Une pièce de dossier RH est le pire objet à laisser franchir la frontière +-- entre deux comptes : elle porte un nom, une identité, parfois une donnée de +-- santé. L'extension Prisma filtre déjà, ces règles valent pour le cas où elle +-- serait contournée. +-- --------------------------------------------------------------------------- + +ALTER TABLE "Document" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "Document" FORCE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON "Document" + USING ("accountId" = planflow_current_account()); + +CREATE POLICY tenant_insert ON "Document" + FOR INSERT WITH CHECK ("accountId" = planflow_current_account()); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fd10215..30ca82b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -167,6 +167,7 @@ model Membership { contracts UserContract[] workPermits WorkPermit[] teams TeamMember[] + documents Document[] @@unique([accountId, employeeNumber]) @@index([accountId]) @@ -717,6 +718,52 @@ model DailyNote { /// pouvoir reproduire à l'identique une paie antérieure après un changement de /// règle. Une mise à jour en place rendrait cette reproduction impossible, et /// l'impossibilité ne se découvrirait qu'au premier contrôle. +/// Pièce du dossier salarié — PLAN.md §4.7. +/// +/// Le contenu vit sur disque, **chiffré**, jamais dans la base : une pièce +/// jointe de santé chiffrée au repos est une exigence du plan (§3.6), et +/// stocker des mégaoctets en base rendrait toute sauvegarde impraticable. +/// `fileKey` est tiré au sort — jamais dérivé du nom déposé, qui ne doit pas +/// pouvoir désigner un emplacement. +model Document { + id String @id @default(cuid()) + accountId String + membershipId String? + category DocumentCategory + name String + fileKey String @unique + mimeType String + sizeBytes Int + /// Empreinte du clair, pour détecter une altération du fichier stocké. + checksum String + /// Donnée de santé : sa lecture est journalisée (§3.6). + isSensitive Boolean @default(false) + /// Échéance de conservation. Nulle tant qu'aucune politique ne l'a fixée : + /// le plan interdit d'appliquer une durée par défaut (§12.5). + retentionUntil DateTime? + uploadedBy String + uploadedAt DateTime @default(now()) + deletedAt DateTime? + deletedBy String? + + membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Cascade) + + @@index([accountId]) + @@index([membershipId]) + @@index([retentionUntil]) +} + +enum DocumentCategory { + IDENTITY + BANK + CONTRACT + AMENDMENT + SICK_NOTE + WORK_PERMIT + REGISTER + OTHER +} + model CollectiveAgreement { id String @id @default(cuid()) accountId String diff --git a/src/app/(app)/equipe/[id]/DocumentsPanel.tsx b/src/app/(app)/equipe/[id]/DocumentsPanel.tsx new file mode 100644 index 0000000..cd23aae --- /dev/null +++ b/src/app/(app)/equipe/[id]/DocumentsPanel.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { useActionState } from 'react'; + +import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; +import { + ACCEPTED_MIME_TYPES, + CATEGORY_LABELS, + DOCUMENT_CATEGORIES, + formatBytes, + MAX_DOCUMENT_BYTES, + type DocumentCategory, +} from '@/domain/documents/rules'; +import { + deleteDocumentAction, + uploadDocumentAction, + type DocumentState, +} from '@/server/documents/actions'; + +const empty: DocumentState = {}; +const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }); + +export interface DocumentView { + id: string; + name: string; + category: DocumentCategory; + sizeBytes: number; + isSensitive: boolean; + retentionUntil: Date | null; + uploadedAt: Date; + href: string; +} + +/** + * Pièces du dossier. + * + * Les liens sont signés et expirent en quelques minutes : recharger la page en + * fabrique de nouveaux. C'est voulu — un lien recopié dans un message cesse de + * fonctionner de lui-même, sans attendre qu'une session expire. + */ +export function DocumentsPanel({ + membershipId, + documents, + canManage, +}: { + membershipId: string; + documents: DocumentView[]; + canManage: boolean; +}) { + const [uploadState, upload, uploading] = useActionState( + uploadDocumentAction, + empty, + ); + const [deleteState, remove] = useActionState(deleteDocumentAction, empty); + + return ( +
+ {documents.length === 0 ? ( +

+ Aucune pièce. Un dossier complet porte au minimum une pièce d’identité + et un relevé d’identité bancaire. +

+ ) : ( + + )} + + {canManage ? ( +
+ + + + + + + +
+ ) : null} + + {uploadState.error ? ( +

+ {uploadState.error} +

+ ) : null} + {uploadState.message ? ( +

{uploadState.message}

+ ) : null} + {deleteState.error ? ( +

+ {deleteState.error} +

+ ) : null} + {deleteState.message ? ( +

{deleteState.message}

+ ) : null} +
+ ); +} diff --git a/src/app/(app)/equipe/[id]/page.tsx b/src/app/(app)/equipe/[id]/page.tsx index ad22c5b..80a9fd9 100644 --- a/src/app/(app)/equipe/[id]/page.tsx +++ b/src/app/(app)/equipe/[id]/page.tsx @@ -1,9 +1,11 @@ import { notFound } from 'next/navigation'; +import { DocumentsPanel } from '@/app/(app)/equipe/[id]/DocumentsPanel'; import { InvitationPanel } from '@/app/(app)/equipe/[id]/InvitationPanel'; import { PageBody, PageHeader } from '@/components/shell/PageHeader'; import { Badge } from '@/components/ui/Badge'; import { Card, CardHeader, EmptyState } from '@/components/ui/Card'; +import { listDocuments } from '@/server/documents/queries'; import { getEmployee } from '@/server/employees/queries'; export const dynamic = 'force-dynamic'; @@ -19,6 +21,10 @@ export default async function FichePage({ const employee = await getEmployee(id); if (!employee) notFound(); + // `members.documents.view` peut manquer là où `members.view` est accordée : + // la section disparaît alors, plutôt que d'échouer sur toute la page. + const documents = await listDocuments(id).catch(() => null); + const active = employee.contracts.find( (contract) => contract.status === 'ACTIVE', ); @@ -63,6 +69,20 @@ export default async function FichePage({ ) : null} + {documents ? ( + + {documents.documents.length}} + /> + + + ) : null} + {employee.canInvite ? ( diff --git a/src/app/documents/[id]/route.ts b/src/app/documents/[id]/route.ts new file mode 100644 index 0000000..2f7b423 --- /dev/null +++ b/src/app/documents/[id]/route.ts @@ -0,0 +1,91 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { asciiFileName, sanitiseFileName } from '@/domain/documents/rules'; +import { recordAudit } from '@/server/audit'; +import { query } from '@/server/context'; +import { checkSignature } from '@/server/documents/links'; +import { readFileByKey } from '@/server/documents/storage'; + +/** + * Téléchargement d'une pièce — PLAN.md §3.6. + * + * Trois conditions, toutes nécessaires : une signature valide et non expirée, + * une session, et la capacité de lire les documents. La signature seule ne + * suffit pas — un lien recopié dans un message ne doit rien ouvrir à qui n'a + * pas le droit de lire le dossier. + * + * La lecture d'une pièce de santé est journalisée, comme l'exige le plan pour + * toute donnée de catégorie particulière. + */ +export async function GET( + request: NextRequest, + context: { params: Promise<{ id: string }> }, +): Promise { + const { id } = await context.params; + const parameters = request.nextUrl.searchParams; + + const link = checkSignature(id, parameters.get('e'), parameters.get('s')); + if (!link.ok) { + return NextResponse.json( + { + error: + link.reason === 'expired' + ? 'Ce lien a expiré. Rouvrez le dossier pour en obtenir un nouveau.' + : 'Lien invalide.', + }, + { status: link.reason === 'expired' ? 410 : 400 }, + ); + } + + // `query` impose la session et la capacité avant d'ouvrir la transaction, et + // le client scopé ne voit que les documents du compte courant. + const found = await query('members.documents.view', async (db, actor) => { + const document = await db.document.findUnique({ where: { id } }); + if (!document || document.deletedAt) return null; + + if (document.isSensitive) { + // Journalisée **avant** la lecture : une consultation qui échouerait + // après coup a tout de même eu lieu du point de vue de l'accès. + await recordAudit(db, { + actorMembershipId: actor.membershipId, + action: 'document.sensitive.read', + entityType: 'Document', + entityId: document.id, + after: { + category: document.category, + membershipId: document.membershipId, + }, + }); + } + + return document; + }); + + if (!found) { + return NextResponse.json({ error: 'Document introuvable.' }, { status: 404 }); + } + + const file = await readFileByKey(found.fileKey, found.checksum); + if (!file.intact) { + // Servir un contenu qui ne correspond plus à son empreinte reviendrait à + // présenter comme authentique une pièce altérée. + return NextResponse.json( + { error: 'Le fichier stocké ne correspond plus à son empreinte.' }, + { status: 409 }, + ); + } + + const name = sanitiseFileName(found.name); + return new NextResponse(new Uint8Array(file.content), { + headers: { + 'content-type': found.mimeType, + 'content-length': String(file.content.byteLength), + // `inline` laisse le navigateur afficher un PDF ou une image sans + // téléchargement ; le nom accentué passe par `filename*`, le repli ASCII + // sert aux clients qui ne lisent que `filename`. + 'content-disposition': `inline; filename="${asciiFileName(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`, + // Une pièce de dossier RH n'a rien à faire dans un cache partagé. + 'cache-control': 'private, no-store', + }, + }); +} diff --git a/src/domain/documents/rules.ts b/src/domain/documents/rules.ts new file mode 100644 index 0000000..8ae1ab4 --- /dev/null +++ b/src/domain/documents/rules.ts @@ -0,0 +1,137 @@ +/** + * Pièces du dossier salarié — PLAN.md §4.7 et §3.6. + * + * Un dossier RH contient des pièces d'identité, des RIB et des arrêts de + * travail. Les derniers sont des données de santé, catégorie particulière au + * sens du RGPD : leur simple **lecture** doit être journalisée, et leur + * stockage chiffré. + */ + +export const DOCUMENT_CATEGORIES = [ + 'IDENTITY', + 'BANK', + 'CONTRACT', + 'AMENDMENT', + 'SICK_NOTE', + 'WORK_PERMIT', + 'REGISTER', + 'OTHER', +] as const; + +export type DocumentCategory = (typeof DOCUMENT_CATEGORIES)[number]; + +export const CATEGORY_LABELS: Record = { + IDENTITY: 'Pièce d’identité', + BANK: 'Coordonnées bancaires', + CONTRACT: 'Contrat', + AMENDMENT: 'Avenant', + SICK_NOTE: 'Arrêt de travail', + WORK_PERMIT: 'Titre de séjour', + REGISTER: 'Registre', + OTHER: 'Autre', +}; + +/** + * Catégories relevant de la santé. + * + * Une liste plutôt qu'un drapeau saisi à la main : laisser l'utilisateur + * déclarer qu'un arrêt de travail n'est pas sensible reviendrait à lui laisser + * désactiver la journalisation. + */ +const HEALTH_CATEGORIES = new Set(['SICK_NOTE']); + +export function isSensitiveCategory(category: DocumentCategory): boolean { + return HEALTH_CATEGORIES.has(category); +} + +/** + * Types de fichiers acceptés. + * + * Restreint volontairement : un dossier RH reçoit des scans et des PDF. Élargir + * aux documents bureautiques ferait entrer des formats à macros, qu'aucun usage + * ici ne justifie. + */ +export const ACCEPTED_MIME_TYPES = [ + 'application/pdf', + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/heic', +] as const; + +/** 20 Mio — un scan de plusieurs pages passe, une vidéo non. Choix produit. */ +export const MAX_DOCUMENT_BYTES = 20 * 1024 * 1024; + +export interface UploadCandidate { + name: string; + mimeType: string; + sizeBytes: number; +} + +/** Motif de refus, ou `null` si la pièce est acceptable. */ +export function uploadProblem(candidate: UploadCandidate): string | null { + if (candidate.sizeBytes <= 0) { + return 'Le fichier est vide.'; + } + if (candidate.sizeBytes > MAX_DOCUMENT_BYTES) { + return `Le fichier dépasse ${Math.round(MAX_DOCUMENT_BYTES / 1024 / 1024)} Mio.`; + } + if (!(ACCEPTED_MIME_TYPES as readonly string[]).includes(candidate.mimeType)) { + return 'Format non accepté. Déposez un PDF ou une image (JPEG, PNG, WebP, HEIC).'; + } + if (!candidate.name.trim()) { + return 'Le fichier n’a pas de nom.'; + } + return null; +} + +/** + * Nom d'affichage assaini. + * + * Le nom déposé par l'utilisateur ne sert **jamais** à désigner l'emplacement + * du fichier — celui-ci porte un identifiant tiré au sort. L'assainir reste + * utile pour l'en-tête de téléchargement, où un retour à la ligne ou un + * guillemet permettrait d'y injecter un second champ. + */ +export function sanitiseFileName(name: string): string { + const base = name.split(/[\\/]/).pop() ?? name; + const cleaned = base + .replace(/[\u0000-\u001f\u007f]/g, '') + .replace(/["\\]/g, '') + .trim(); + return cleaned.slice(0, 120) || 'document'; +} + +/** + * Nom en ASCII pur, pour l'en-tête `Content-Disposition`. + * + * Les accents y sont transmis séparément, en `filename*` ; le repli ASCII sert + * aux clients qui ne lisent que `filename`. + */ +export function asciiFileName(name: string): string { + return ( + sanitiseFileName(name) + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^\x20-\x7E]/g, '_') || 'document' + ); +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} o`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} Kio`; + return `${(bytes / 1024 / 1024).toFixed(1)} Mio`; +} + +/** + * Durée de validité d'un lien de téléchargement. + * + * Assez pour ouvrir la pièce depuis la liste, trop peu pour qu'un lien recopié + * dans un message reste exploitable. Le lien seul ne suffit d'ailleurs pas : + * la session et la capacité sont revérifiées à la lecture. + */ +export const DOWNLOAD_LINK_TTL_SECONDS = 120; + +export function isLinkExpired(expiresAtSeconds: number, nowMs: number): boolean { + return expiresAtSeconds * 1000 <= nowMs; +} diff --git a/src/lib/env.ts b/src/lib/env.ts index 5cbbd18..576c507 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -25,6 +25,15 @@ const schema = z.object({ /** Public origin, used for links in invitation and notification e-mails. */ APP_URL: z.url().default('http://localhost:3000'), + + /** + * Where employee documents live on disk, encrypted with ENCRYPTION_KEY. + * + * Kept out of the database: megabytes of scans would make every backup + * impractical. Back this directory up alongside the database — one without + * the other restores a dossier with missing pieces. + */ + DOCUMENT_STORE: z.string().min(1).default('./storage/documents'), }); export type Env = z.infer; diff --git a/src/server/crypto.ts b/src/server/crypto.ts index feddfef..ad4c5a4 100644 --- a/src/server/crypto.ts +++ b/src/server/crypto.ts @@ -62,6 +62,45 @@ function decryptBuffer(payload: Buffer): string { ]).toString('utf8'); } +/** + * Chiffrement d'un contenu binaire — pièces du dossier salarié. + * + * Même format et même clé que les colonnes : un arrêt de travail est une donnée + * de santé, et le plan (§3.6) exige qu'il soit chiffré au repos. Chiffrer + * **toutes** les pièces plutôt que les seules pièces de santé évite une branche + * dont l'oubli serait silencieux, et ne coûte rien de plus. + */ +export function encryptBytes(plaintext: Uint8Array): Buffer { + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key(), iv); + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); + return Buffer.concat([iv, cipher.getAuthTag(), encrypted]); +} + +export function decryptBytes(payload: Uint8Array): Buffer { + const buffer = Buffer.from( + payload.buffer, + payload.byteOffset, + payload.byteLength, + ); + if (buffer.length < IV_LENGTH + TAG_LENGTH) { + throw new Error('Chiffré invalide : trop court pour contenir iv et tag'); + } + + const iv = buffer.subarray(0, IV_LENGTH); + const tag = buffer.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); + const encrypted = buffer.subarray(IV_LENGTH + TAG_LENGTH); + + const decipher = createDecipheriv(ALGORITHM, key(), iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(encrypted), decipher.final()]); +} + +/** Empreinte d'un contenu, pour détecter l'altération du fichier stocké. */ +export function checksumBytes(payload: Uint8Array): string { + return createHash('sha256').update(payload).digest('hex'); +} + export function encryptOptional(value: string | null | undefined): Buffer | null { return value ? encrypt(value) : null; } diff --git a/src/server/documents/actions.ts b/src/server/documents/actions.ts new file mode 100644 index 0000000..87cb8e1 --- /dev/null +++ b/src/server/documents/actions.ts @@ -0,0 +1,168 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; + +import { AuthorizationError } from '@/domain/access/authorize'; +import { + DOCUMENT_CATEGORIES, + isSensitiveCategory, + sanitiseFileName, + uploadProblem, + type DocumentCategory, +} from '@/domain/documents/rules'; +import { recordAudit } from '@/server/audit'; +import { mutate } from '@/server/context'; +import { removeFile, storeFile } from '@/server/documents/storage'; + +/** + * Dépôt et retrait de pièces. + * + * Le fichier est écrit sur disque **avant** la ligne en base : l'inverse + * laisserait, en cas d'échec d'écriture, une entrée désignant un contenu + * inexistant — une pièce fantôme dans un dossier RH est pire qu'une absence, + * parce qu'elle se croit présente. + */ + +export interface DocumentState { + error?: string; + ok?: boolean; + message?: string; +} + +export async function uploadDocumentAction( + _previous: DocumentState, + formData: FormData, +): Promise { + const membershipId = String(formData.get('membershipId') ?? ''); + const rawCategory = String(formData.get('category') ?? ''); + const file = formData.get('file'); + + if (!membershipId) return { error: 'Salarié introuvable.' }; + if (!(DOCUMENT_CATEGORIES as readonly string[]).includes(rawCategory)) { + return { error: 'Catégorie inconnue.' }; + } + if (!(file instanceof File)) return { error: 'Aucun fichier déposé.' }; + + const category = rawCategory as DocumentCategory; + const name = sanitiseFileName(file.name); + + const problem = uploadProblem({ + name, + mimeType: file.type, + sizeBytes: file.size, + }); + if (problem) return { error: problem }; + + const content = new Uint8Array(await file.arrayBuffer()); + // La taille annoncée par le navigateur n'engage personne : c'est celle du + // contenu réellement reçu qui doit passer le contrôle. + const received = uploadProblem({ + name, + mimeType: file.type, + sizeBytes: content.byteLength, + }); + if (received) return { error: received }; + + try { + await mutate('members.documents.manage', async (db, actor) => { + const membership = await db.membership.findUnique({ + where: { id: membershipId }, + select: { id: true }, + }); + if (!membership) throw new ValidationError('Salarié introuvable.'); + + const stored = await storeFile(actor.accountId, content); + + await db.document.create({ + data: { + membershipId, + category, + name, + fileKey: stored.fileKey, + mimeType: file.type, + sizeBytes: stored.sizeBytes, + checksum: stored.checksum, + // Déduit de la catégorie, jamais saisi : laisser déclarer qu'un arrêt + // de travail n'est pas sensible reviendrait à laisser désactiver la + // journalisation de sa lecture. + isSensitive: isSensitiveCategory(category), + uploadedBy: actor.membershipId, + } as never, + }); + + await recordAudit(db, { + actorMembershipId: actor.membershipId, + action: 'document.upload', + entityType: 'Membership', + entityId: membershipId, + // Le nom du fichier suffit à la relecture ; son contenu n'a rien à + // faire dans un journal. + after: { name, category, sizeBytes: stored.sizeBytes }, + }); + }); + } catch (error) { + return toState(error, "Vous n'avez pas le droit de déposer une pièce."); + } + + revalidatePath(`/equipe/${membershipId}`); + return { ok: true, message: `« ${name} » déposé.` }; +} + +/** + * Retire une pièce. + * + * Le contenu est effacé du disque ; la ligne reste, marquée supprimée. Le + * dossier doit garder trace qu'une pièce a existé et qui l'a retirée — c'est le + * contenu qui disparaît, pas l'événement. + */ +export async function deleteDocumentAction( + _previous: DocumentState, + formData: FormData, +): Promise { + const documentId = String(formData.get('documentId') ?? ''); + if (!documentId) return { error: 'Pièce introuvable.' }; + + let membershipId: string | null = null; + + try { + await mutate('members.documents.manage', async (db, actor) => { + const document = await db.document.findUnique({ + where: { id: documentId }, + }); + if (!document || document.deletedAt) { + throw new ValidationError('Pièce introuvable.'); + } + + membershipId = document.membershipId; + + await db.document.update({ + where: { id: documentId }, + data: { deletedAt: new Date(), deletedBy: actor.membershipId } as never, + }); + + await recordAudit(db, { + actorMembershipId: actor.membershipId, + action: 'document.delete', + entityType: 'Document', + entityId: documentId, + before: { name: document.name, category: document.category }, + after: { deleted: true }, + }); + + await removeFile(document.fileKey); + }); + } catch (error) { + return toState(error, "Vous n'avez pas le droit de retirer une pièce."); + } + + if (membershipId) revalidatePath(`/equipe/${membershipId}`); + return { ok: true, message: 'Pièce retirée.' }; +} + +class ValidationError extends Error {} + +function toState(error: unknown, denied: string): DocumentState { + if (error instanceof ValidationError) return { error: error.message }; + if (error instanceof AuthorizationError) return { error: denied }; + throw error; +} diff --git a/src/server/documents/links.ts b/src/server/documents/links.ts new file mode 100644 index 0000000..048df19 --- /dev/null +++ b/src/server/documents/links.ts @@ -0,0 +1,57 @@ +import 'server-only'; + +import { createHmac } from 'node:crypto'; + +import { DOWNLOAD_LINK_TTL_SECONDS } from '@/domain/documents/rules'; +import { env } from '@/lib/env'; +import { safeEqual } from '@/server/crypto'; + +/** + * Liens de téléchargement signés — PLAN.md §3.6. + * + * Le plan exige que les fichiers ne soient servis que par URL signée à durée + * courte. La signature ne remplace pas le contrôle d'accès : la route + * revérifie la session, la capacité et le périmètre. Elle s'y ajoute, pour + * qu'un lien recopié ailleurs cesse de fonctionner de lui-même, sans attendre + * qu'une session expire. + */ + +function signature(documentId: string, expiresAt: number): string { + return createHmac('sha256', Buffer.from(env.ENCRYPTION_KEY, 'base64')) + .update(`${documentId}:${expiresAt}`) + .digest('base64url'); +} + +export function signedDocumentUrl(documentId: string, nowMs = Date.now()): string { + const expiresAt = Math.floor(nowMs / 1000) + DOWNLOAD_LINK_TTL_SECONDS; + const parameters = new URLSearchParams({ + e: String(expiresAt), + s: signature(documentId, expiresAt), + }); + return `/documents/${documentId}?${parameters.toString()}`; +} + +export type LinkCheck = + | { ok: true } + | { ok: false; reason: 'expired' | 'invalid' }; + +export function checkSignature( + documentId: string, + rawExpiresAt: string | null, + rawSignature: string | null, + nowMs = Date.now(), +): LinkCheck { + if (!rawExpiresAt || !rawSignature) return { ok: false, reason: 'invalid' }; + + const expiresAt = Number(rawExpiresAt); + if (!Number.isInteger(expiresAt)) return { ok: false, reason: 'invalid' }; + + // La signature est éprouvée **avant** l'échéance : sans cela, on répondrait + // « expiré » à un lien fabriqué, ce qui indiquerait qu'il aurait pu marcher. + if (!safeEqual(signature(documentId, expiresAt), rawSignature)) { + return { ok: false, reason: 'invalid' }; + } + if (expiresAt * 1000 <= nowMs) return { ok: false, reason: 'expired' }; + + return { ok: true }; +} diff --git a/src/server/documents/queries.ts b/src/server/documents/queries.ts new file mode 100644 index 0000000..51247e3 --- /dev/null +++ b/src/server/documents/queries.ts @@ -0,0 +1,59 @@ +import { can } from '@/domain/access/authorize'; +import type { DocumentCategory } from '@/domain/documents/rules'; +import { query } from '@/server/context'; +import { signedDocumentUrl } from '@/server/documents/links'; + +export interface DocumentRow { + id: string; + name: string; + category: DocumentCategory; + mimeType: string; + sizeBytes: number; + isSensitive: boolean; + retentionUntil: Date | null; + uploadedAt: Date; + /** Lien signé, valable quelques minutes seulement. */ + href: string; +} + +export interface DocumentList { + documents: DocumentRow[]; + canManage: boolean; +} + +/** + * Pièces d'un salarié. + * + * Le lien signé est fabriqué à la lecture de la page, jamais stocké : sa durée + * de vie se compte en minutes, et le conserver en base le rendrait aussi long + * que la ligne qui le porte. + */ +export async function listDocuments( + membershipId: string, +): Promise { + return query('members.documents.view', async (db, actor) => { + const rows = await db.document.findMany({ + where: { membershipId, deletedAt: null }, + orderBy: { uploadedAt: 'desc' }, + select: { + id: true, + name: true, + category: true, + mimeType: true, + sizeBytes: true, + isSensitive: true, + retentionUntil: true, + uploadedAt: true, + }, + }); + + return { + documents: rows.map((row) => ({ + ...row, + category: row.category as DocumentCategory, + href: signedDocumentUrl(row.id), + })), + canManage: can(actor, 'members.documents.manage'), + }; + }); +} diff --git a/src/server/documents/storage.ts b/src/server/documents/storage.ts new file mode 100644 index 0000000..5626a25 --- /dev/null +++ b/src/server/documents/storage.ts @@ -0,0 +1,97 @@ +import 'server-only'; + +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve, sep } from 'node:path'; + +import { env } from '@/lib/env'; +import { checksumBytes, decryptBytes, encryptBytes } from '@/server/crypto'; + +/** + * Stockage des pièces — PLAN.md §3.6. + * + * Sur disque plutôt qu'en base : des mégaoctets en base rendraient toute + * sauvegarde impraticable. Chiffré, parce qu'un arrêt de travail est une donnée + * de santé et qu'un disque volé ne doit rien livrer. + * + * L'emplacement est **tiré au sort**, jamais dérivé du nom déposé. C'est ce qui + * rend la traversée de chemin impossible par construction plutôt que par + * filtrage — un filtre s'oublie, un identifiant aléatoire ne se négocie pas. + */ + +export interface StoredFile { + fileKey: string; + checksum: string; + sizeBytes: number; +} + +function root(): string { + return resolve(env.DOCUMENT_STORE); +} + +/** + * Chemin absolu d'une clé, vérifié. + * + * La clé vient de la base et non d'un formulaire, mais la vérification reste : + * une donnée corrompue ou une migration maladroite ne doit pas pouvoir faire + * écrire ailleurs que dans le magasin. + */ +function pathFor(fileKey: string): string { + const base = root(); + const target = resolve(join(base, fileKey)); + if (target !== base && !target.startsWith(base + sep)) { + throw new Error('Clé de fichier hors du magasin de documents'); + } + return target; +} + +/** `aa/bb/` — deux niveaux, pour ne pas entasser des milliers d'entrées. */ +function newKey(accountId: string): string { + const id = randomUUID(); + return join(accountId, id.slice(0, 2), id.slice(2, 4), id); +} + +export async function storeFile( + accountId: string, + content: Uint8Array, +): Promise { + const fileKey = newKey(accountId); + const target = pathFor(fileKey); + + await mkdir(dirname(target), { recursive: true }); + // Le clair ne touche jamais le disque : on chiffre avant d'écrire, et la + // taille comme l'empreinte se rapportent au clair, seul objet que + // l'utilisateur reconnaît. + await writeFile(target, encryptBytes(content), { mode: 0o600 }); + + return { + fileKey, + checksum: checksumBytes(content), + sizeBytes: content.byteLength, + }; +} + +export interface ReadResult { + content: Buffer; + /** Faux si le contenu ne correspond plus à l'empreinte enregistrée. */ + intact: boolean; +} + +export async function readFileByKey( + fileKey: string, + expectedChecksum: string, +): Promise { + const content = decryptBytes(await readFile(pathFor(fileKey))); + return { content, intact: checksumBytes(content) === expectedChecksum }; +} + +/** + * Efface le contenu. + * + * L'entrée en base reste, marquée supprimée : le dossier doit garder trace + * qu'une pièce a existé et qui l'a retirée. C'est le contenu qui disparaît, pas + * l'événement. + */ +export async function removeFile(fileKey: string): Promise { + await rm(pathFor(fileKey), { force: true }); +} diff --git a/tests/e2e/documents.spec.ts b/tests/e2e/documents.spec.ts new file mode 100644 index 0000000..2020212 --- /dev/null +++ b/tests/e2e/documents.spec.ts @@ -0,0 +1,193 @@ +import { expect, test, type Page } from '@playwright/test'; + +/** + * Pièces du dossier salarié — PLAN.md §3.6 et WP-10. + * + * Trois garanties à protéger : le contenu revient tel qu'il a été déposé, le + * lien expire, et il ne suffit pas à lui seul — un navigateur sans session + * n'obtient rien. + * + * Chaque test dépose sur **son** salarié : les pièces s'accumulent, et deux + * tests qui se partageraient un dossier compteraient les pièces de l'autre. + * + * Les liens sont suivis par **navigation** et non par le client HTTP de + * Playwright : le cookie de session est marqué `Secure`, et ce client ne + * l'émet pas sur http, ce qui ferait passer les refus pour de bonnes raisons + * sans rien prouver. + */ + +/** Un PDF minimal mais authentique : le type est contrôlé au dépôt. */ +const PDF = Buffer.from( + '%PDF-1.4\n1 0 obj<>endobj\ntrailer<>\n%%EOF\n', + 'ascii', +); + +async function createEmployee(page: Page, tag: string) { + const suffix = `${Date.now()}-${tag}`; + const lastName = `Dossier${suffix}`; + + await page.goto('/equipe'); + const form = page.locator('form').filter({ hasText: 'Ajouter' }); + await form.getByLabel('Prénom').fill('Inès'); + await form.getByLabel('Nom', { exact: true }).fill(lastName); + await form.getByLabel('Matricule').fill(`DOC${suffix}`); + await form.getByRole('button', { name: 'Ajouter' }).click(); + await expect(page.getByText('Salarié ajouté.')).toBeVisible(); + + await page.getByRole('link', { name: new RegExp(lastName) }).click(); + await expect(page.getByRole('heading', { name: new RegExp(lastName) })).toBeVisible(); + return { lastName, url: page.url() }; +} + +/** + * Récupère une pièce depuis la page elle-même. + * + * `page.goto` sur un PDF passe par la visionneuse intégrée de Chromium, qui ne + * restitue pas le corps ; une requête émise depuis la page contourne cela et + * emporte les mêmes cookies que la navigation. + */ +async function fetchDocument(page: Page, href: string) { + return page.evaluate(async (target) => { + const response = await fetch(target); + const bytes = new Uint8Array(await response.arrayBuffer()); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return { + status: response.status, + contentType: response.headers.get('content-type'), + base64: btoa(binary), + }; + }, href); +} + +function uploadForm(page: Page) { + return page.locator('form').filter({ hasText: 'Déposer' }); +} + +async function upload(page: Page, category: string, name: string) { + await uploadForm(page).getByLabel('Catégorie').selectOption(category); + await uploadForm(page) + .getByLabel('Fichier') + .setInputFiles({ name, mimeType: 'application/pdf', buffer: PDF }); + await uploadForm(page).getByRole('button', { name: 'Déposer' }).click(); + await expect(page.getByText(`« ${name} » déposé.`)).toBeVisible(); +} + +test('une pièce déposée se relit à l’identique', async ({ page }) => { + await createEmployee(page, 'depot'); + + await expect(page.getByText(/Aucune pièce/)).toBeVisible(); + await upload(page, 'IDENTITY', 'carte-identite.pdf'); + + const link = page.getByRole('link', { name: 'carte-identite.pdf' }); + await expect(link).toBeVisible(); + + const href = (await link.getAttribute('href'))!; + const response = await fetchDocument(page, href); + expect(response.status).toBe(200); + expect(response.contentType).toBe('application/pdf'); + // Le contenu traverse un chiffrement au repos : il doit revenir tel quel. + expect(Buffer.from(response.base64, 'base64').equals(PDF)).toBe(true); +}); + +test('un arrêt de travail est signalé comme donnée de santé', async ({ + page, +}) => { + await createEmployee(page, 'sante'); + await upload(page, 'SICK_NOTE', 'arret.pdf'); + + // Celui qui ouvre la pièce doit savoir que sa consultation laisse une trace + // nominative — c'est la contrepartie de la journalisation, pas un détail. + await expect(page.getByText('Santé · lecture journalisée')).toBeVisible(); + + const href = (await page + .getByRole('link', { name: 'arret.pdf' }) + .getAttribute('href'))!; + expect((await fetchDocument(page, href)).status).toBe(200); +}); + +test('un lien de téléchargement ne vaut rien sans session', async ({ + page, + browser, +}) => { + await createEmployee(page, 'anonyme'); + await upload(page, 'CONTRACT', 'contrat.pdf'); + + const href = (await page + .getByRole('link', { name: 'contrat.pdf' }) + .getAttribute('href'))!; + + // La signature seule ne suffit pas : un lien recopié dans un message ne doit + // rien ouvrir à qui n'a pas le droit de lire le dossier. + const guest = await browser.newContext({ storageState: undefined }); + const guestPage = await guest.newPage(); + await guestPage.goto(new URL(href, page.url()).toString()); + + // Renvoyé vers la connexion, et sans une once du contenu. + await expect(guestPage).toHaveURL(/connexion/); + expect(await guestPage.content()).not.toContain('%PDF'); + await guest.close(); +}); + +test('un lien altéré ou expiré est refusé', async ({ page }) => { + await createEmployee(page, 'signature'); + await upload(page, 'BANK', 'rib.pdf'); + + const href = (await page + .getByRole('link', { name: 'rib.pdf' }) + .getAttribute('href'))!; + const url = new URL(href, page.url()); + + // Signature falsifiée. + const forged = new URL(url); + forged.searchParams.set('s', 'signature-inventee'); + expect((await fetchDocument(page, forged.toString())).status).toBe(400); + + // Échéance repoussée à la main : la signature ne couvre plus la valeur. + const extended = new URL(url); + extended.searchParams.set('e', String(Math.floor(Date.now() / 1000) + 86_400)); + expect((await fetchDocument(page, extended.toString())).status).toBe(400); + + // Échéance passée : refusée aussi, mais après contrôle de la signature — + // répondre « expiré » à un lien fabriqué indiquerait qu'il aurait pu marcher. + const stale = new URL(url); + stale.searchParams.set('e', '1'); + expect((await fetchDocument(page, stale.toString())).status).toBe(400); +}); + +test('un format non accepté est refusé', async ({ page }) => { + await createEmployee(page, 'format'); + + await uploadForm(page) + .getByLabel('Fichier') + .setInputFiles({ + name: 'macro.xls', + mimeType: 'application/vnd.ms-excel', + buffer: Buffer.from('sans importance'), + }); + await uploadForm(page).getByRole('button', { name: 'Déposer' }).click(); + + await expect(page.getByText(/Format non accepté/)).toBeVisible(); + await expect(page.getByText(/Aucune pièce/)).toBeVisible(); +}); + +test('une pièce retirée n’est plus servie', async ({ page }) => { + await createEmployee(page, 'retrait'); + await upload(page, 'OTHER', 'note.pdf'); + + const href = (await page + .getByRole('link', { name: 'note.pdf' }) + .getAttribute('href'))!; + + await page + .locator('li') + .filter({ hasText: 'note.pdf' }) + .getByRole('button', { name: 'Retirer' }) + .click(); + await expect(page.getByText('Pièce retirée.')).toBeVisible(); + + await expect(page.getByText(/Aucune pièce/)).toBeVisible(); + + // Le lien signé reste valide dans sa fenêtre, mais le document ne l'est plus. + expect((await fetchDocument(page, href)).status).toBe(404); +}); diff --git a/tests/unit/documents.test.ts b/tests/unit/documents.test.ts new file mode 100644 index 0000000..57c5b74 --- /dev/null +++ b/tests/unit/documents.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; + +import { + asciiFileName, + CATEGORY_LABELS, + DOCUMENT_CATEGORIES, + formatBytes, + isLinkExpired, + isSensitiveCategory, + MAX_DOCUMENT_BYTES, + sanitiseFileName, + uploadProblem, +} from '@/domain/documents/rules'; + +describe('catégories', () => { + it('portent toutes un libellé', () => { + // Une catégorie sans libellé s'afficherait en majuscules anglaises dans un + // dossier RH français. + for (const category of DOCUMENT_CATEGORIES) { + expect(CATEGORY_LABELS[category]).toBeTruthy(); + } + }); + + it('classent l’arrêt de travail en donnée de santé', () => { + expect(isSensitiveCategory('SICK_NOTE')).toBe(true); + }); + + it('n’étendent pas la sensibilité à tout le dossier', () => { + // Marquer tout comme sensible noierait les lectures qui comptent vraiment + // sous un journal que personne ne relit. + expect(isSensitiveCategory('IDENTITY')).toBe(false); + expect(isSensitiveCategory('CONTRACT')).toBe(false); + }); +}); + +describe('contrôle du dépôt', () => { + const valid = { + name: 'attestation.pdf', + mimeType: 'application/pdf', + sizeBytes: 1024, + }; + + it('accepte un PDF ordinaire', () => { + expect(uploadProblem(valid)).toBeNull(); + }); + + it('refuse un fichier vide', () => { + expect(uploadProblem({ ...valid, sizeBytes: 0 })).toMatch(/vide/); + }); + + it('refuse au-delà de la taille maximale', () => { + expect( + uploadProblem({ ...valid, sizeBytes: MAX_DOCUMENT_BYTES + 1 }), + ).toMatch(/20 Mio/); + expect(uploadProblem({ ...valid, sizeBytes: MAX_DOCUMENT_BYTES })).toBeNull(); + }); + + it('refuse un format hors de la liste', () => { + // Les formats bureautiques à macros n'ont aucun usage ici. + expect( + uploadProblem({ ...valid, mimeType: 'application/vnd.ms-excel' }), + ).toMatch(/Format non accepté/); + expect(uploadProblem({ ...valid, mimeType: 'text/html' })).toMatch( + /Format non accepté/, + ); + }); + + it('accepte les images de scan', () => { + for (const mimeType of ['image/jpeg', 'image/png', 'image/webp', 'image/heic']) { + expect(uploadProblem({ ...valid, mimeType })).toBeNull(); + } + }); +}); + +describe('nom de fichier', () => { + it('ne garde que le dernier segment', () => { + // Le nom ne désigne jamais l'emplacement — celui-ci est tiré au sort — mais + // le nettoyer évite qu'un chemin s'affiche tel quel dans le dossier. + expect(sanitiseFileName('../../etc/passwd')).toBe('passwd'); + expect(sanitiseFileName('C:\\Users\\moi\\carte.pdf')).toBe('carte.pdf'); + }); + + it('retire ce qui casserait l’en-tête de téléchargement', () => { + // Un guillemet ou un retour à la ligne permettrait d'injecter un second + // champ dans `Content-Disposition`. + expect(sanitiseFileName('rib".pdf')).toBe('rib.pdf'); + expect(sanitiseFileName('note\r\ninjectée.pdf')).toBe('noteinjectée.pdf'); + }); + + it('borne la longueur', () => { + expect(sanitiseFileName(`${'a'.repeat(300)}.pdf`).length).toBe(120); + }); + + it('ne rend jamais une chaîne vide', () => { + expect(sanitiseFileName(' ')).toBe('document'); + expect(sanitiseFileName('/')).toBe('document'); + }); + + it('produit un repli ASCII pour l’en-tête', () => { + expect(asciiFileName('attestation été.pdf')).toBe('attestation ete.pdf'); + expect(asciiFileName('相片.png')).toBe('__.png'); + }); +}); + +describe('taille lisible', () => { + it('choisit l’unité', () => { + expect(formatBytes(512)).toBe('512 o'); + expect(formatBytes(2048)).toBe('2 Kio'); + expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 Mio'); + }); +}); + +describe('expiration d’un lien', () => { + it('expire à la seconde annoncée', () => { + const now = 1_700_000_000_000; + expect(isLinkExpired(now / 1000, now)).toBe(true); + expect(isLinkExpired(now / 1000 + 1, now)).toBe(false); + }); +});