Trois problèmes, dont un grave, trouvés en corrigeant un échec de déploiement. **Le compose connectait l'application en superutilisateur PostgreSQL.** Un superutilisateur contourne toute politique de sécurité au niveau ligne, y compris déclarée en FORCE : la seconde couche d'isolation était présente en base et absente des faits. Le README l'interdisait déjà noir sur blanc ; le chemin de déploiement que nous livrons faisait exactement l'inverse. Un script d'initialisation crée désormais un rôle `planflow_app` NOSUPERUSER NOBYPASSRLS, propriétaire de la base — il lui faut ce droit pour migrer, et les politiques sont en FORCE précisément pour s'appliquer aussi au propriétaire. Mesuré : en superutilisateur, deux lignes visibles sans compte courant ; avec le rôle dédié, zéro. Basculer la base de développement sur ce même rôle a révélé le défaut que le superutilisateur masquait : `resolveSession` lisait `Membership`, table filtrée par compte, sans périmètre. Avec la RLS active, plus personne ne pouvait se connecter. La résolution passe maintenant par une porte étroite — une politique qui n'ouvre que les lignes dont l'utilisateur est titulaire, sous `app.user_id` — le temps de trouver le compte, puis repasse par le périmètre ordinaire. La suite de tests traverse enfin la RLS au lieu de la contourner. **Les pièces du dossier salarié n'avaient aucun volume.** Elles étaient écrites dans la couche du conteneur et disparaissaient au premier redéploiement. Une pièce d'identité perdue ne se reconstitue pas. Le reste répond à la demande : Postgres préconfiguré — la base n'étant ni publiée ni attachée au réseau du proxy, ce mot de passe protège d'un conteneur voisin, pas d'Internet — réseau `nginx_default` déclaré externe avec l'application seule dessus, et port publié peu courant. ENCRYPTION_KEY reste la seule variable sans valeur par défaut, et n'en aura pas : elle chiffre le NIR, l'IBAN et les arrêts de travail. Une clé livrée avec l'image serait connue de quiconque lit ce dépôt. Au démarrage, l'application contrôle ses propres privilèges : elle refuse de se lancer si la base porte plus d'un compte, et se contente d'un avertissement s'il n'y en a qu'un — bloquer une installation mono-compte fermerait l'accès de l'entreprise à ses données pour une fuite entre clients qui ne peut pas se produire. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
210 lines
11 KiB
Markdown
210 lines
11 KiB
Markdown
# PlanFlow
|
|
|
|
Gestion du personnel, des plannings et des temps, multi-établissements, auto-hébergée. La paie est **exportée vers Silae** ; PlanFlow ne produit ni bulletin ni DSN.
|
|
|
|
La spécification de construction est [`PLAN.md`](PLAN.md). Elle est normative : en cas d'écart entre le code et le plan, c'est le plan qui a raison, ou le plan qui doit être corrigé — jamais l'écart qui s'installe.
|
|
|
|
| Document | Rôle |
|
|
|---|---|
|
|
| [`PLAN.md`](PLAN.md) | Spécification : périmètre, modèle de données, règles, lots de travail |
|
|
| [`matrice-conformite-rh-france-2026.md`](matrice-conformite-rh-france-2026.md) | Exigences réglementaires françaises, cotées P0/P1/P2 |
|
|
| [`Audit Combo/`](Audit%20Combo/INDEX.md) | Audit fonctionnel du produit de référence |
|
|
|
|
## État
|
|
|
|
Neuf lots livrés. **Aucun écran ne lit plus de données de démonstration** : le
|
|
répertoire `src/lib/demo` a disparu.
|
|
|
|
| Lot | Contenu |
|
|
|---|---|
|
|
| WP-00 | Socle : Next.js, PostgreSQL, CSP restrictive, CI, sauvegardes |
|
|
| WP-01 | Tenancy, identité, autorisation : RLS, audit append-only, 70 capacités, cinq rôles |
|
|
| WP-02 | Référentiels et registre de paramétrage juridique |
|
|
| WP-03 | Dossiers salariés, contrats et avenants, forfait jours |
|
|
| WP-04 | Planning : quatre vues, publication par équipe, impression |
|
|
| WP-05 | Moteur de règles de convention, effectif-daté |
|
|
| WP-06 | Absences, registre de compteurs, calendrier |
|
|
| WP-07 | Heures prévu/réalisé/payé, périodes de paie verrouillables |
|
|
| WP-08 | Export Silae, format relevé sur un export réel du dossier |
|
|
| WP-09 | Tableau de bord RH : indicateurs explicables |
|
|
|
|
Restent WP-10 (documents) et WP-11 (communication, optionnel), ainsi que deux
|
|
points nommés à l'intérieur des lots livrés : la régularisation automatique sur
|
|
la période suivante et l'information au retour d'arrêt.
|
|
|
|
### Ce qui attend une décision du client
|
|
|
|
Ces points sont des **signaux d'arrêt** au sens de `PLAN.md` : ils ne se
|
|
devinent pas.
|
|
|
|
- Les **codes d'absence Silae** (`AB-100`, `AB-200`, `AB-300`, `AB-630`) : leur
|
|
existence est connue, leur signification non. L'export refuse de tourner tant
|
|
que la correspondance n'est pas confirmée.
|
|
- Le **régime dominical** applicable aux magasins, qui détermine si la
|
|
majoration de 100 % et le repos compensateur sont les bonnes contreparties.
|
|
- Les **règles applicables aux mineurs**, sans source primaire au dossier.
|
|
|
|
## Démarrer
|
|
|
|
### Avec Docker
|
|
|
|
```bash
|
|
cp .env.example .env
|
|
# ENCRYPTION_KEY est la seule variable sans valeur par défaut :
|
|
echo "ENCRYPTION_KEY=$(openssl rand -base64 32)" >> .env
|
|
docker compose up --build
|
|
```
|
|
|
|
L'application écoute sur <http://localhost:9317> — port peu courant à dessein, le service étant censé passer par un reverse-proxy. Les migrations s'appliquent au démarrage du conteneur.
|
|
|
|
La pile attend un réseau externe nommé `nginx_default`, celui du reverse-proxy. S'il n'existe pas encore :
|
|
|
|
```bash
|
|
docker network create nginx_default
|
|
```
|
|
|
|
Seule l'application y est attachée. La base reste sur le réseau privé de la pile : l'exposer au réseau du proxy la rendrait joignable par tout ce qu'il héberge.
|
|
|
|
### Avec Portainer
|
|
|
|
Portainer ne lit pas de fichier `.env` : les variables se déclarent dans l'écran de la pile, section **Environment variables**. Une seule est obligatoire :
|
|
|
|
| Variable | Valeur |
|
|
|---|---|
|
|
| `ENCRYPTION_KEY` | `openssl rand -base64 32` |
|
|
|
|
Les autres ont une valeur par défaut utilisable telle quelle : `POSTGRES_PASSWORD`, `POSTGRES_USER`, `POSTGRES_DB`, `APP_PORT` (9317), `APP_URL`.
|
|
|
|
Renseignez `APP_URL` avec l'adresse publique réelle, sans quoi les liens des messages — invitations comprises — pointeront vers `localhost` et personne ne pourra les suivre.
|
|
|
|
### En local
|
|
|
|
Nécessite Node 22, pnpm 10 et un PostgreSQL 16 accessible.
|
|
|
|
```bash
|
|
pnpm install
|
|
cp .env.example .env # renseigner DATABASE_URL et ENCRYPTION_KEY
|
|
pnpm db:generate
|
|
pnpm db:deploy
|
|
pnpm dev
|
|
```
|
|
|
|
### Clé de chiffrement
|
|
|
|
`ENCRYPTION_KEY` chiffre au repos les colonnes sensibles exigées par le plan (§3.6) — NIR, IBAN, BIC — ainsi que les secrets de second facteur, le mot de passe du serveur d'envoi et **les pièces du dossier salarié**.
|
|
|
|
```bash
|
|
openssl rand -base64 32
|
|
```
|
|
|
|
`ENCRYPTION_KEY` n'a **délibérément pas de valeur par défaut**, et n'en aura pas : une clé livrée avec l'image serait connue de quiconque lit ce dépôt, et le chiffrement ne protégerait plus rien. C'est la seule variable qui bloque le démarrage tant qu'elle manque.
|
|
|
|
Elle vit **hors de la base** : une sauvegarde volée ne doit pas suffire à lire ces colonnes. La perdre rend ces données irrécupérables — la sauvegarder séparément et documenter sa rotation. Elle chiffre également les secrets de second facteur et le mot de passe du serveur d'envoi.
|
|
|
|
### Sauvegardes
|
|
|
|
Deux choses à sauvegarder **ensemble**, plus une à garder à part :
|
|
|
|
| Quoi | Où |
|
|
|---|---|
|
|
| Base de données | volume `planflow_db-data` |
|
|
| Pièces du dossier salarié | volume `planflow_documents` |
|
|
| `ENCRYPTION_KEY` | **ailleurs**, jamais dans la même sauvegarde |
|
|
|
|
Restaurer l'un sans l'autre rend un dossier amputé : les pièces référencées en base pointeraient vers des fichiers absents. Et sans la clé, le volume des documents est illisible — c'est précisément ce qu'on attend de lui si quelqu'un l'emporte.
|
|
|
|
### Second facteur — accès de secours
|
|
|
|
Les rôles qui lisent les rémunérations ou distribuent les droits doivent porter un second facteur (matrice n° 15) : tant qu'il n'est pas activé, l'application ne leur ouvre aucun écran. Chaque activation délivre dix codes de secours, affichés **une seule fois**.
|
|
|
|
PlanFlow étant auto-hébergé, il n'y a pas d'éditeur à appeler si un administrateur perd à la fois son téléphone et ses codes. Le retrait se fait alors depuis le serveur :
|
|
|
|
```bash
|
|
pnpm mfa:reset adresse@example.fr
|
|
```
|
|
|
|
Le retrait révoque les sessions ouvertes et s'inscrit au journal d'audit. Il n'est délibérément pas exposé dans l'application : l'exécuter demande déjà un accès au serveur, c'est-à-dire davantage que ce que le second facteur protège.
|
|
|
|
### Durées de conservation
|
|
|
|
Aucune durée n'est appliquée par défaut : la matrice interdit d'aligner tout sur cinq ans, et un objet sans politique déclarée se conserve. Les durées se déclarent dans Réglages → Durées de conservation, chacune avec sa justification — elle devra être défendue lors d'un contrôle.
|
|
|
|
La purge doit tourner périodiquement, par exemple en `cron` :
|
|
|
|
```bash
|
|
pnpm retention:purge --dry # inventaire, n'efface rien
|
|
pnpm retention:purge # efface les pièces échues, compte par compte
|
|
```
|
|
|
|
Une conservation à titre probatoire (*legal hold*) suspend la purge des objets qu'elle vise, quelle que soit leur échéance. Les journaux d'audit y échappent par construction : ils doivent survivre aux données qu'ils décrivent, sans quoi il deviendrait impossible de démontrer que la purge a eu lieu.
|
|
|
|
## Vérifier
|
|
|
|
```bash
|
|
pnpm verify # typecheck + lint + tests unitaires
|
|
pnpm test:e2e # build, serveur standalone, tests de bout en bout
|
|
```
|
|
|
|
`pnpm verify` est ce que la CI exécute sur chaque *pull request*, suivi du build et des tests end-to-end.
|
|
|
|
## Configuration de la base — à ne pas rater
|
|
|
|
**L'application ne doit pas se connecter en superutilisateur PostgreSQL.**
|
|
|
|
En docker-compose c'est déjà réglé : `docker/init-app-role.sh` crée au premier démarrage un rôle `planflow_app`, `NOSUPERUSER NOBYPASSRLS`, propriétaire de la base — il lui faut ce droit pour appliquer les migrations, et les politiques sont déclarées en `FORCE` précisément pour s'appliquer aussi au propriétaire.
|
|
|
|
Le script ne s'exécute qu'à la **première** initialisation du volume. Sur une installation déjà en place, jouer le même SQL à la main puis basculer `DATABASE_URL` sur ce rôle.
|
|
|
|
Au démarrage, l'application vérifie ses propres privilèges : elle refuse de se lancer si la base porte plus d'un compte, et se contente d'un avertissement visible dans les journaux s'il n'y en a qu'un — bloquer une installation mono-compte fermerait l'accès de l'entreprise à ses données pour un risque de fuite entre clients qui n'existe pas.
|
|
|
|
Un superutilisateur contourne la *row-level security*, y compris déclarée en `FORCE`. Connecter PlanFlow avec un tel compte désactive silencieusement la seconde couche d'isolation multi-tenant : les requêtes fonctionnent, les tests applicatifs passent, et rien n'indique que la protection a disparu — jusqu'au jour où quelqu'un lit les données d'un autre établissement.
|
|
|
|
```sql
|
|
CREATE ROLE planflow_app LOGIN PASSWORD '…' NOSUPERUSER NOBYPASSRLS;
|
|
GRANT USAGE ON SCHEMA public TO planflow_app;
|
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO planflow_app;
|
|
```
|
|
|
|
Les migrations, elles, s'appliquent avec un compte propriétaire distinct.
|
|
|
|
L'application vérifie ce point au démarrage : elle refuse de démarrer en production sur une base mal configurée, et se contente d'un avertissement en développement. `GET /api/sante` expose l'état sous `tenantIsolation`.
|
|
|
|
## Choix structurants
|
|
|
|
**Aucun traceur tiers.** L'audit du produit de référence a intercepté 2102 requêtes de traçage — Segment, LinkedIn Ads, Google Ads, DoubleClick, Clarity, Hotjar — et aucune requête métier. Une application RH ne doit pas envoyer un contexte de navigation portant sur des salariés identifiables à des régies publicitaires. Deux garde-fous rendent la règle vérifiable plutôt que déclarative :
|
|
|
|
- une Content-Security-Policy qui ne nomme **aucune** origine externe, posée par requête avec un nonce (`src/proxy.ts`) ;
|
|
- un test qui échoue si une dépendance de traçage apparaît dans `package.json`.
|
|
|
|
Pour de la télémétrie technique, passer par une interface abstraite auto-hébergée.
|
|
|
|
**Le serveur testé est celui qui est déployé.** Les tests end-to-end lancent le serveur `standalone`, celui que l'image Docker exécute — pas `next dev`, dont la politique de sécurité est volontairement plus permissive.
|
|
|
|
## Structure
|
|
|
|
```
|
|
src/
|
|
├── app/ écrans (App Router)
|
|
├── lib/
|
|
│ ├── env.ts contrat d'environnement, validé à l'import
|
|
│ └── security/csp.ts politique de sécurité, fonction pure et testable
|
|
├── server/
|
|
│ ├── db.ts client Prisma — le scoping multi-tenant s'y greffe au WP-01
|
|
│ └── health.ts
|
|
└── proxy.ts en-têtes de sécurité par requête
|
|
prisma/ schéma et migrations
|
|
tests/
|
|
├── unit/ Vitest
|
|
└── e2e/ Playwright
|
|
```
|
|
|
|
## Écarts assumés par rapport au plan
|
|
|
|
Trois choix diffèrent de ce qu'annonçait `PLAN.md` §2, et le plan a été mis à jour en conséquence.
|
|
|
|
| Sujet | Plan initial | Retenu | Raison |
|
|
|---|---|---|---|
|
|
| Next.js | 15 | **16.3** | Version stable courante ; démarrer un greenfield une majeure en retard n'a pas de contrepartie. |
|
|
| Authentification | Auth.js v5 | **Sessions maison en base** | Auth.js v5 est encore en beta. Le besoin se limite à identifiants et invitation, sans OAuth, et la matrice de conformité (n° 23) impose la **révocation de session** — immédiate avec des sessions en base, malaisée avec des jetons JWT. |
|
|
| Convention Next | `middleware.ts` | **`proxy.ts`** | Next 16 a renommé la convention ; `middleware` est déprécié. |
|