Checkpoint: Ajout du secours de stockage réservé aux PDF de paie chiffrés : lorsque Forge est indisponible en recette, les octets AES-256-GCM sont stockés dans un volume hôte Docker persistant, jamais dans l'image ni la base. Import, réindexation et consultation passent par cette même abstraction, avec test de round-trip sans Forge. Compose transmet la clé AES et monte le volume dédié. Validation complète : 56 tests, TypeScript, build et format YAML.
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 2m9s

This commit is contained in:
Manus
2026-08-29 23:19:21 +00:00
parent 7698e9f680
commit 193f7ce32a
7 changed files with 134 additions and 36 deletions

View File

@@ -4,32 +4,35 @@ services:
container_name: itinova-budget-si-app container_name: itinova-budget-si-app
restart: unless-stopped restart: unless-stopped
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- DATABASE_URL=mysql://budget_user:BudgetSI2027!@db:3306/budget_si - DATABASE_URL=mysql://budget_user:BudgetSI2027!@db:3306/budget_si
- JWT_SECRET=${JWT_SECRET:-budget-si-recette-jwt-secret-2027} - JWT_SECRET=${JWT_SECRET:-budget-si-recette-jwt-secret-2027}
- VITE_APP_ID=${VITE_APP_ID:-} - VITE_APP_ID=${VITE_APP_ID:-}
- OAUTH_SERVER_URL=${OAUTH_SERVER_URL:-} - OAUTH_SERVER_URL=${OAUTH_SERVER_URL:-}
- VITE_OAUTH_PORTAL_URL=${VITE_OAUTH_PORTAL_URL:-} - VITE_OAUTH_PORTAL_URL=${VITE_OAUTH_PORTAL_URL:-}
- OWNER_OPEN_ID=${OWNER_OPEN_ID:-} - OWNER_OPEN_ID=${OWNER_OPEN_ID:-}
- OWNER_NAME=${OWNER_NAME:-Administrateur} - OWNER_NAME=${OWNER_NAME:-Administrateur}
- BUILT_IN_FORGE_API_URL=${BUILT_IN_FORGE_API_URL:-} - BUILT_IN_FORGE_API_URL=${BUILT_IN_FORGE_API_URL:-}
- BUILT_IN_FORGE_API_KEY=${BUILT_IN_FORGE_API_KEY:-} - BUILT_IN_FORGE_API_KEY=${BUILT_IN_FORGE_API_KEY:-}
- VITE_FRONTEND_FORGE_API_KEY=${VITE_FRONTEND_FORGE_API_KEY:-} - VITE_FRONTEND_FORGE_API_KEY=${VITE_FRONTEND_FORGE_API_KEY:-}
- VITE_FRONTEND_FORGE_API_URL=${VITE_FRONTEND_FORGE_API_URL:-} - VITE_FRONTEND_FORGE_API_URL=${VITE_FRONTEND_FORGE_API_URL:-}
- VITE_ANALYTICS_ENDPOINT=${VITE_ANALYTICS_ENDPOINT:-} - VITE_ANALYTICS_ENDPOINT=${VITE_ANALYTICS_ENDPOINT:-}
- VITE_ANALYTICS_WEBSITE_ID=${VITE_ANALYTICS_WEBSITE_ID:-} - VITE_ANALYTICS_WEBSITE_ID=${VITE_ANALYTICS_WEBSITE_ID:-}
- PAYROLL_ENCRYPTION_KEY=${PAYROLL_ENCRYPTION_KEY:-} - 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: depends_on:
db: db:
condition: service_healthy condition: service_healthy
networks: networks:
- web - web
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.budget-si.rule=Host(`budget-si.recette.santinova-soft.org`)" - "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.entrypoints=websecure"
- "traefik.http.routers.budget-si.tls.certresolver=letsencrypt" - "traefik.http.routers.budget-si.tls.certresolver=letsencrypt"
- "traefik.http.services.budget-si.loadbalancer.server.port=3000" - "traefik.http.services.budget-si.loadbalancer.server.port=3000"
db: db:
image: mysql:8.0 image: mysql:8.0
@@ -41,14 +44,25 @@ services:
MYSQL_USER: budget_user MYSQL_USER: budget_user
MYSQL_PASSWORD: BudgetSI2027! MYSQL_PASSWORD: BudgetSI2027!
volumes: volumes:
- db_data:/var/lib/mysql - db_data:/var/lib/mysql
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pBudgetSI2027!"] test:
[
"CMD",
"mysqladmin",
"ping",
"-h",
"localhost",
"-u",
"root",
"-pBudgetSI2027!",
]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
networks: networks:
- web - web
volumes: volumes:
db_data: db_data:

View File

@@ -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. 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. 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. 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. 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.

View File

@@ -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 });
});
});

View File

@@ -9,14 +9,14 @@ vi.mock("./db", () => ({
getSalaireLiasseById: vi.fn(), getSalaireLiasseById: vi.fn(),
replaceSalaireLiasseIndex: 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() })); vi.mock("pdf-parse/lib/pdf-parse.js", () => ({ default: vi.fn() }));
import { sdk } from "./_core/sdk"; import { sdk } from "./_core/sdk";
import { createSalaireLiasse, getSalaireLiasseById, getSalaireLiasseByPeriod } from "./db"; import { createSalaireLiasse, getSalaireLiasseById, getSalaireLiasseByPeriod } from "./db";
import { encryptPayrollPdf } from "./payrollCrypto"; import { encryptPayrollPdf } from "./payrollCrypto";
import { registerSalairesHttpRoutes } from "./salairesHttp"; import { registerSalairesHttpRoutes } from "./salairesHttp";
import { storageGetBuffer, storagePut } from "./storage"; import { payrollStorageGetBuffer, payrollStoragePut } from "./storage";
import pdfParse from "pdf-parse/lib/pdf-parse.js"; import pdfParse from "pdf-parse/lib/pdf-parse.js";
const EXTRACTED_TEXT = `##BULLETIN##05-2026##009999##DUPONT##TEST## const EXTRACTED_TEXT = `##BULLETIN##05-2026##009999##DUPONT##TEST##
@@ -49,13 +49,13 @@ describe("routes HTTP Salaires", () => {
.expect(403); .expect(403);
expect(createSalaireLiasse).not.toHaveBeenCalled(); 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 () => { it("chiffre et archive une liasse administrateur sans persister de donnée sensible", async () => {
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 1, role: "admin" }); (sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 1, role: "admin" });
(pdfParse as ReturnType<typeof vi.fn>).mockResolvedValue({ text: EXTRACTED_TEXT }); (pdfParse as ReturnType<typeof vi.fn>).mockResolvedValue({ text: EXTRACTED_TEXT });
(storagePut as ReturnType<typeof vi.fn>).mockResolvedValue({ key: "salaires/2026/05/liasse_opaque.enc" }); (payrollStoragePut as ReturnType<typeof vi.fn>).mockResolvedValue({ key: "salaires/2026/05/liasse_opaque.enc" });
(createSalaireLiasse as ReturnType<typeof vi.fn>).mockResolvedValue({ liasseId: 12, bulletins: 1 }); (createSalaireLiasse as ReturnType<typeof vi.fn>).mockResolvedValue({ liasseId: 12, bulletins: 1 });
const response = await request(makeApp()) 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 plaintext = Buffer.from("%PDF-1.7 contenu de bulletin", "utf8");
const encrypted = encryptPayrollPdf(plaintext); const encrypted = encryptPayrollPdf(plaintext);
(getSalaireLiasseById as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 30001, stockageKey: "opaque", ivBase64: encrypted.ivBase64, authTagBase64: encrypted.authTagBase64, annee: 2026, mois: 5 }); (getSalaireLiasseById as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 30001, stockageKey: "opaque", ivBase64: encrypted.ivBase64, authTagBase64: encrypted.authTagBase64, annee: 2026, mois: 5 });
(storageGetBuffer as ReturnType<typeof vi.fn>).mockResolvedValue(encrypted.ciphertext); (payrollStorageGetBuffer as ReturnType<typeof vi.fn>).mockResolvedValue(encrypted.ciphertext);
const response = await request(makeApp()).get("/api/salaires/liasses/30001/pdf").expect(200); 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<typeof vi.fn>).mockResolvedValue({ id: 1, role: "admin" }); (sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 1, role: "admin" });
const encrypted = encryptPayrollPdf(Buffer.from("%PDF-1.7 contenu de bulletin", "utf8")); const encrypted = encryptPayrollPdf(Buffer.from("%PDF-1.7 contenu de bulletin", "utf8"));
(getSalaireLiasseById as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 30001, stockageKey: "opaque", ivBase64: encrypted.ivBase64, authTagBase64: encrypted.authTagBase64, annee: 2026, mois: 5 }); (getSalaireLiasseById as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 30001, stockageKey: "opaque", ivBase64: encrypted.ivBase64, authTagBase64: encrypted.authTagBase64, annee: 2026, mois: 5 });
(storageGetBuffer as ReturnType<typeof vi.fn>).mockResolvedValue(encrypted.ciphertext); (payrollStorageGetBuffer as ReturnType<typeof vi.fn>).mockResolvedValue(encrypted.ciphertext);
(pdfParse as ReturnType<typeof vi.fn>).mockResolvedValue({ text: EXTRACTED_TEXT }); (pdfParse as ReturnType<typeof vi.fn>).mockResolvedValue({ text: EXTRACTED_TEXT });
(replaceSalaireLiasseIndex as ReturnType<typeof vi.fn>).mockResolvedValue({ bulletins: 1 }); (replaceSalaireLiasseIndex as ReturnType<typeof vi.fn>).mockResolvedValue({ bulletins: 1 });
@@ -113,6 +113,6 @@ describe("routes HTTP Salaires", () => {
statutExtraction: "ready", statutExtraction: "ready",
bulletins: [expect.objectContaining({ matricule: "009999" })], bulletins: [expect.objectContaining({ matricule: "009999" })],
})); }));
expect(storagePut).not.toHaveBeenCalled(); expect(payrollStoragePut).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -6,7 +6,7 @@ import { createSalaireLiasse, getSalaireLiasseById, getSalaireLiasseByPeriod, re
import { decryptPayrollPdf, encryptPayrollPdf } from "./payrollCrypto"; import { decryptPayrollPdf, encryptPayrollPdf } from "./payrollCrypto";
import { extractPayrollBulletins, type PayrollExtractionResult } from "./payrollParser"; import { extractPayrollBulletins, type PayrollExtractionResult } from "./payrollParser";
import { extractPayrollPdfText } from "./payrollPdfText"; import { extractPayrollPdfText } from "./payrollPdfText";
import { storageGetBuffer, storagePut } from "./storage"; import { payrollStorageGetBuffer, payrollStoragePut } from "./storage";
const MAX_PDF_BYTES = 25 * 1024 * 1024; const MAX_PDF_BYTES = 25 * 1024 * 1024;
const upload = multer({ const upload = multer({
@@ -82,7 +82,7 @@ export function registerSalairesHttpRoutes(app: Express) {
try { try {
const encrypted = encryptPayrollPdf(file.buffer); const encrypted = encryptPayrollPdf(file.buffer);
const archive = await storagePut( const archive = await payrollStoragePut(
`salaires/${annee}/${String(mois).padStart(2, "0")}/liasse.pdf.enc`, `salaires/${annee}/${String(mois).padStart(2, "0")}/liasse.pdf.enc`,
encrypted.ciphertext, encrypted.ciphertext,
"application/octet-stream", "application/octet-stream",
@@ -128,7 +128,7 @@ export function registerSalairesHttpRoutes(app: Express) {
return; return;
} }
try { try {
const ciphertext = await storageGetBuffer(liasse.stockageKey); const ciphertext = await payrollStorageGetBuffer(liasse.stockageKey);
const pdf = decryptPayrollPdf({ ciphertext, ivBase64: liasse.ivBase64, authTagBase64: liasse.authTagBase64 }); const pdf = decryptPayrollPdf({ ciphertext, ivBase64: liasse.ivBase64, authTagBase64: liasse.authTagBase64 });
if (!isPdf(pdf)) throw new Error("Invalid decrypted PDF signature"); if (!isPdf(pdf)) throw new Error("Invalid decrypted PDF signature");
const extraction = extractPayrollBulletins(await extractPayrollPdfText(pdf)); const extraction = extractPayrollBulletins(await extractPayrollPdfText(pdf));
@@ -160,7 +160,7 @@ export function registerSalairesHttpRoutes(app: Express) {
} }
try { try {
const ciphertext = await storageGetBuffer(liasse.stockageKey); const ciphertext = await payrollStorageGetBuffer(liasse.stockageKey);
const pdf = decryptPayrollPdf({ const pdf = decryptPayrollPdf({
ciphertext, ciphertext,
ivBase64: liasse.ivBase64, ivBase64: liasse.ivBase64,

View File

@@ -2,6 +2,8 @@
// Uploads via Forge Server presigned URL to S3 (PUT direct). // Uploads via Forge Server presigned URL to S3 (PUT direct).
// Downloads return /manus-storage/{key} paths served via 307 redirect. // 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"; import { ENV } from "./_core/env";
function getForgeConfig() { function getForgeConfig() {
@@ -107,3 +109,43 @@ export async function storageGetBuffer(relKey: string): Promise<Buffer> {
if (!response.ok) throw new Error(`Storage download failed (${response.status})`); if (!response.ok) throw new Error(`Storage download failed (${response.status})`);
return Buffer.from(await response.arrayBuffer()); 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<Buffer> {
const localDestination = getPayrollStoragePath(relKey);
if (!localDestination) return storageGetBuffer(relKey);
return readFile(localDestination);
}

View File

@@ -188,6 +188,8 @@
- [ ] Vérifier l'application Salaires déployée en HTTPS sur la recette - [ ] 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 - [ ] 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 - [ ] 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 ## Exécution en arrière-plan
- [ ] Finaliser la fenêtre Salaires et le déploiement de recette sans intervention utilisateur - [ ] Finaliser la fenêtre Salaires et le déploiement de recette sans intervention utilisateur