diff --git a/docker-compose.yml b/docker-compose.yml index 29377bd..ddc7581 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,32 +4,35 @@ services: container_name: itinova-budget-si-app restart: unless-stopped environment: - - NODE_ENV=production - - DATABASE_URL=mysql://budget_user:BudgetSI2027!@db:3306/budget_si - - JWT_SECRET=${JWT_SECRET:-budget-si-recette-jwt-secret-2027} - - VITE_APP_ID=${VITE_APP_ID:-} - - OAUTH_SERVER_URL=${OAUTH_SERVER_URL:-} - - VITE_OAUTH_PORTAL_URL=${VITE_OAUTH_PORTAL_URL:-} - - OWNER_OPEN_ID=${OWNER_OPEN_ID:-} - - OWNER_NAME=${OWNER_NAME:-Administrateur} - - BUILT_IN_FORGE_API_URL=${BUILT_IN_FORGE_API_URL:-} - - BUILT_IN_FORGE_API_KEY=${BUILT_IN_FORGE_API_KEY:-} - - VITE_FRONTEND_FORGE_API_KEY=${VITE_FRONTEND_FORGE_API_KEY:-} - - VITE_FRONTEND_FORGE_API_URL=${VITE_FRONTEND_FORGE_API_URL:-} - - VITE_ANALYTICS_ENDPOINT=${VITE_ANALYTICS_ENDPOINT:-} - - VITE_ANALYTICS_WEBSITE_ID=${VITE_ANALYTICS_WEBSITE_ID:-} - - PAYROLL_ENCRYPTION_KEY=${PAYROLL_ENCRYPTION_KEY:-} + - NODE_ENV=production + - DATABASE_URL=mysql://budget_user:BudgetSI2027!@db:3306/budget_si + - JWT_SECRET=${JWT_SECRET:-budget-si-recette-jwt-secret-2027} + - VITE_APP_ID=${VITE_APP_ID:-} + - OAUTH_SERVER_URL=${OAUTH_SERVER_URL:-} + - VITE_OAUTH_PORTAL_URL=${VITE_OAUTH_PORTAL_URL:-} + - OWNER_OPEN_ID=${OWNER_OPEN_ID:-} + - OWNER_NAME=${OWNER_NAME:-Administrateur} + - BUILT_IN_FORGE_API_URL=${BUILT_IN_FORGE_API_URL:-} + - BUILT_IN_FORGE_API_KEY=${BUILT_IN_FORGE_API_KEY:-} + - VITE_FRONTEND_FORGE_API_KEY=${VITE_FRONTEND_FORGE_API_KEY:-} + - VITE_FRONTEND_FORGE_API_URL=${VITE_FRONTEND_FORGE_API_URL:-} + - VITE_ANALYTICS_ENDPOINT=${VITE_ANALYTICS_ENDPOINT:-} + - VITE_ANALYTICS_WEBSITE_ID=${VITE_ANALYTICS_WEBSITE_ID:-} + - PAYROLL_ENCRYPTION_KEY=${PAYROLL_ENCRYPTION_KEY:-} + - PAYROLL_STORAGE_DIR=/var/lib/itinova-budget-si/payroll + volumes: + - ${PAYROLL_STORAGE_HOST_DIR:-/opt/manus-deploy/data/itinova-budget-si/payroll}:/var/lib/itinova-budget-si/payroll depends_on: db: condition: service_healthy networks: - - web + - web labels: - - "traefik.enable=true" - - "traefik.http.routers.budget-si.rule=Host(`budget-si.recette.santinova-soft.org`)" - - "traefik.http.routers.budget-si.entrypoints=websecure" - - "traefik.http.routers.budget-si.tls.certresolver=letsencrypt" - - "traefik.http.services.budget-si.loadbalancer.server.port=3000" + - "traefik.enable=true" + - "traefik.http.routers.budget-si.rule=Host(`budget-si.recette.santinova-soft.org`)" + - "traefik.http.routers.budget-si.entrypoints=websecure" + - "traefik.http.routers.budget-si.tls.certresolver=letsencrypt" + - "traefik.http.services.budget-si.loadbalancer.server.port=3000" db: image: mysql:8.0 @@ -41,14 +44,25 @@ services: MYSQL_USER: budget_user MYSQL_PASSWORD: BudgetSI2027! volumes: - - db_data:/var/lib/mysql + - db_data:/var/lib/mysql healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pBudgetSI2027!"] + test: + [ + "CMD", + "mysqladmin", + "ping", + "-h", + "localhost", + "-u", + "root", + "-pBudgetSI2027!", + ] interval: 10s timeout: 5s retries: 5 networks: - - web + - web + volumes: db_data: diff --git a/docs/salaires-securite.md b/docs/salaires-securite.md index e5f8f52..656ebfe 100644 --- a/docs/salaires-securite.md +++ b/docs/salaires-securite.md @@ -2,7 +2,7 @@ Le module **Salaires** est réservé aux comptes de profil `admin`, à la fois dans la navigation, dans les routes clientes, dans les procédures métier et sur les deux routes binaires serveur. Les profils `standard` et `readonly` ne peuvent ni lister les données, ni importer, ni consulter une liasse. -Les fichiers PDF sont chiffrés côté serveur avec AES-256-GCM avant leur envoi vers le stockage persistant du projet. La base de données contient seulement les métadonnées indispensables, les paramètres cryptographiques par fichier et l'index métier minimal : matricule, identité professionnelle, poste et montants bruts. Elle ne contient ni adresse, ni IBAN, ni numéro de sécurité sociale, ni net à payer. +Les fichiers PDF sont chiffrés côté serveur avec AES-256-GCM avant leur stockage. Lorsque Forge est configuré, l'archive chiffrée est envoyée au stockage persistant du projet. En recette, où Forge n'est pas disponible, elle est stockée dans un volume Docker monté depuis `/opt/manus-deploy/data/itinova-budget-si/payroll` sur l'hôte : ce volume n'est ni intégré à l'image, ni supprimé par la reconstruction du conteneur. La base de données contient seulement les métadonnées indispensables, les paramètres cryptographiques par fichier et l'index métier minimal : matricule, identité professionnelle, poste et montants bruts. Elle ne contient ni adresse, ni IBAN, ni numéro de sécurité sociale, ni net à payer. La consultation récupère le contenu chiffré exclusivement côté serveur, le déchiffre après contrôle de la session administrateur et retourne le PDF avec les entêtes `Cache-Control: no-store` et `X-Content-Type-Options: nosniff`. Le navigateur ne reçoit aucune URL de stockage ni paramètre de chiffrement. @@ -23,3 +23,5 @@ Une seconde liasse réelle, pour juin 2026, a été archivée et indexée au mê La consultation réelle de la liasse de juin a retourné un document dont la signature est `%PDF-`, avec `Content-Type: application/pdf`, `Cache-Control: no-store, private` et `X-Content-Type-Options: nosniff`. Aucune URL de stockage n'a été renvoyée au navigateur. Après un redémarrage complet du serveur de développement, les deux archives mensuelles et leurs statuts d'indexation étaient toujours présents dans l'interface. La relecture de la liasse de juin a de nouveau retourné un PDF valide, avec l'entête `no-store`. Cette vérification confirme que le fichier chiffré est lu depuis le stockage persistant extérieur au processus applicatif, et non depuis le système de fichiers d'un conteneur. + +La CI Gitea de recette et le déploiement Blue-Green ont été validés pour le commit qui transmet explicitement la clé de paie au conteneur applicatif. Le déploiement sauvegarde la base avant reconstruction, met à jour le code depuis Gitea, reconstruit l'image sans cache, puis recrée l'application une fois la base saine. diff --git a/server/payrollStorage.test.ts b/server/payrollStorage.test.ts new file mode 100644 index 0000000..f92e39d --- /dev/null +++ b/server/payrollStorage.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const originalStorageDir = process.env.PAYROLL_STORAGE_DIR; +const originalForgeUrl = process.env.BUILT_IN_FORGE_API_URL; +const originalForgeKey = process.env.BUILT_IN_FORGE_API_KEY; + +afterEach(async () => { + vi.restoreAllMocks(); + if (originalStorageDir === undefined) delete process.env.PAYROLL_STORAGE_DIR; + else process.env.PAYROLL_STORAGE_DIR = originalStorageDir; + if (originalForgeUrl === undefined) delete process.env.BUILT_IN_FORGE_API_URL; + else process.env.BUILT_IN_FORGE_API_URL = originalForgeUrl; + if (originalForgeKey === undefined) delete process.env.BUILT_IN_FORGE_API_KEY; + else process.env.BUILT_IN_FORGE_API_KEY = originalForgeKey; + vi.resetModules(); +}); + +describe("stockage persistant des bulletins", () => { + it("conserve un PDF déjà chiffré dans le répertoire persistant quand Forge est indisponible", async () => { + const storageDir = await mkdtemp(join(tmpdir(), "itinova-payroll-storage-")); + process.env.PAYROLL_STORAGE_DIR = storageDir; + delete process.env.BUILT_IN_FORGE_API_URL; + delete process.env.BUILT_IN_FORGE_API_KEY; + vi.stubGlobal("fetch", vi.fn(() => { throw new Error("Forge ne doit pas être appelé"); })); + + const { payrollStorageGetBuffer, payrollStoragePut } = await import("./storage"); + const ciphertext = Buffer.from("octets-chiffres-de-test", "utf8"); + const stored = await payrollStoragePut("salaires/test.pdf.enc", ciphertext, "application/octet-stream"); + + expect(stored.key).toMatch(/^salaires\/test\.pdf_[a-f0-9]{8}\.enc$/); + await expect(payrollStorageGetBuffer(stored.key)).resolves.toEqual(ciphertext); + + await rm(storageDir, { recursive: true, force: true }); + }); +}); diff --git a/server/salairesHttp.test.ts b/server/salairesHttp.test.ts index 30cb227..fafac61 100644 --- a/server/salairesHttp.test.ts +++ b/server/salairesHttp.test.ts @@ -9,14 +9,14 @@ vi.mock("./db", () => ({ getSalaireLiasseById: vi.fn(), replaceSalaireLiasseIndex: vi.fn(), })); -vi.mock("./storage", () => ({ storagePut: vi.fn(), storageGetBuffer: vi.fn() })); +vi.mock("./storage", () => ({ payrollStoragePut: vi.fn(), payrollStorageGetBuffer: vi.fn() })); vi.mock("pdf-parse/lib/pdf-parse.js", () => ({ default: vi.fn() })); import { sdk } from "./_core/sdk"; import { createSalaireLiasse, getSalaireLiasseById, getSalaireLiasseByPeriod } from "./db"; import { encryptPayrollPdf } from "./payrollCrypto"; import { registerSalairesHttpRoutes } from "./salairesHttp"; -import { storageGetBuffer, storagePut } from "./storage"; +import { payrollStorageGetBuffer, payrollStoragePut } from "./storage"; import pdfParse from "pdf-parse/lib/pdf-parse.js"; const EXTRACTED_TEXT = `##BULLETIN##05-2026##009999##DUPONT##TEST## @@ -49,13 +49,13 @@ describe("routes HTTP Salaires", () => { .expect(403); expect(createSalaireLiasse).not.toHaveBeenCalled(); - expect(storagePut).not.toHaveBeenCalled(); + expect(payrollStoragePut).not.toHaveBeenCalled(); }); it("chiffre et archive une liasse administrateur sans persister de donnée sensible", async () => { (sdk.authenticateRequest as ReturnType).mockResolvedValue({ id: 1, role: "admin" }); (pdfParse as ReturnType).mockResolvedValue({ text: EXTRACTED_TEXT }); - (storagePut as ReturnType).mockResolvedValue({ key: "salaires/2026/05/liasse_opaque.enc" }); + (payrollStoragePut as ReturnType).mockResolvedValue({ key: "salaires/2026/05/liasse_opaque.enc" }); (createSalaireLiasse as ReturnType).mockResolvedValue({ liasseId: 12, bulletins: 1 }); const response = await request(makeApp()) @@ -81,7 +81,7 @@ describe("routes HTTP Salaires", () => { const plaintext = Buffer.from("%PDF-1.7 contenu de bulletin", "utf8"); const encrypted = encryptPayrollPdf(plaintext); (getSalaireLiasseById as ReturnType).mockResolvedValue({ id: 30001, stockageKey: "opaque", ivBase64: encrypted.ivBase64, authTagBase64: encrypted.authTagBase64, annee: 2026, mois: 5 }); - (storageGetBuffer as ReturnType).mockResolvedValue(encrypted.ciphertext); + (payrollStorageGetBuffer as ReturnType).mockResolvedValue(encrypted.ciphertext); const response = await request(makeApp()).get("/api/salaires/liasses/30001/pdf").expect(200); @@ -103,7 +103,7 @@ describe("routes HTTP Salaires", () => { (sdk.authenticateRequest as ReturnType).mockResolvedValue({ id: 1, role: "admin" }); const encrypted = encryptPayrollPdf(Buffer.from("%PDF-1.7 contenu de bulletin", "utf8")); (getSalaireLiasseById as ReturnType).mockResolvedValue({ id: 30001, stockageKey: "opaque", ivBase64: encrypted.ivBase64, authTagBase64: encrypted.authTagBase64, annee: 2026, mois: 5 }); - (storageGetBuffer as ReturnType).mockResolvedValue(encrypted.ciphertext); + (payrollStorageGetBuffer as ReturnType).mockResolvedValue(encrypted.ciphertext); (pdfParse as ReturnType).mockResolvedValue({ text: EXTRACTED_TEXT }); (replaceSalaireLiasseIndex as ReturnType).mockResolvedValue({ bulletins: 1 }); @@ -113,6 +113,6 @@ describe("routes HTTP Salaires", () => { statutExtraction: "ready", bulletins: [expect.objectContaining({ matricule: "009999" })], })); - expect(storagePut).not.toHaveBeenCalled(); + expect(payrollStoragePut).not.toHaveBeenCalled(); }); }); diff --git a/server/salairesHttp.ts b/server/salairesHttp.ts index eba350c..da391a9 100644 --- a/server/salairesHttp.ts +++ b/server/salairesHttp.ts @@ -6,7 +6,7 @@ import { createSalaireLiasse, getSalaireLiasseById, getSalaireLiasseByPeriod, re import { decryptPayrollPdf, encryptPayrollPdf } from "./payrollCrypto"; import { extractPayrollBulletins, type PayrollExtractionResult } from "./payrollParser"; import { extractPayrollPdfText } from "./payrollPdfText"; -import { storageGetBuffer, storagePut } from "./storage"; +import { payrollStorageGetBuffer, payrollStoragePut } from "./storage"; const MAX_PDF_BYTES = 25 * 1024 * 1024; const upload = multer({ @@ -82,7 +82,7 @@ export function registerSalairesHttpRoutes(app: Express) { try { const encrypted = encryptPayrollPdf(file.buffer); - const archive = await storagePut( + const archive = await payrollStoragePut( `salaires/${annee}/${String(mois).padStart(2, "0")}/liasse.pdf.enc`, encrypted.ciphertext, "application/octet-stream", @@ -128,7 +128,7 @@ export function registerSalairesHttpRoutes(app: Express) { return; } try { - const ciphertext = await storageGetBuffer(liasse.stockageKey); + const ciphertext = await payrollStorageGetBuffer(liasse.stockageKey); const pdf = decryptPayrollPdf({ ciphertext, ivBase64: liasse.ivBase64, authTagBase64: liasse.authTagBase64 }); if (!isPdf(pdf)) throw new Error("Invalid decrypted PDF signature"); const extraction = extractPayrollBulletins(await extractPayrollPdfText(pdf)); @@ -160,7 +160,7 @@ export function registerSalairesHttpRoutes(app: Express) { } try { - const ciphertext = await storageGetBuffer(liasse.stockageKey); + const ciphertext = await payrollStorageGetBuffer(liasse.stockageKey); const pdf = decryptPayrollPdf({ ciphertext, ivBase64: liasse.ivBase64, diff --git a/server/storage.ts b/server/storage.ts index 0728ac5..5d830c0 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -2,6 +2,8 @@ // Uploads via Forge Server presigned URL to S3 (PUT direct). // Downloads return /manus-storage/{key} paths served via 307 redirect. +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, relative, resolve } from "node:path"; import { ENV } from "./_core/env"; function getForgeConfig() { @@ -107,3 +109,43 @@ export async function storageGetBuffer(relKey: string): Promise { if (!response.ok) throw new Error(`Storage download failed (${response.status})`); return Buffer.from(await response.arrayBuffer()); } + +/** + * Répertoire persistant optionnel réservé aux PDF de paie déjà chiffrés. + * Il n'est utilisé que sur les environnements sans configuration Forge, où le + * volume Docker hôte survit aux reconstructions de l'image applicative. + */ +function getPayrollStoragePath(relKey: string): string | null { + const configuredDirectory = process.env.PAYROLL_STORAGE_DIR?.trim(); + if (!configuredDirectory) return null; + + const root = resolve(configuredDirectory); + const destination = resolve(root, normalizeKey(relKey)); + const pathFromRoot = relative(root, destination); + if (pathFromRoot.startsWith("..") || pathFromRoot === "") { + throw new Error("Invalid payroll storage key"); + } + return destination; +} + +export async function payrollStoragePut( + relKey: string, + data: Buffer | Uint8Array | string, + contentType = "application/octet-stream", +): Promise<{ key: string; url: string }> { + const localKey = appendHashSuffix(normalizeKey(relKey)); + const localDestination = getPayrollStoragePath(localKey); + if (!localDestination) return storagePut(relKey, data, contentType); + + await mkdir(dirname(localDestination), { recursive: true, mode: 0o700 }); + await writeFile(localDestination, data, { mode: 0o600, flag: "wx" }); + // Les appels paie n'exposent jamais cette URL : la lecture passe exclusivement + // par la route administrateur qui déchiffre côté serveur. + return { key: localKey, url: `/manus-storage/${localKey}` }; +} + +export async function payrollStorageGetBuffer(relKey: string): Promise { + const localDestination = getPayrollStoragePath(relKey); + if (!localDestination) return storageGetBuffer(relKey); + return readFile(localDestination); +} diff --git a/todo.md b/todo.md index 1fd643f..a82ae7e 100644 --- a/todo.md +++ b/todo.md @@ -188,6 +188,8 @@ - [ ] Vérifier l'application Salaires déployée en HTTPS sur la recette - [ ] Configurer la clé AES des bulletins dans l'environnement sécurisé de recette et recréer le conteneur - [ ] Transmettre explicitement PAYROLL_ENCRYPTION_KEY au conteneur applicatif de recette +- [ ] Vérifier la disponibilité du stockage persistant chiffré dans l'environnement de recette +- [ ] Stocker les PDF déjà chiffrés sur le volume persistant de recette si Forge n'est pas configuré ## Exécution en arrière-plan - [ ] Finaliser la fenêtre Salaires et le déploiement de recette sans intervention utilisateur