Déposer et consulter les pièces du dossier salarié

Rien ne pouvait être téléversé jusqu'ici : ni pièce d'identité, ni relevé
d'identité bancaire, ni arrêt de travail. Le dossier RH n'existait qu'en
champs de formulaire.

Les fichiers vivent sur disque, chiffrés avec la clé qui protège déjà le NIR et
l'IBAN. Le plan n'exige le chiffrement que des pièces jointes de santé ; les
chiffrer toutes supprime une branche dont l'oubli serait silencieux et ne coûte
rien de plus. Leur emplacement est tiré au sort, jamais dérivé du nom déposé :
la traversée de chemin devient impossible par construction plutôt que par
filtrage, et un filtre s'oublie.

Le caractère sensible se déduit de la catégorie et n'est jamais saisi : laisser
déclarer qu'un arrêt de travail n'est pas une donnée de santé reviendrait à
laisser désactiver la journalisation de sa lecture. Cette lecture est inscrite
au journal avant d'être servie, et l'écran l'annonce — celui qui ouvre la pièce
doit savoir que sa consultation laisse une trace nominative.

Les liens sont signés et durent deux minutes, comme l'exige le plan. La
signature ne remplace pas le contrôle d'accès : la route revérifie session,
capacité et périmètre. Elle s'y ajoute pour qu'un lien recopié dans un message
cesse de fonctionner de lui-même, sans attendre qu'une session expire. La
signature est éprouvée avant l'échéance, sans quoi répondre « expiré » à un lien
fabriqué indiquerait qu'il aurait pu marcher.

L'empreinte du clair est conservée et revérifiée à chaque lecture : servir un
contenu qui ne correspond plus reviendrait à présenter comme authentique une
pièce altérée. Retirer une pièce efface le contenu mais garde la ligne : le
dossier doit conserver trace qu'elle a existé et qui l'a retirée.

Aucune durée de conservation n'est appliquée par défaut — le plan l'interdit
explicitement (§12.5). L'échéance reste nulle et l'écran le dit, plutôt que
d'inventer « cinq ans partout ».

Deux pièges d'outillage rencontrés et documentés dans les tests : le cookie de
session étant marqué Secure, le client HTTP de Playwright ne l'émet pas sur
http et faisait passer les refus pour de bonnes raisons sans rien prouver ; et
la visionneuse PDF intégrée de Chromium ne restitue pas le corps d'une
navigation, ce qui masquait la vérification d'intégrité du contenu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-09 07:31:22 +00:00
1 parent 5a15a25145
commit 29369d4a4e
16 files changed
+1275

No files matched your search

+39
View File
@@ -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;
}
+168
View File
@@ -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<DocumentState> {
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<DocumentState> {
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;
}
+57
View File
@@ -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 };
}
+59
View File
@@ -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<DocumentList> {
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'),
};
});
}
+97
View File
@@ -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/<uuid>` — 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<StoredFile> {
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<ReadResult> {
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<void> {
await rm(pathFor(fileKey), { force: true });
}