From 32ff6a63bab3e6e77eb27c99cd39119e1ef8c079 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Mon, 15 Jun 2026 12:16:11 +0200 Subject: [PATCH] P1 conception: security-by-design layer (Merise 21 entities, Forgejo CI/CD, hardening) (#3) --- .env.example | 44 +++ .forgejo/workflows/ci.yml | 2 - .gitea/PULL_REQUEST_TEMPLATE.md | 11 + SECURITY.md | 55 +++ docker/php-fpm/php.ini | 25 +- docs/PROJECT_CONTEXT.md | 218 ++++++++-- docs/architecture/forgejo-actions-runner.md | 102 +++++ docs/merise/_diagrams/mcd-catalogue.drawio | 67 ---- docs/merise/_diagrams/mcd-catalogue.mmd | 51 +++ docs/merise/_diagrams/mcd-catalogue.svg | 5 +- docs/merise/_diagrams/mcd-commande.drawio | 93 ----- docs/merise/_diagrams/mcd-commande.svg | 4 - docs/merise/_diagrams/mcd-global.drawio | 182 --------- docs/merise/_diagrams/mcd-global.svg | 4 - .../_diagrams/mcd-ingredients-stock.mmd | 61 +++ .../_diagrams/mcd-ingredients-stock.svg | 1 + docs/merise/_diagrams/mcd-order.mmd | 67 ++++ docs/merise/_diagrams/mcd-order.svg | 1 + docs/merise/_diagrams/mcd-rbac.drawio | 57 --- docs/merise/_diagrams/mcd-rbac.mmd | 64 +++ docs/merise/_diagrams/mcd-rbac.svg | 5 +- docs/merise/_diagrams/mld-catalogue.drawio | 59 --- docs/merise/_diagrams/mld-commande.drawio | 78 ---- docs/merise/_diagrams/mld-rbac.drawio | 56 --- docs/merise/dictionary.md | 158 +++++++- docs/merise/mcd.md | 144 +++++-- docs/merise/mct.md | 85 +++- docs/merise/mld.md | 187 +++++++-- docs/merise/mlt.md | 124 ++++-- docs/uml/security-sequence.md | 232 +++++++++++ docs/uml/sequence-passer-commande.md | 151 ++++--- docs/uml/state-commande.md | 152 +++---- docs/uml/use-cases.md | 374 +++++++++++------- scripts/forgejo-branch-protection.sh | 64 +++ scripts/forgejo-pr-automerge.sh | 59 +++ 35 files changed, 2016 insertions(+), 1026 deletions(-) create mode 100644 SECURITY.md create mode 100644 docs/architecture/forgejo-actions-runner.md delete mode 100644 docs/merise/_diagrams/mcd-catalogue.drawio create mode 100644 docs/merise/_diagrams/mcd-catalogue.mmd delete mode 100644 docs/merise/_diagrams/mcd-commande.drawio delete mode 100644 docs/merise/_diagrams/mcd-commande.svg delete mode 100644 docs/merise/_diagrams/mcd-global.drawio delete mode 100644 docs/merise/_diagrams/mcd-global.svg create mode 100644 docs/merise/_diagrams/mcd-ingredients-stock.mmd create mode 100644 docs/merise/_diagrams/mcd-ingredients-stock.svg create mode 100644 docs/merise/_diagrams/mcd-order.mmd create mode 100644 docs/merise/_diagrams/mcd-order.svg delete mode 100644 docs/merise/_diagrams/mcd-rbac.drawio create mode 100644 docs/merise/_diagrams/mcd-rbac.mmd delete mode 100644 docs/merise/_diagrams/mld-catalogue.drawio delete mode 100644 docs/merise/_diagrams/mld-commande.drawio delete mode 100644 docs/merise/_diagrams/mld-rbac.drawio create mode 100644 docs/uml/security-sequence.md create mode 100755 scripts/forgejo-branch-protection.sh create mode 100755 scripts/forgejo-pr-automerge.sh diff --git a/.env.example b/.env.example index 0e2cb55..10d3c84 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,50 @@ CORS_ALLOWED_ORIGIN=https://kiosk.example.com # argon2id recommande depuis PHP 7.3 pour les nouveaux projets. PASSWORD_ALGO=argon2id +# Parametres de cout argon2id (password_hash options). +# Defauts alignes sur les recommandations OWASP Password Storage Cheat Sheet +# (memoire >= 19 MiB, >= 2 iterations). 65536 KiB = 64 MiB, marge confortable. +# Ces valeurs servent aussi au hash du PIN equipier (pin_hash, actions sensibles). +ARGON2_MEMORY_COST=65536 # KiB (64 MiB) +ARGON2_TIME_COST=4 # nombre d'iterations +ARGON2_THREADS=1 # parallelisme (1 = portable, deterministe) + +# =================================================================== +# Anti brute-force - throttling de connexion (security-by-design) +# =================================================================== +# Deux gardes complementaires (cf. docs/merise/mld.md 4.21 + PROJECT_CONTEXT 19) : +# 1. par compte : colonnes user.failed_login_attempts / user.lockout_until +# 2. par IP source : table login_throttle (entite 21) +# Backoff degressif (pas de lock definitif) : evite le DoS sur compte legitime. + +# Compteur par compte : nombre d'echecs avant declenchement du backoff. +ACCOUNT_LOCKOUT_THRESHOLD=5 +# Duree de base du backoff (secondes). Croit de facon degressive a chaque +# palier d'echecs supplementaires (ex : 60 -> 300 -> 900). +ACCOUNT_LOCKOUT_BASE_SECONDS=60 +ACCOUNT_LOCKOUT_MAX_SECONDS=900 # plafond du backoff (15 min) + +# Gate par IP : fenetre glissante et plafond de tentatives sur cette fenetre. +IP_THROTTLE_WINDOW_SECONDS=900 # 15 min +IP_THROTTLE_MAX_ATTEMPTS=20 # par IP sur la fenetre + +# PIN equipier pour actions sensibles (annulation, override). Longueur minimale. +STAFF_PIN_MIN_LENGTH=4 + +# Expiration du token de reinitialisation de mot de passe (secondes). +PASSWORD_RESET_TTL=3600 # 1h + +# =================================================================== +# Retention des donnees (RGPD - minimisation et limitation de conservation) +# =================================================================== +# Purges executees par le service cron (docker/cron/crontab). +# Justification documentee : interet legitime / obligations probatoires. + +AUDIT_LOG_RETENTION_DAYS=365 # journal d'audit ~12 mois +THROTTLE_PURGE_AFTER_HOURS=24 # login_throttle : lignes sans lockout actif > 24h +ORDER_RETENTION_DAYS=1095 # commandes (historique/stats) ~3 ans +# Purge des sessions expirees : deja geree par le cron */15 (voir crontab). + # =================================================================== # Upload images produits # =================================================================== diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index b42f73d..63f73b2 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -80,5 +80,3 @@ jobs: else echo "PHPUnit skipped: no tests/ + phpunit.xml yet (activates in P2)" fi - -# (CI pipeline validee le 2026-06-15 - test auto-merge) diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md index 9905ed8..0b72345 100644 --- a/.gitea/PULL_REQUEST_TEMPLATE.md +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -24,6 +24,17 @@ Remplis les sections, coche ce qui s'applique, supprime ce qui ne sert pas. - [ ] Docs Merise / dictionnaire a jour si le modele de donnees change - [ ] Tests ajoutes et passants si du code est touche (unit > integration > e2e) +## Checklist securite (security-by-design) + + + +- [ ] Aucun secret commite (CI gitleaks verte) ; `.env` reste gitignore +- [ ] Entrees utilisateur validees ; requetes SQL en prepared statements (anti-injection) +- [ ] Mots de passe / PIN en argon2id ; pas de donnee sensible en clair ni dans les logs +- [ ] Sorties HTML echappees (anti-XSS) ; CSRF gere sur les formulaires d'etat +- [ ] Permissions RBAC verifiees cote serveur pour toute action sensible +- [ ] Impact RGPD evalue si nouvelles donnees personnelles (retention, droit a l'effacement) + ## Bloc RNCP impacte diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5c6aa3f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,55 @@ +# Politique de securite - Wakdo + +Wakdo est un projet de fin de formation (RNCP 37805) construit en +**security-by-design** : la menace est modelisee avant le code. Ce document +resume la posture, le signalement de vulnerabilites et les garde-fous CI. + +## Modele de menace + +Le modele STRIDE complet, le registre des risques et la classification des +donnees (4 niveaux) vivent dans `docs/PROJECT_CONTEXT.md` section 19, et le flux +d'authentification durci dans `docs/uml/security-sequence.md`. + +## Mesures en place (resume) + +| Domaine | Mesure | +|---|---| +| Mots de passe | `password_hash` argon2id (cout configurable, defauts OWASP) | +| Actions sensibles | PIN equipier hashe argon2id (`pin_hash`) | +| Brute-force | double throttle : compteur par compte (`user`) + par IP (`login_throttle`), backoff degressif | +| Sessions | cookies `HttpOnly` + `Secure` + `SameSite=Strict`, regeneration d'ID a la connexion (anti-fixation), idle 4h / absolu 10h | +| Injection | PDO prepared statements exclusivement | +| Upload | validation MIME + taille, stockage hors webroot | +| En-tetes / PHP | `expose_php=Off`, `allow_url_fopen/include=Off`, `cgi.fix_pathinfo=0`, fonctions d'execution systeme desactivees | +| RGPD | retention limitee (audit ~12 mois, throttle 24h, commandes ~3 ans), droit consultation/modif/suppression | +| Secrets | `.env` gitignore, tenu hors de `.git/config` (credential helper lisant `.env`), secret-scan gitleaks en CI | + +Les seuils operationnels (couts argon2, lockout, throttle, retention) sont +documentes dans `.env.example`. + +## Garde-fous CI (Forgejo Actions) + +Chaque PR vers `dev` ou `main` declenche `.forgejo/workflows/ci.yml` : + +- **secret-scan** (gitleaks) : empeche un secret d'entrer dans l'historique +- **php-lint** : `php -l` sur tous les fichiers PHP +- **static-tests** : PHPStan + PHPUnit (s'activent quand le code PHP arrive en P2) + +La strategie de merge est **PR + auto-merge sur CI verte** (travail solo) : la +PR est obligatoire (trace de gouvernance), le merge se declenche automatiquement +une fois les checks au vert. Voir `scripts/forgejo-pr-automerge.sh` et +`scripts/forgejo-branch-protection.sh`. + +## Signaler une vulnerabilite + +Projet pedagogique non destine a la production publique. Pour signaler un +probleme de securite : ouvrir une issue sur le depot Forgejo +(`https://git.acadenice.com/AcadeNice/corentin_wakdo`) ou contacter l'auteur. +Merci de ne pas divulguer publiquement un detail exploitable avant correction. + +## Perimetre + +Couvert : authentification, autorisation (RBAC), gestion de session, validation +d'entree, integrite des donnees de commande, hygiene des secrets. +Hors perimetre : paiement reel (remplace par numero de commande), durcissement +OS de l'hote, securite physique de la borne. diff --git a/docker/php-fpm/php.ini b/docker/php-fpm/php.ini index bc210d1..dc53cee 100644 --- a/docker/php-fpm/php.ini +++ b/docker/php-fpm/php.ini @@ -41,8 +41,31 @@ session.cookie_secure = 1 ; Persistance inter-container non necessaire : chaque session est liee a une ; instance unique du service wakdo-app (pas de scale horizontal pour ce projet). -; --- Expose_php = Off : ne pas leak la version PHP dans l'entete HTTP --- +; session.gc_maxlifetime : filet de securite cote serveur (l'idle reel est +; pilote par l'appli via SESSION_LIFETIME_IDLE). 4h. +session.gc_maxlifetime = 14400 +; IDs de session longs et a forte entropie (anti-prediction/fixation). +session.sid_length = 48 +session.sid_bits_per_character = 6 +; Pas de cache navigateur sur les pages avec session (anti-fuite via cache). +session.cache_limiter = nocache + +; --- Durcissement general (security-by-design, cf. PROJECT_CONTEXT 19) --- +; Expose_php = Off : ne pas leak la version PHP dans l'entete HTTP. expose_php = Off +; Anti RFI/SSRF : interdire l'ouverture d'URL distantes et leur inclusion. +allow_url_fopen = Off +allow_url_include = Off +; FPM : ne pas deviner le script a partir du PATH_INFO (anti exploitation +; d'upload mal route vers l'interpreteur). Le routage passe par le front controller. +cgi.fix_pathinfo = 0 +; Interdire le chargement dynamique d'extensions au runtime. +enable_dl = Off +; Ne pas inclure les arguments dans les stack traces (anti-fuite de secrets). +zend.exception_ignore_args = On +; Desactiver les fonctions d'execution systeme : l'appli n'en a aucun usage +; legitime (anti-RCE en cas d'injection). Les scripts d'ops vivent cote cron/host. +disable_functions = exec,passthru,shell_exec,system,proc_open,popen ; --- OPcache (perf + stabilite) --- [opcache] diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 2efbaaf..674c287 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -26,12 +26,19 @@ Wakdo est une **borne de commande tactile** pour un restaurant de restauration r ### Acteurs -| Acteur | Role | Interface | +| Acteur | Role RBAC | Interface | |---|---|---| -| **Client** | Passe sa commande sur la borne | Borne tactile (Bloc 1) | -| **Accueil** | Saisit commandes au **comptoir** (client au guichet) ou au **drive** (client en voiture via intercom + casque equipier), remet les commandes livrees aux clients | Back-office (Bloc 2) | -| **Preparation** | Voit les commandes a preparer triees par heure croissante, les declare "preparees" | Back-office (Bloc 2) | -| **Administration** | CRUD sur donnees (produits, menus, prix, images) + gestion utilisateurs + stats | Back-office (Bloc 2) | +| **Client** | (non authentifie) | Borne tactile (Bloc 1, canal `kiosk`) | +| **Counter** | `counter` | Back-office : saisit les commandes au **comptoir**, les remet au client, peut annuler | +| **Drive** | `drive` | Back-office : saisit les commandes au **drive** (intercom + casque), les remet, peut annuler | +| **Kitchen** | `kitchen` | Back-office : voit la file des commandes `paid` triees par `paid_at` croissant, en **lecture seule** (KDS visuel, aucune transition) | +| **Manager** | `manager` | Back-office : catalogue (create/update), stock/reappro, statistiques | +| **Administration** | `admin` | Back-office : catalogue complet (+ suppressions), gestion utilisateurs, roles et permissions (RBAC), stats | + +> Modele v0.2 : 5 roles RBAC (`admin`, `manager`, `kitchen`, `counter`, `drive`) +> + Customer non authentifie. RBAC permission-driven (le code teste une +> permission, pas un nom de role) ; catalogue de 23 permissions fige au seed. +> Voir `docs/merise/dictionary.md` 3.15-3.18 et `docs/uml/use-cases.md`. ### Processus metier cle @@ -46,21 +53,21 @@ Client Borne (Bloc 1) API (Bloc 2) BDD │ │─POST /api/orders─────▶│───INSERT──────────▶│ │ │◀──────────201─────────│ │ │─recupere au comptoir │ │ │ - Preparation voit commande pending - → declare "preparee" - Accueil voit commande prete - → declare "livree" + Kitchen voit la file des commandes paid (lecture seule, KDS) + Counter / Drive remettent au client + → declarent "livree" (geste unique paid -> delivered) ``` ### Regles metier (MCT - a modeliser en Merise) -- Un **menu** = burger + accompagnement (frites OU salade) + boisson + sauce -- Les **accompagnements** et **boissons** ont **2 tailles** (normale / grande) -- **Grande taille** = +0,50 € sur le prix de base -- Une **commande** a un **numero** saisi par le client (remplace le paiement dans le cadre de l'exam) -- Statuts commande : `pending` -> `preparing` -> `ready` -> `delivered` (ou `cancelled`) +- Un **menu** = burger fixe + slots a choix (boisson, accompagnement, sauce). Modele relationnel `menu_slot` + `menu_slot_option` (voir `dictionary.md` 3.4-3.5) +- Format **Normal / Maxi** au niveau du menu (deux prix : `price_normal_cents`, `price_maxi_cents`) ; le Maxi agrandit accompagnement + boisson uniquement +- **Personnalisation des ingredients** (retirer = gratuit, ajouter = supplement) sur les sandwichs composes, via le configurateur (`ingredient`, `product_ingredient`, `order_item_modifier`) +- **TVA portee par le produit** (`vat_rate` : 10% defaut, 5,5% contenant conservable), calculee ligne par ligne et snapshotee sur `order_item` (fact-check BOFiP, voir `dictionary.md` note 9) +- Une **commande** a un **numero** saisi par le client, prefixe par canal `K`/`C`/`D` (remplace le paiement dans le cadre de l'exam) +- Statuts commande (machine a **4 etats**) : `pending_payment` -> `paid` -> `delivered` (+ `cancelled`). La transition `pending_payment -> paid` est **atomique** a la creation (saisie du numero = substitut de paiement). `cancelled` est atteignable depuis `pending_payment` et `paid` (pas depuis `delivered`). Plus de `preparing` / `ready` : la cuisine est en lecture seule, la remise est un geste unique - **Source commande** (trace sur chaque commande) : `kiosk` (borne autonome) | `counter` (comptoir) | `drive` (drive-thru) -- La preparation voit les commandes triees par **heure de livraison croissante** (tous canaux confondus) +- Le canal de prepa (`kitchen`/`counter`/`drive`) voit la file des commandes `paid` triee par `paid_at` **croissant**, filtree par `role_visible_source` (kitchen voit tout ; counter voit kiosk+counter ; drive voit drive) - **Horaires service** : 10h00 → 01h00 du matin (service continu 15h, pas de fermeture intermediaire) - **Pas de notion de "session de service" a modeliser** : les equipiers se relaient, chacun se connecte a sa prise de poste et se deconnecte a la fin. Pas de "shift" a tracer dans la BDD (hors scope RNCP) - **Fenetre de maintenance systeme** : 01h30 → 09h30 (crons lourds, backups, agregations) — evite toute interference avec le service actif @@ -187,8 +194,8 @@ Reseaux : | TLS | Let's Encrypt via Traefik | auto | `acme.json` existant | | Conteneurisation | Docker + docker compose | v2 | Cr 7.c | | Orchestration locale | Makefile | — | Cr 7.b (script) + Cr 7.c.4 (une commande) | -| CI/CD | GitHub Actions | — | Cr 7.d | -| Versioning | Git + GitHub | — | Cr 4.f (collaboration) | +| CI/CD | Forgejo Actions (act_runner auto-heberge) | — | Cr 7.d | +| Versioning | Git + Forgejo auto-heberge (push-mirror GitHub) | — | Cr 4.f (collaboration) | | Hooks Git | pre-commit + commit-msg | versionnes dans `.githooks/` | Conventional Commits | --- @@ -220,10 +227,11 @@ Reseaux : **IN scope — Back-office :** - Authentification sessions securisees (hash bcrypt/argon2, protection CSRF, fixation session) — duree de session adaptee a un poste complet d'equipier (idle timeout 4h, absolute timeout 10h) -- 3 roles RBAC : `admin`, `preparation`, `accueil` -- **Admin** : CRUD categories, produits (nom, description, prix, image, dispo), menus (composition + options), utilisateurs -- **Preparation** : liste commandes a preparer triees par heure livraison croissante, bouton "declarer preparee" -- **Accueil** : saisir commande manuellement (comptoir ou drive-thru via casque/intercom), bouton "declarer livree" ; champ `source` enregistre sur chaque commande (`counter` ou `drive`) +- 5 roles RBAC seed : `admin`, `manager`, `kitchen`, `counter`, `drive` (RBAC permission-driven, 23 permissions figees au seed ; roles personnalises possibles) +- **Admin** : CRUD complet catalogue (+ suppressions), gestion utilisateurs, roles et permissions (RBAC), stats +- **Manager** : catalogue (create/update), stock (reappro + inventaire), statistiques ; pas d'acces utilisateurs ni RBAC +- **Kitchen** : file des commandes `paid` triee par `paid_at` croissant, en **lecture seule** (KDS visuel) ; inventaire +- **Counter** / **Drive** : saisir une commande (comptoir / drive-thru via casque/intercom), bouton "declarer livree" (geste unique `paid -> delivered`), annuler ; `source` auto-tague depuis `role.order_source` ; inventaire - Upload images produits (validation type MIME + taille + stockage dans volume `wakdo_uploads`) - Historique commandes par statut - Stats de base (commandes du jour, CA jour, produits top) @@ -261,9 +269,10 @@ Reseaux : - `0 3 * * *` — backup BDD quotidien a 03h00 (entre fin service 01h et ouverture 10h) - `*/15 * * * *` — purge sessions expirees toutes les 15 min (leger, peut tourner en service) - `30 4 * * *` — agregation stats commandes a 04h30 sur le **jour de service** ecoule (10h J-1 → 01h J) -- **CI GitHub Actions** : lint PHP + PHPUnit sur PR -> dev -- **CD GitHub Actions** : deploy auto sur merge main (SSH + pull + `make rebuild`) -- `.env.example` documente, secrets hors du repo +- **CI Forgejo Actions** (act_runner auto-heberge) : lint PHP + PHPStan + PHPUnit + secret-scan (gitleaks) sur PR -> dev +- **CD Forgejo Actions** : deploy auto sur merge main (SSH + pull + `make rebuild`) +- `.env.example` documente (parametres securite : argon2id, lockout, seuils throttle, retention RGPD), secrets hors du repo +- `php.ini` durci (expose_php off, session cookies httponly/secure/samesite, upload limite) - Healthcheck Traefik + readiness probes - Logs centralises (stdout des conteneurs) - Documentation deploiement + architecture (schemas dans `docs/`) @@ -327,8 +336,8 @@ Reseaux : | Cr 7.c.3 | App conteneurisee complete | 4 services (web, app, db, cron) | | Cr 7.c.4 | **Une ligne de commande** | `make init` lance toute la stack + migrate + seed | | Cr 7.d.1 | Architecture serveur | Traefik reverse + reseaux segmentes documentes | -| Cr 7.d.2 | Tests avant deploy | CI PHPUnit + lint sur PR | -| Cr 7.d.3 | Integration/deploiement continus | GitHub Actions deploy automatique sur merge main | +| Cr 7.d.2 | Tests avant deploy | CI PHPUnit + PHPStan + secret-scan sur PR (Forgejo Actions) | +| Cr 7.d.3 | Integration/deploiement continus | Forgejo Actions deploy automatique sur merge main | --- @@ -344,13 +353,13 @@ main ← production (tag vX.Y.Z sur chaque release) fix/* ← corrections refactor/* ← refactos docs/* ← doc seulement - ci/* ← GitHub Actions + ci/* ← Forgejo Actions db/* ← migrations / schema BDD chore/* ← tooling, config test/* ← ajout de tests ``` -Les branches `main` et `dev` sont **protegees** cote GitHub. Pas de commit direct autorise. Hook pre-commit local les bloque egalement. +Les branches `main` et `dev` sont **protegees** cote Forgejo (push direct interdit, force-push bloque, PR obligatoire via l'API `branch_protections`). Hook pre-commit local les bloque egalement. **Flow :** 1. `git checkout -b feat/menu-composition` (depuis `dev`) @@ -432,9 +441,10 @@ Les branches `main` et `dev` sont **protegees** cote GitHub. Pas de commit direc | 10 | Service cron dedie | Cr 7.b.3 explicite + realiste prod | | 11 | Makefile avec `make init` | Cr 7.c.4 + demonstration DevOps | | 12 | Conventional Commits + hooks | Cr 4.f.x + discipline de versioning | -| 13 | Branches feat/* -> dev -> main | Pipeline propre pour jury, GitHub PR trace | -| 14 | CI/CD GitHub Actions | Cr 7.d explicite dans referentiel | +| 13 | Branches feat/* -> dev -> main | Pipeline propre pour jury, PR tracee (Forgejo, mirror GitHub) | +| 14 | CI/CD Forgejo Actions (act_runner auto-heberge) | Cr 7.d explicite ; forge + CI maitrisees de bout en bout (argument Bloc 5) | | 15 | RGPD implemente minimal | Cr 3.d.1-4 evaluees meme projet ecole | +| 16 | Security-by-design (threat model STRIDE + classification donnees) | Audit Cr 7.a ; stock en %, throttle brute-force, retention RGPD documentes en amont du code | --- @@ -442,18 +452,18 @@ Les branches `main` et `dev` sont **protegees** cote GitHub. Pas de commit direc | Phase | Scope | Budget (h) | Deadline intermediaire | |---|---|---|---| -| **P0 - Setup** | PC, arborescence, Docker, hooks, CI squelette, init Git/GitHub | 20 | Semaine 1 | -| **P1 - Conception Merise** | Dictionnaire, MCD, MCT, MLD, schemas fonctionnels, DDL | 30 | Semaine 3 | +| **P0 - Setup** | PC, arborescence, Docker, hooks, CI squelette, migration Forgejo + act_runner | 22 | Semaine 1 | +| **P1 - Conception Merise + Security-by-design** | Dictionnaire, MCD, MCT, MLD, schemas fonctionnels, DDL, threat model STRIDE + classification donnees + sequence securite | 38 | Semaine 3 | | **P2 - Back squelette** | POO base (Core, Router, Autoloader, DB), auth + roles | 30 | Semaine 6 | | **P3 - Back CRUD admin** | Produits, menus, utilisateurs, views | 40 | Semaine 10 | | **P4 - API REST** | Endpoints + CORS + tests | 20 | Semaine 12 | | **P5 - Front borne** | Integration maquette, Ajax, accessibilite, responsive | 60 | Semaine 16 | | **P6 - Tests + finition** | PHPUnit, tests E2E borne, corrections | 25 | Semaine 18 | -| **P7 - DevOps finalisation** | CI/CD deploy auto, crons, docs argumentation | 20 | Semaine 19 | +| **P7 - DevOps finalisation** | Forgejo Actions CI/CD (PHPUnit + PHPStan + secret-scan + deploy auto), crons, SECURITY.md, docs argumentation | 22 | Semaine 19 | | **P8 - Prep soutenance** | README pour jury, schemas finaux, repetitions, modifs en direct | 15 | Semaine 20 | -| **TOTAL** | | **260** | **Semaine 20 = fin aout 2026** | +| **TOTAL** | | **272** | **Semaine 20 = fin aout 2026** | -Buffer : ~20 h pour imprevus. Cible effective : ~240 h sur 20 semaines = **12 h/semaine**. +Buffer : ~8 h pour imprevus. Cible effective : ~264 h sur 20 semaines = **~13 h/semaine**. --- @@ -481,7 +491,7 @@ Buffer : ~20 h pour imprevus. Cible effective : ~240 h sur 20 semaines = **12 h/ - `docker-compose.yml` commente - Dockerfiles customs commentes - `Makefile` avec `make help` -- `.github/workflows/` avec CI + CD +- `.forgejo/workflows/` avec CI (PHPUnit + PHPStan + secret-scan) + CD - Crontab documente - Script de backup/restore teste - Architecture serveur decrite (`docs/architecture/deployment.md`) @@ -675,4 +685,138 @@ Ces regles tiennent lieu de garde-fous pendant toute la duree du projet. Les enf --- -*Document vivant — version 1.1 — 2026-04-24 (ajout section 17 transparence IA). A mettre a jour a chaque decision structurante.* +## 19. Security threat model and data classification + +Cette section formalise la couche **security-by-design** ajoutee au modele Merise v0.2 +(voir `docs/merise/dictionary.md` note 13, `docs/merise/mlt.md` section 2 pour les regles +transverses RG-T13 a RG-T21). Elle se lit a deux niveaux : un **registre des risques** de +synthese pour une lecture gestion, suivi d'une **analyse STRIDE par element** pour la +profondeur technique, puis une **matrice de classification des donnees** en 4 niveaux. Tous +les claims securite sont rattaches a un mecanisme concret (une regle RG-T, une colonne, une +entite) plutot qu'enonces comme des absolus. + +### 19.1 Perimetre et frontieres de confiance + +Le systeme expose cinq frontieres de confiance (trust boundaries), correspondant aux points +d'entree analyses ci-dessous : + +- **E1 — Borne kiosk (public anonyme)** : `POST /api/orders`, consultation du catalogue. + Aucune authentification ; la borne est anonyme par conception (les commandes kiosk ont + `customer_order.acting_user_id = NULL`). Surface la plus exposee, donc traitee sans + hypothese de confiance sur l'entree. +- **E2 — Back-office admin (staff authentifie, poste partage + PIN par equipier)** : CRUD + catalogue/menus/ingredients, RBAC, gestion utilisateurs, stock, annulation de commande, + stats. Session partagee par poste pour le flux courant ; un PIN par equipier + (`user.pin_hash`) re-autorise l'ensemble sensible (RG-T13). +- **E3 — Surface d'authentification** : login (`AUTHENTICATE_USER`, op 25, `mlt.md` 12.1) et + reinitialisation de mot de passe (`RESET_PASSWORD`, op 28, `mlt.md` 12.3). +- **E4 — Couche donnees / BDD** : acces PDO, requetes preparees (RG-T06), allowlists + (RG-T16/RG-T17), integrite transactionnelle (RG-T08/RG-T11), snapshots immuables (RG-T05). +- **E5 — Stock / inventaire** : decrement de vente, reappro, comptage d'inventaire, avec un + journal append-only `stock_movement` et attribution de l'acteur ; la correction d'inventaire + est PIN-gated (RG-T13, `mlt.md` 9.2) car elle peut masquer de la demarque (shrinkage). + +Hors perimetre de cette section : la securite reseau/infra (Traefik, segmentation Docker, +TLS), couverte en section 5 ; le durcissement CORS, couvert en section 5. + +### 19.2 Registre des risques (risk register) + +Synthese gestion. Likelihood et residual risk sont evalues a dire d'expert pour ce projet +fictif (`[REASONING]`, non quantifies par benchmark) ; chaque mitigation cite une regle RG-T +et/ou une entite reelle du modele. + +| # | Actif | Menace | Impact | Likelihood | Mitigation (regle / entite) | Risque residuel | +|---|---|---|---|---|---|---| +| R1 | Recette (cash) sur commande payee | Un equipier annule une commande `paid` pour detourner l'encaissement (fraude interne) | Fort | Moyenne | `CANCEL_ORDER` (`mlt.md` 7.1) PIN-gated (RG-T13) + ecriture `audit_log` dans la meme transaction (RG-T14, RG-T11) ; acteur capture via `audit_log.actor_user_id` | Faible — l'annulation reste possible mais devient nominative et tracee ; dissuasion plus que blocage | +| R2 | `product.price_cents` / `vat_rate` / `role_id` | Falsification via un champ de formulaire injecte (mass-assignment) | Fort | Moyenne | Allowlist de colonnes par operation (RG-T16) sur `UPDATE_PRODUCT` (`mlt.md` 8.2) et `UPDATE_USER` (10.2) ; seules les colonnes autorisees sont bindees | Faible — les champs hors allowlist sont ignores ; un changement de prix reste audite (RG-T14) | +| R3 | Comptes back-office (`user.password_hash`) | Brute-force sur le login staff | Moyen | Haute | Backoff degressif par compte (`user.failed_login_attempts` / `lockout_until`) + par IP (`login_throttle`, entite 21) ; gate avant verification (`mlt.md` 12.1 PRE-3, RG-8) | Faible — ralentissement sans lock indefini ; un service de 15h n'est pas bloque par une saisie maladroite | +| R4 | Vues kiosk et admin (texte stocke) | XSS stocke via `product.name` / `ingredient.name` / `user.first_name` | Moyen | Moyenne | Echappement au rendu (RG-T15) : `htmlspecialchars(..., ENT_QUOTES)` cote admin, injection via `textContent` (pas `innerHTML`) cote kiosk vanilla-JS | Faible — l'echappement reduit le risque d'execution de script injecte | +| R5 | `ingredient.stock_quantity` | Survente (oversell) sous concurrence multi-borne | Moyen | Moyenne | Decrement atomique auto-verrouillant (RG-T20) sans read-gate + disponibilite calculee (RG-T21) ; `stock_quantity` signe, la magnitude de survente est remontee aux managers | Moyen accepte — le systeme ne bloque pas une commande sur le stock ; la survente est mesuree, pas empechee (decision metier) | +| R6 | Commande payee | Double-charge sur retry reseau de `POST /api/orders` | Moyen | Moyenne | Idempotence (RG-T19) : `customer_order.idempotency_key` UNIQUE ; un retry renvoie la commande existante au lieu d'en creer une seconde | Faible — la cle UNIQUE deduplique les rejeux ; depend d'une cle client correctement generee | +| R7 | PII utilisateur (`user.email`/`first_name`/`last_name`) | Demande d'effacement RGPD non honoree, ou rupture de l'integrite referentielle a la suppression | Fort (conformite) | Faible | Anonymisation (`ERASE_USER_PII`, `mlt.md` 10.5) : la ligne est conservee, PII remplacees par un placeholder `anon-@wakdo.invalid`, credentials invalides, `anonymized_at` pose ; `audit_log` retient sa propre fenetre | Faible — effacement et tracabilite coexistent ; les FK (`audit_log.actor_user_id`, `customer_order.acting_user_id`, `stock_movement.user_id`) restent valides | +| R8 | Matrice RBAC (`role_permission`) | Elevation de privilege via modification de role non controlee | Fort | Faible | `MANAGE_RBAC` (`mlt.md` 10.4) PIN-gated (RG-T13) + `audit_log` du diff de permissions (RG-T14, RG-6) ; `role_id` derriere l'allowlist (RG-T16) | Faible — tout gain/perte de capacite est nominatif et trace | +| R9 | `stock_movement` (demarque) | Correction d'inventaire masquant une demarque | Moyen | Moyenne | `INVENTORY_COUNT` (`mlt.md` 9.2) PIN-gated (RG-T13) ; le `user_id` capture par PIN est ecrit dans `stock_movement.user_id` (append-only) | Faible — la correction devient attribuable a une personne meme sur poste partage | + +### 19.3 Analyse STRIDE par element + +Un bloc par categorie STRIDE, mappe aux controles reels du modele (verifies contre +`mlt.md` section 2). + +**Spoofing (usurpation d'identite).** L'authentification back-office repose sur argon2id +(`user.password_hash`, `mlt.md` 12.1 RG-2) avec regeneration de session a la connexion +(`session_regenerate(true)`, RG-3) pour contrer la fixation. Le login est enumeration-safe : +meme erreur generique que l'email existe ou non, avec un `password_verify` leurre pour garder +le timing comparable (RG-2). Sur un poste partage, un PIN par equipier (`user.pin_hash`, +RG-T13) re-authentifie l'acteur reel pour les actions sensibles. La reinitialisation de mot de +passe (`RESET_PASSWORD`, `mlt.md` 12.3) stocke le token hashe (`password_reset_token_hash`), +n'envoie le token brut qu'une seule fois, l'expire a 1h et le rend a usage unique (RG-2/RG-3) ; +la phase requete renvoie une reponse neutre identique que le compte existe ou non (RG-1, +enumeration-safe). La borne kiosk est anonyme +par conception, donc hors perimetre d'usurpation (pas de compte a usurper). + +**Tampering (alteration).** L'allowlist de mass-assignment (RG-T16) limite les colonnes +bindees aux champs autorises par operation, protegeant `price_cents`, `vat_rate`, `role_id`, +`is_active`, `status`. La validation cote serveur (RG-T18) re-verifie type, plage, longueur, +appartenance ENUM et existence des FK independamment du client. Les requetes preparees PDO +(RG-T06) traitent les valeurs hors de la chaine SQL, ce qui ferme l'injection SQL par valeur. +Les identifiants SQL dynamiques (colonne et direction d'un `ORDER BY`/`GROUP BY`) sont resolus +contre une allowlist fixe avant construction de la requete (RG-T17), car un identifiant ne +peut pas etre bind comme une valeur. +Les snapshots de commande (`order_item.label_snapshot`, `unit_price_cents_snapshot`, +`vat_rate_snapshot`) sont immuables apres INSERT (RG-T05), preservant l'integrite historique +des commandes placees. La re-validation serveur des modifiers (`mlt.md` 3.3 RG-9) rejette un +`POST` forge ajoutant un ingredient non-`is_addable`. + +**Repudiation (deni d'action).** Le journal `audit_log` (entite 20, RG-T14) enregistre les +actions sensibles non-stock avec `actor_user_id` (capture par PIN, RG-T13), `actor_role_id` +(denormalise pour survivre a l'anonymisation), `action_code`, `entity_type`/`entity_id` et un +`summary` non-personnel ; pas d'UPDATE/DELETE applicatif. L'attribution des commandes +comptoir/drive passe par `customer_order.acting_user_id` (`mlt.md` 4.1 RG-5) et celle du stock +par `stock_movement.user_id` (`mlt.md` 9.1/9.2). Les actions stock ne sont pas doublement +journalisees : `stock_movement` (append-only) fournit deja la piste. + +**Information disclosure (divulgation).** La matrice de classification (19.4) borne ce qui +sort des logs et des reponses API. Les erreurs d'auth sont generiques (RG-2, pas de +distinction email inconnu / mot de passe faux). L'`audit_log` stocke des **noms de champs** +modifies, pas les valeurs PII (`audit_log.details`, RG-T14). L'attribution de stock +(`stock_movement.user_id`) n'est visible que pour manager/admin ; le staff de ligne voit les +deltas sans l'identite de l'acteur (`mlt.md` 9.3 RG-4). Les credentials (`password_hash`, +`pin_hash`, `password_reset_token_hash`) sont tenus hors logs et hors reponses API. + +**Denial of service.** Le throttling de login est degressif (backoff exponentiel plafonne) +plutot qu'un lock indefini, dans les deux dimensions compte (`user.lockout_until`) et IP +(`login_throttle.lockout_until`, `mlt.md` 12.1 RG-8) : une saisie maladroite ne bloque pas une +cuisine en plein service de 15h continu. L'idempotence (RG-T19) absorbe les doubles-soumissions +de retry reseau sur le kiosk anonyme. Le decrement de stock atomique (RG-T20) evite tout +contentieux de verrou (pas de `SELECT ... FOR UPDATE`, pas d'ordre de deadlock). + +**Elevation of privilege.** Le RBAC est permission-driven : le code teste une permission, pas +un nom de role (catalogue de 23 permissions fige au seed, `dictionary.md` 3.17). Les +changements de role passent par `MANAGE_RBAC` (`mlt.md` 10.4) PIN-gated (RG-T13) et audites +avec le diff de permissions (RG-T14, RG-6). `role_id` est derriere l'allowlist de +mass-assignment (RG-T16) sur `UPDATE_USER` (10.2). Les permissions sont rechargees depuis la +BDD a chaque verification (`mlt.md` 10.4 RG-3), donc un changement de droits prend effet sans +re-login force. + +### 19.4 Matrice de classification des donnees (4 niveaux) + +Les 21 entites du modele (`dictionary.md` 3.1-3.21) sont reparties en quatre niveaux. La +classification suit l'entite ; quelques colonnes sont surclassees explicitement (credentials, +PII). + +| Niveau | Definition | Entites / colonnes | Regle de manipulation | +|---|---|---|---| +| **RESTRICTED** (secrets / credentials) | Secrets d'authentification ; tenus hors de toute exposition | Colonnes de `user` (14) : `password_hash`, `pin_hash`, `password_reset_token_hash` | Hors logs et hors reponses API ; argon2id ; invalides a l'anonymisation (`mlt.md` 10.5 RG-1) ; exclus de `audit_log.details` qui ne retient que des noms de champs (RG-T14) | +| **CONFIDENTIAL** (PII, RGPD) | Donnees a caractere personnel d'un staff identifiable | Colonnes de `user` (14) : `email`, `first_name`, `last_name` | Sujet a l'anonymisation a l'effacement (`ERASE_USER_PII`, op 27) ; `audit_log` stocke les noms de champs, pas les valeurs ; echappement au rendu (RG-T15) | +| **INTERNAL** (sensible metier) | Donnees d'exploitation, non publiques, a acces restreint par RBAC | `customer_order` (10), `order_item` (11), `order_item_selection` (12), `order_item_modifier` (13), `stock_movement` (19), `audit_log` (20), `login_throttle` (21, contient l'IP source), `role` (15), `permission` (17), `role_permission` (18), `role_visible_source` (16) ; sorties de stats (`READ_STATS`, op 24) | Acces filtre par permission (RG-T03) ; attribution stock visible manager/admin seulement (`mlt.md` 9.3 RG-4) ; integrite par snapshots (RG-T05) et transactions (RG-T08/RG-T11) | +| **PUBLIC** (catalogue, face kiosk) | Donnees servies a la borne anonyme | `category` (1), `product` (2), `menu` (3), `menu_slot` (4), `menu_slot_option` (5), `ingredient` (6, nom + dispo calculee), `product_ingredient` (7), `allergen` (8), `ingredient_allergen` (9) | Lecture publique via `LOAD_CATALOGUE` (op 1) ; ecriture reservee admin/manager (RG-T03) ; texte echappe au rendu (RG-T15) ; disponibilite calculee (RG-T21) | + +**Couverture** : 21/21 entites classifiees (9 PUBLIC, 11 INTERNAL incluant les deux entites +security-by-design `audit_log` et `login_throttle`, plus `user` dont les colonnes sont +reparties entre RESTRICTED, CONFIDENTIAL et — pour `is_active`, `role_id`, `last_login_at`, +les compteurs de throttle — INTERNAL). L'entite `user` (14) est la seule a porter trois +niveaux simultanement, d'ou son traitement par colonne. + +--- + +*Document vivant — version 1.3 — 2026-06-15 (drift GitHub -> Forgejo Actions corrige, CI securite PHPStan/secret-scan, planning rechiffre pour la couche security-by-design). A mettre a jour a chaque decision structurante.* diff --git a/docs/architecture/forgejo-actions-runner.md b/docs/architecture/forgejo-actions-runner.md new file mode 100644 index 0000000..96a5e6c --- /dev/null +++ b/docs/architecture/forgejo-actions-runner.md @@ -0,0 +1,102 @@ +# Forgejo Actions - runner (act_runner) + +Prerequis d'infrastructure pour la CI/CD Wakdo. Les workflows vivent dans +`.forgejo/workflows/` (lot D) ; ils ne s'executent que si un `act_runner` est +enregistre et en ligne sur le serveur. + +## Pourquoi un runner separe de la stack app + +La stack `docker-compose.yml` de Wakdo = runtime applicatif (web, app, db, cron). +Le runner CI est du **tooling** : il se rattache au depot Forgejo, pas a l'app. +On le fait tourner comme service dedie sur l'hote stark (meme lecon que +"gh dans Docker = mauvaise idee", cf. journal session 6). Cela evite que la CI +puisse impacter le runtime, et garde un cycle de vie independant. + +## 1. Obtenir le token de registration (action manuelle, niveau admin) + +Le token vient de l'instance Forgejo, pas du repo. Dans l'UI Forgejo : + +- niveau **repo** : `Settings > Actions > Runners > Create new runner` +- ou niveau **org/instance** : `Site Administration > Actions > Runners` + +Recuperer le `REGISTRATION_TOKEN` affiche. Il est a usage unique pour +l'enregistrement (pas a versionner). + +## 2. Enregistrer le runner (sur stark) + +Setup reel en place (image `simplyforma/forgejo-runner` deja presente sur +l'hote, data dir sous `$HOME` car `/srv` non inscriptible par `corentin`). +Le conteneur tourne sous l'uid de l'hote (`--user`) pour pouvoir ecrire +`.runner` dans le volume monte. + +```bash +DATA=/home/corentin/forgejo-runner-wakdo +mkdir -p "$DATA" + +docker run --rm \ + --user "$(id -u):$(id -g)" \ + -v "$DATA":/data --workdir /data \ + --entrypoint forgejo-runner \ + simplyforma/forgejo-runner:12.10.2 \ + register --no-interactive \ + --instance https://git.acadenice.com \ + --token "" \ + --name stark-wakdo \ + --labels 'docker:docker://node:20-bookworm,php-ci:docker://php:8.3-cli' +``` + +L'enregistrement ecrit `$DATA/.runner` (contient le secret du runner - ne pas +versionner, ne pas sortir de l'hote). Runner enregistre le 2026-06-15 +(uuid `e4a3dbef-...`, labels `docker` + `php-ci`). + +## 3. Lancer le runner en service + +```bash +DATA=/home/corentin/forgejo-runner-wakdo +DOCKER_GID=$(stat -c '%g' /var/run/docker.sock) + +docker run -d --restart=always \ + --name forgejo-runner-wakdo \ + --user "$(id -u):$(id -g)" \ + --group-add "$DOCKER_GID" \ + -e HOME=/data \ + -v "$DATA":/data --workdir /data \ + -v /var/run/docker.sock:/var/run/docker.sock \ + --entrypoint forgejo-runner \ + simplyforma/forgejo-runner:12.10.2 \ + daemon +``` + +Notes : +- `--group-add $DOCKER_GID` : acces au socket Docker pour executer les jobs + dans des conteneurs (sans tourner en root). +- `-e HOME=/data` : evite l'erreur `mkdir /.cache: permission denied` (le cache + server interne ecrit sous `$HOME`). +- Verifier `docker logs forgejo-runner-wakdo` : `declared successfully` + + `[poller] launched`, et `Settings > Actions > Runners` doit montrer `stark-wakdo` **Idle**. +- Prerequis cote depot : **Actions activees** (`Settings > Actions` du depot). + +## 4. Labels et usage en workflow + +Les jobs ciblent un label via `runs-on`. Pour la CI PHP de Wakdo : + +```yaml +jobs: + ci: + runs-on: docker # image par defaut node:20-bookworm + # les etapes installent/php via le conteneur ou une action setup-php +``` + +## Securite du runner + +- Le `.runner` (secret) reste sur l'hote, hors du repo. +- Le socket Docker monte donne un acces privilegie : le runner ne doit executer + que des workflows du depot Wakdo (runner dedie au repo, pas partage). +- Roter le secret = re-enregistrer avec un nouveau token et supprimer l'ancien + runner dans l'UI. + +## Lien avec les autres lots + +- **Lot C** : ce document + prerequis infra. +- **Lot D** : `.forgejo/workflows/ci.yml` (PHPUnit + PHPStan + secret-scan gitleaks) + et auto-merge des PR sur CI verte (strategie solo dev validee). diff --git a/docs/merise/_diagrams/mcd-catalogue.drawio b/docs/merise/_diagrams/mcd-catalogue.drawio deleted file mode 100644 index c9cf3e8..0000000 --- a/docs/merise/_diagrams/mcd-catalogue.drawio +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/merise/_diagrams/mcd-catalogue.mmd b/docs/merise/_diagrams/mcd-catalogue.mmd new file mode 100644 index 0000000..698a179 --- /dev/null +++ b/docs/merise/_diagrams/mcd-catalogue.mmd @@ -0,0 +1,51 @@ +erDiagram + category { + int id PK + varchar name + varchar slug + varchar image_path + smallint display_order + tinyint is_active + } + product { + int id PK + int category_id FK + varchar name + text description + int price_cents + smallint vat_rate + varchar image_path + tinyint is_available + smallint display_order + } + menu { + int id PK + int category_id FK + int burger_product_id FK + varchar name + text description + int price_normal_cents + int price_maxi_cents + varchar image_path + tinyint is_available + smallint display_order + } + menu_slot { + int id PK + int menu_id FK + varchar name + enum slot_type + tinyint is_required + smallint display_order + } + menu_slot_option { + int menu_slot_id FK + int product_id FK + } + + category ||--o{ product : "groups" + category ||--o{ menu : "groups" + menu ||--|| product : "anchors (burger_product_id)" + menu ||--o{ menu_slot : "defines_slot" + menu_slot ||--o{ menu_slot_option : "lists" + product ||--o{ menu_slot_option : "is_eligible_for" diff --git a/docs/merise/_diagrams/mcd-catalogue.svg b/docs/merise/_diagrams/mcd-catalogue.svg index c3a2016..38c2764 100644 --- a/docs/merise/_diagrams/mcd-catalogue.svg +++ b/docs/merise/_diagrams/mcd-catalogue.svg @@ -1,4 +1 @@ - - - -
CATEGORIE
id : INT (PK)
libelle : VARCHAR (UNIQUE)
slug : VARCHAR (UNIQUE)
image_path : VARCHAR
ordre : SMALLINT
est_actif : BOOLEAN
CATEGORIEid : INT (PK)...
PRODUIT
id : INT (PK)
categorie_id : INT (FK)
libelle : VARCHAR
description : TEXT
prix_ttc_cents : INT
image_path : VARCHAR
est_disponible : BOOLEAN
ordre : SMALLINT
PRODUITid : INT (PK)...
MENU
id : INT (PK)
categorie_id : INT (FK)
libelle : VARCHAR
description : TEXT
prix_ttc_cents : INT
image_path : VARCHAR
est_disponible : BOOLEAN
ordre : SMALLINT
MENUid : INT (PK)...
MENU_PRODUIT (associative)
menu_id : INT (PK, FK)
produit_id : INT (PK, FK)
role : ENUM
position : SMALLINT
MENU_PRODUIT (associative)menu_id : INT (PK,...
regroupe
regroupe
(0,N)
(0,N)
(1,1)
(1,1)
regroupe
regroupe
(0,N)
(0,N)
(1,1)
(1,1)
fait_partie_de
fait_partie_de
(0,N)
(0,N)
(1,1)
(1,1)
compose
compose
(1,N)
(1,N)
(1,1)
(1,1)
Text is not SVG - cannot display
\ No newline at end of file +

groups

groups

anchors (burger_product_id)

defines_slot

lists

is_eligible_for

category

int

id

PK

varchar

name

varchar

slug

varchar

image_path

smallint

display_order

tinyint

is_active

product

int

id

PK

int

category_id

FK

varchar

name

text

description

int

price_cents

smallint

vat_rate

varchar

image_path

tinyint

is_available

smallint

display_order

menu

int

id

PK

int

category_id

FK

int

burger_product_id

FK

varchar

name

text

description

int

price_normal_cents

int

price_maxi_cents

varchar

image_path

tinyint

is_available

smallint

display_order

menu_slot

int

id

PK

int

menu_id

FK

varchar

name

enum

slot_type

tinyint

is_required

smallint

display_order

menu_slot_option

int

menu_slot_id

FK

int

product_id

FK

\ No newline at end of file diff --git a/docs/merise/_diagrams/mcd-commande.drawio b/docs/merise/_diagrams/mcd-commande.drawio deleted file mode 100644 index a6ef277..0000000 --- a/docs/merise/_diagrams/mcd-commande.drawio +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/merise/_diagrams/mcd-commande.svg b/docs/merise/_diagrams/mcd-commande.svg deleted file mode 100644 index 2d4be3b..0000000 --- a/docs/merise/_diagrams/mcd-commande.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
COMMANDE
id : INT (PK)
numero : VARCHAR (UNIQUE)
source : ENUM (kiosk|comptoir|drive)
mode_consommation : ENUM (sur_place|a_emporter|drive)
statut : ENUM
total_ht_cents : INT
total_tva_cents : INT
total_ttc_cents : INT
tva_taux_pourmille : SMALLINT
paye_a : DATETIME
USER
id : INT (PK)
(detail dans RBAC)
COMMANDE_EVENT
id : INT (PK)
commande_id : INT (FK)
event_type : ENUM
from_statut : ENUM (NULL)
to_statut : ENUM
user_id : INT (FK, NULL)
payload : JSON (NULL)
created_at : DATETIME
LIGNE_COMMANDE
id : INT (PK)
commande_id : INT (FK)
type_item : ENUM (produit|menu)
produit_id : INT (FK, NULL)
menu_id : INT (FK, NULL)
libelle_snapshot : VARCHAR
prix_unitaire_ttc_cents_snapshot : INT
quantite : SMALLINT
PRODUIT
id : INT (PK)
(detail dans Catalogue)
MENU
id : INT (PK)
(detail dans Catalogue)
contient
(1,N)
(1,1)
refere_si_type_produit
(0,1)
(0,N)
refere_si_type_menu
(0,N)
Polymorphisme
Exactement UNE des deux references est non-nulle.
Discriminateur : type_item ∈ {produit, menu}.
Contrainte CHECK SQL au MLD.
journalise
(1,N)
(1,1)
declenche
(0,N)
(0,1)
(0,1)
Journal d'audit (event sourcing)
Append-only : aucun UPDATE / DELETE applicatif.
user_id NULL si auto-validation kiosk.
ON DELETE CASCADE cote commande_id.
ON DELETE SET NULL cote user_id.
\ No newline at end of file diff --git a/docs/merise/_diagrams/mcd-global.drawio b/docs/merise/_diagrams/mcd-global.drawio deleted file mode 100644 index 962f01b..0000000 --- a/docs/merise/_diagrams/mcd-global.drawio +++ /dev/null @@ -1,182 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/merise/_diagrams/mcd-global.svg b/docs/merise/_diagrams/mcd-global.svg deleted file mode 100644 index 1d26537..0000000 --- a/docs/merise/_diagrams/mcd-global.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
CATEGORIE
PRODUIT
MENU_PRODUIT
MENU
LIGNE_COMMANDE
COMMANDE
COMMANDE_EVENT
USER
ROLE
ROLE_PERMISSION
PERMISSION
regroupe
(0,N)
(1,1)
regroupe
(0,N)
(1,1)
fait_partie_de
(0,N)
(1,1)
compose
(1,N)
(1,1)
contient
(1,N)
(1,1)
refere_si_type_produit
(0,1)
(0,N)
refere_si_type_menu
(0,1)
(0,N)
a_pour_role
(1,1)
(0,N)
possede
(0,N)
(1,1)
assignee_a
(0,N)
(1,1)
journalise
(1,N)
(1,1)
declenche
(0,N)
(0,1)
\ No newline at end of file diff --git a/docs/merise/_diagrams/mcd-ingredients-stock.mmd b/docs/merise/_diagrams/mcd-ingredients-stock.mmd new file mode 100644 index 0000000..dff0eee --- /dev/null +++ b/docs/merise/_diagrams/mcd-ingredients-stock.mmd @@ -0,0 +1,61 @@ +erDiagram + product { + int id PK + varchar name + } + ingredient { + int id PK + varchar name + varchar unit + int stock_quantity + int stock_capacity + smallint pack_size + varchar pack_label + smallint low_stock_pct + smallint critical_stock_pct + tinyint is_active + } + product_ingredient { + int product_id FK + int ingredient_id FK + smallint quantity_normal + smallint quantity_maxi + tinyint is_removable + tinyint is_addable + int extra_price_cents + } + allergen { + int id PK + varchar code + varchar name + text description + } + ingredient_allergen { + int ingredient_id FK + int allergen_id FK + } + customer_order { + int id PK + varchar order_number + } + user { + int id PK + varchar email + } + stock_movement { + int id PK + int ingredient_id FK + enum movement_type + int delta + int order_id FK + int user_id FK + varchar note + } + + product ||--o{ product_ingredient : "is_composed_of" + ingredient ||--o{ product_ingredient : "appears_in" + ingredient ||--o{ ingredient_allergen : "contains" + allergen ||--o{ ingredient_allergen : "is_present_in" + ingredient ||--o{ stock_movement : "decrements" + customer_order |o--o{ stock_movement : "triggers" + user |o--o{ stock_movement : "logs" diff --git a/docs/merise/_diagrams/mcd-ingredients-stock.svg b/docs/merise/_diagrams/mcd-ingredients-stock.svg new file mode 100644 index 0000000..615a975 --- /dev/null +++ b/docs/merise/_diagrams/mcd-ingredients-stock.svg @@ -0,0 +1 @@ +

is_composed_of

appears_in

contains

is_present_in

decrements

triggers

logs

product

int

id

PK

varchar

name

ingredient

int

id

PK

varchar

name

varchar

unit

int

stock_quantity

int

stock_capacity

smallint

pack_size

varchar

pack_label

smallint

low_stock_pct

smallint

critical_stock_pct

tinyint

is_active

product_ingredient

int

product_id

FK

int

ingredient_id

FK

smallint

quantity_normal

smallint

quantity_maxi

tinyint

is_removable

tinyint

is_addable

int

extra_price_cents

allergen

int

id

PK

varchar

code

varchar

name

text

description

ingredient_allergen

int

ingredient_id

FK

int

allergen_id

FK

customer_order

int

id

PK

varchar

order_number

user

int

id

PK

varchar

email

stock_movement

int

id

PK

int

ingredient_id

FK

enum

movement_type

int

delta

int

order_id

FK

int

user_id

FK

varchar

note

\ No newline at end of file diff --git a/docs/merise/_diagrams/mcd-order.mmd b/docs/merise/_diagrams/mcd-order.mmd new file mode 100644 index 0000000..be06a07 --- /dev/null +++ b/docs/merise/_diagrams/mcd-order.mmd @@ -0,0 +1,67 @@ +erDiagram + customer_order { + int id PK + varchar order_number + varchar idempotency_key + enum source + int acting_user_id FK + enum service_mode + enum status + int total_ht_cents + int total_vat_cents + int total_ttc_cents + datetime paid_at + datetime delivered_at + datetime cancelled_at + } + order_item { + int id PK + int order_id FK + enum item_type + int product_id FK + int menu_id FK + enum format + varchar label_snapshot + int unit_price_cents_snapshot + smallint vat_rate_snapshot + smallint quantity + } + order_item_selection { + int id PK + int order_item_id FK + int menu_slot_id FK + int product_id FK + varchar label_snapshot + } + order_item_modifier { + int id PK + int order_item_id FK + int ingredient_id FK + enum action + int extra_price_cents + } + product { + int id PK + varchar name + } + menu { + int id PK + varchar name + } + menu_slot { + int id PK + varchar name + } + ingredient { + int id PK + varchar name + } + + customer_order ||--o{ order_item : "contains" + order_item }o--o| product : "references_product" + order_item }o--o| menu : "references_menu" + order_item ||--o{ order_item_selection : "fills_slot" + order_item ||--o{ order_item_modifier : "modifies_ingredient" + menu_slot ||--o{ order_item_selection : "slot_filled_by" + product ||--o{ order_item_selection : "chosen_for_slot" + ingredient ||--o{ order_item_modifier : "modified_by" diff --git a/docs/merise/_diagrams/mcd-order.svg b/docs/merise/_diagrams/mcd-order.svg new file mode 100644 index 0000000..aeb5ac7 --- /dev/null +++ b/docs/merise/_diagrams/mcd-order.svg @@ -0,0 +1 @@ +

contains

references_product

references_menu

fills_slot

modifies_ingredient

slot_filled_by

chosen_for_slot

modified_by

customer_order

int

id

PK

varchar

order_number

varchar

idempotency_key

enum

source

int

acting_user_id

FK

enum

service_mode

enum

status

int

total_ht_cents

int

total_vat_cents

int

total_ttc_cents

datetime

paid_at

datetime

delivered_at

datetime

cancelled_at

order_item

int

id

PK

int

order_id

FK

enum

item_type

int

product_id

FK

int

menu_id

FK

enum

format

varchar

label_snapshot

int

unit_price_cents_snapshot

smallint

vat_rate_snapshot

smallint

quantity

order_item_selection

int

id

PK

int

order_item_id

FK

int

menu_slot_id

FK

int

product_id

FK

varchar

label_snapshot

order_item_modifier

int

id

PK

int

order_item_id

FK

int

ingredient_id

FK

enum

action

int

extra_price_cents

product

int

id

PK

varchar

name

menu

int

id

PK

varchar

name

menu_slot

int

id

PK

varchar

name

ingredient

int

id

PK

varchar

name

\ No newline at end of file diff --git a/docs/merise/_diagrams/mcd-rbac.drawio b/docs/merise/_diagrams/mcd-rbac.drawio deleted file mode 100644 index 31e109d..0000000 --- a/docs/merise/_diagrams/mcd-rbac.drawio +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/merise/_diagrams/mcd-rbac.mmd b/docs/merise/_diagrams/mcd-rbac.mmd new file mode 100644 index 0000000..f3bb49a --- /dev/null +++ b/docs/merise/_diagrams/mcd-rbac.mmd @@ -0,0 +1,64 @@ +erDiagram + user { + int id PK + varchar email + varchar password_hash + varchar pin_hash + varchar first_name + varchar last_name + int role_id FK + tinyint is_active + datetime last_login_at + smallint failed_login_attempts + datetime lockout_until + datetime anonymized_at + } + role { + int id PK + varchar code + varchar label + text description + varchar default_route + enum order_source + tinyint is_active + } + role_visible_source { + int role_id FK + enum source + } + permission { + int id PK + varchar code + varchar label + text description + } + role_permission { + int role_id FK + int permission_id FK + } + audit_log { + int id PK + int actor_user_id FK + int actor_role_id FK + varchar action_code + varchar entity_type + int entity_id + varchar summary + json details + datetime created_at + } + login_throttle { + int id PK + varchar ip_address UK + smallint failed_attempts + datetime window_started_at + datetime lockout_until + datetime last_attempt_at + } + + user }o--|| role : "holds" + role ||--o{ role_visible_source : "sees_source" + role ||--o{ role_permission : "grants" + permission ||--o{ role_permission : "granted_to" + user |o--o{ audit_log : "performs" + role |o--o{ audit_log : "context_of" diff --git a/docs/merise/_diagrams/mcd-rbac.svg b/docs/merise/_diagrams/mcd-rbac.svg index e92d624..688b27f 100644 --- a/docs/merise/_diagrams/mcd-rbac.svg +++ b/docs/merise/_diagrams/mcd-rbac.svg @@ -1,4 +1 @@ - - - -
USER
id : INT (PK)
email : VARCHAR (UNIQUE, RFC 5321)
password_hash : VARCHAR (argon2id)
nom : VARCHAR
prenom : VARCHAR
role_id : INT (FK)
est_actif : BOOLEAN
last_login_at : DATETIME
ROLE
id : INT (PK)
code : VARCHAR (UNIQUE)
libelle : VARCHAR
description : TEXT
est_actif : BOOLEAN
PERMISSION
id : INT (PK)
code : VARCHAR (UNIQUE, resource.action)
libelle : VARCHAR
description : TEXT
ROLE_PERMISSION (associative)
role_id : INT (PK, FK)
permission_id : INT (PK, FK)
a_pour_role
(1,1)
(0,N)
possede
(0,N)
(1,1)
assignee_a
(0,N)
(1,1)
\ No newline at end of file +

holds

sees_source

grants

granted_to

performs

context_of

user

int

id

PK

varchar

email

varchar

password_hash

varchar

pin_hash

varchar

first_name

varchar

last_name

int

role_id

FK

tinyint

is_active

datetime

last_login_at

smallint

failed_login_attempts

datetime

lockout_until

datetime

anonymized_at

role

int

id

PK

varchar

code

varchar

label

text

description

varchar

default_route

enum

order_source

tinyint

is_active

role_visible_source

int

role_id

FK

enum

source

permission

int

id

PK

varchar

code

varchar

label

text

description

role_permission

int

role_id

FK

int

permission_id

FK

audit_log

int

id

PK

int

actor_user_id

FK

int

actor_role_id

FK

varchar

action_code

varchar

entity_type

int

entity_id

varchar

summary

json

details

datetime

created_at

login_throttle

int

id

PK

varchar

ip_address

UK

smallint

failed_attempts

datetime

window_started_at

datetime

lockout_until

datetime

last_attempt_at

\ No newline at end of file diff --git a/docs/merise/_diagrams/mld-catalogue.drawio b/docs/merise/_diagrams/mld-catalogue.drawio deleted file mode 100644 index e292dbc..0000000 --- a/docs/merise/_diagrams/mld-catalogue.drawio +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/merise/_diagrams/mld-commande.drawio b/docs/merise/_diagrams/mld-commande.drawio deleted file mode 100644 index 1a47a5d..0000000 --- a/docs/merise/_diagrams/mld-commande.drawio +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/merise/_diagrams/mld-rbac.drawio b/docs/merise/_diagrams/mld-rbac.drawio deleted file mode 100644 index 0922801..0000000 --- a/docs/merise/_diagrams/mld-rbac.drawio +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/merise/dictionary.md b/docs/merise/dictionary.md index 0dcbf80..cb72788 100644 --- a/docs/merise/dictionary.md +++ b/docs/merise/dictionary.md @@ -1,10 +1,10 @@ # Data Dictionary — Wakdo **Merise phase** : P1 - Conception, step 1 (data dictionary first, mantra #33) -**Version** : v0.2 — prod-like, 19 entities -**Date** : 2026-06-04 +**Version** : v0.2 — prod-like, 21 entities (19 prod-like + security-by-design layer, incl. the new `login_throttle` entity) +**Date** : 2026-06-04 (security-by-design additions 2026-06-11) **Branch** : `feat/p1-conception` -**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7) +**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design layer in progress (see note 13) **Author** : BYAN (methodology layer) --- @@ -193,21 +193,32 @@ Elementary ingredient used in product composition. Carries stock data. | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | | `name` | VARCHAR(120) | NO | — | UNIQUE | e.g., "Sesame Bun", "Cheddar Slice", "Ketchup Portion" | | `unit` | VARCHAR(40) | NO | — | — | packaging unit label: piece / portion / sachet 1kg / pot / bottle (free-form label, not an ENUM — units vary per ingredient) | -| `stock_quantity` | INT | NO | 0 | CHECK >= 0 | current stock in units. Signed INT to allow negative detection (alert), but business rule enforces >= 0 | +| `stock_quantity` | INT (signed) | NO | 0 | — | current stock in units. Signed INT with no `CHECK >= 0`: it MAY go negative when sales outrun counted stock (oversell magnitude, surfaced to managers). The system does not block an order on stock. | +| `stock_capacity` | INT | NO | — | CHECK > 0 | reference "full" level in units = the 100% used to compute the stock percentage. The `CHECK > 0` also guards the percentage division against divide-by-zero | | `pack_size` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | units per restocking pack (e.g., 100 for a bag of 100 portions) | | `pack_label` | VARCHAR(80) | YES | NULL | — | human label of the pack (e.g., "Sac 100 portions") | -| `low_stock_threshold` | SMALLINT UNSIGNED | NO | 0 | CHECK >= 0 | alert threshold: stock_quantity <= this value triggers low-stock indicator | +| `low_stock_pct` | SMALLINT UNSIGNED | NO | 10 | CHECK BETWEEN 0 AND 100 | warning band, percent of capacity: `stock_quantity <= stock_capacity * low_stock_pct/100` triggers the low-stock indicator | +| `critical_stock_pct` | SMALLINT UNSIGNED | NO | 5 | CHECK BETWEEN 0 AND 100 | auto-out-of-stock floor, percent of capacity: `stock_quantity <= stock_capacity * critical_stock_pct/100` makes the product computed out-of-stock | | `is_active` | TINYINT(1) | NO | 1 | — | deactivate obsolete ingredients without deleting | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit | | `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit | +**Table-level CHECK**: `critical_stock_pct < low_stock_pct` (the critical floor sits below the warning band). + **Stock decrement rule**: at the `paid` transition, each ingredient is decremented by `product_ingredient.quantity_normal` or `quantity_maxi` (selected by `order_item.format`) multiplied by `order_item.quantity`, then adjusted by `order_item_modifier` rows. See note 7. **Restocking rule**: `stock_quantity += N * pack_size` (restocked in full packs). **Cancellation rule**: stock is re-credited when a `paid` order is cancelled. -**Low-stock alert**: computed at display time (`stock_quantity <= low_stock_threshold`); -no additional stored column. +**Stock model (percentage-based, three bands)**: the absolute alert threshold is replaced by a +percentage model anchored on `stock_capacity` (the 100% reference). The stock percentage is +computed, not stored: `stock_pct = ROUND(stock_quantity / stock_capacity * 100)`. The +`CHECK > 0` on `stock_capacity` guards this division against divide-by-zero. Three bands: +- **Normal** — above the low band: nothing flagged. +- **Low** — `stock_quantity <= stock_capacity * low_stock_pct/100`: orderable + manager alert. + The manager either pulls the product via `product.is_available=0`, or restocks to clear the alert. +- **Critical** — `stock_quantity <= stock_capacity * critical_stock_pct/100`: the product + auto-goes out-of-stock (computed availability, see rule RG-T21 in `mlt.md`); no extra stored column. --- @@ -271,7 +282,9 @@ Customer transaction: 1 order = 1 validated cart at a point in time. |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | | `order_number` | VARCHAR(20) | NO | — | UNIQUE | human-readable format: `K`/`C`/`D`-YYYY-MM-DD-NNN. Prefix by channel: K=kiosk, C=counter, D=drive. See note 4. | +| `idempotency_key` | VARCHAR(36) | YES | NULL | UNIQUE | client-generated UUID to deduplicate a retried `POST /api/orders` (anti-double-charge). UNIQUE rejects duplicates; multiple NULLs allowed. Security-by-design, see note 13 | | `source` | ENUM('kiosk','counter','drive') | NO | — | INDEX | input channel (who entered the order). Values in English, see note 5. | +| `acting_user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | back-office staff (counter/drive) who created the order, captured under PIN. NULL for `kiosk` (anonymous). Targeted accountability without forcing per-person login on the kiosk. See note 13 | | `service_mode` | ENUM('dine_in','takeaway','drive') | NO | — | — | consumption mode, retained for stats/KPI only. No fiscal role (see note 9). `drive` source implies `drive` service_mode (cross-constraint enforced at app layer). | | `status` | ENUM('pending_payment','paid','delivered','cancelled') | NO | 'pending_payment' | INDEX | 4-state machine: `pending_payment -> paid -> delivered` (+ `cancelled`). See note 6. | | `total_ht_cents` | INT UNSIGNED | NO | — | CHECK >= 0 | ex-VAT total, snapshot at order validation | @@ -384,6 +397,13 @@ are not authenticated and have no row here. | `role_id` | INT UNSIGNED | NO | — | FK -> `role(id)`, ON DELETE RESTRICT | a user cannot exist without a role | | `is_active` | TINYINT(1) | NO | 1 | — | deactivation without deletion | | `last_login_at` | DATETIME | YES | NULL | — | useful for audit and dormant account detection | +| `pin_hash` | VARCHAR(255) | YES | NULL | — | argon2id hash of the per-staff PIN that authorises sensitive actions (price/RBAC/user/cancel/inventory). NULL = no PIN set. Security-by-design, see note 13 | +| `failed_login_attempts` | SMALLINT UNSIGNED | NO | 0 | — | consecutive failed logins; drives degressive throttling (note 13) | +| `last_failed_login_at` | DATETIME | YES | NULL | — | timestamp of the last failed login | +| `lockout_until` | DATETIME | YES | NULL | — | end of the current throttling window (degressive backoff, not a hard indefinite lock) | +| `password_reset_token_hash` | VARCHAR(255) | YES | NULL | — | hash of the reset token (not the raw token); NULL when no reset pending | +| `password_reset_expires_at` | DATETIME | YES | NULL | — | expiry of the reset token | +| `anonymized_at` | DATETIME | YES | NULL | — | RGPD tombstone marker: when set, PII columns are nulled/replaced (note 13). The row is kept for referential integrity | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit | | `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit | @@ -392,6 +412,10 @@ are not authenticated and have no row here. RFC 5321 email length: local-part <= 64, domain <= 255, total <= 254 (including `@`). VARCHAR(254) is the spec-compliant value. +**PII columns**: `email`, `first_name`, `last_name`. Subject to RGPD anonymisation +(see note 13). `password_hash` and `pin_hash` are credentials, kept out of logs and +API responses. + --- ### 3.15 `role` @@ -539,6 +563,59 @@ Append-only audit log of all stock changes per ingredient. --- +### 3.20 `audit_log` + +Append-only log of **sensitive back-office actions**, for accountability where it matters +(insider threat, money handling, RBAC changes). Complements `stock_movement` (which is +stock-specific); covers catalogue/price, user, role/permission, and order cancellation events. +Security-by-design addition (see note 13). + +| Attribute | Type | NULL | Default | Constraint | Notes | +|---|---|---|---|---|---| +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | +| `actor_user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | staff who performed the action, captured via PIN for sensitive operations. NULL if not attributable to an individual | +| `actor_role_id` | INT UNSIGNED | YES | NULL | FK -> `role(id)`, ON DELETE SET NULL | role context at action time (denormalised so the trail survives user anonymisation) | +| `action_code` | VARCHAR(60) | NO | — | INDEX | MCT operation / permission code, e.g. `product.update`, `order.cancel`, `role.manage`, `user.deactivate` | +| `entity_type` | VARCHAR(40) | YES | NULL | — | affected table name, e.g. `product`, `customer_order`, `role`, `user` | +| `entity_id` | INT UNSIGNED | YES | NULL | — | PK of the affected row | +| `summary` | VARCHAR(255) | YES | NULL | — | short non-personal description, e.g. "price_cents 880 -> 920", "added permission stock.manage" | +| `details` | JSON | YES | NULL | — | optional before/after diff. For user-targeted actions, stores changed **field names**, not PII values | +| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | immutable timestamp | + +**Immutability**: no UPDATE or DELETE at application layer (same discipline as `stock_movement`). +**Indexes**: `(actor_user_id, created_at)`, `(entity_type, entity_id)`, `(action_code, created_at)`. +**Retention**: own window (~12 months, legitimate-interest / fiscal traceability), decoupled +from user PII lifecycle (note 13). A scheduled purge (cron) removes rows past the window. + +**Logged operations** (sensitive set): `UPDATE_PRODUCT` (8.2, incl. price), `DELETE_PRODUCT` +(8.3), `DELETE_MENU` (8.6), `CANCEL_ORDER` (7.1), `RESTOCK` (9.1), `INVENTORY_COUNT` (9.2), +`CREATE_USER` / `UPDATE_USER` / `DEACTIVATE_USER` (10.1-10.3), `MANAGE_RBAC` (10.4). + +**Volume**: low (~10-50 sensitive actions/day) — orders of magnitude below `stock_movement`. + +--- + +### 3.21 `login_throttle` + +Per-source-IP brute-force throttle. Complements the per-account counter already on `user` +(`failed_login_attempts` / `lockout_until`), one row per source IP. Security-by-design addition +(see note 13). + +| Attribute | Type | NULL | Default | Constraint | Notes | +|---|---|---|---|---|---| +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | +| `ip_address` | VARCHAR(45) | NO | — | UNIQUE | source IP, one row per IP, upserted; 45 chars holds a full IPv6 literal | +| `failed_attempts` | SMALLINT UNSIGNED | NO | 0 | — | consecutive failed logins from this IP in the current window | +| `window_started_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | start of the current counting window | +| `lockout_until` | DATETIME | YES | NULL | — | end of the degressive backoff window; NULL = not throttled | +| `last_attempt_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | timestamp of the last failed attempt | + +**No FK**: an IP is not a modelled entity. Rows are appended/upserted by IP; the window resets +when expired. A daily cron purges rows with no active lockout whose `last_attempt_at` is older +than 24h. + +--- + ## 4. Modeling notes ### Note 1 — Why `INT UNSIGNED` in cents for prices @@ -748,6 +825,62 @@ The 4-state machine combined with 3 phase timestamps provides all KPI data neede For stock audit, `stock_movement` (entity 3.19) provides the append-only audit trail where it is genuinely needed (inventory reconciliation). +### Note 13 — Security-by-design data additions (2026-06-11) + +These additions extend the prod-like model with a security-by-design layer. They do not +replace any v0.2 decision; they add accountability, auth lifecycle, and abuse resistance. + +**Accountability — hybrid shared-account + PIN.** Back-office sessions stay shared per +workstation for the routine flow (a fast-food terminal is shared, `equipiers` rotate). A +per-staff PIN (`user.pin_hash`, argon2id) authorises a defined set of **sensitive actions** +(price/menu edits 8.2/8.3/8.6, order cancellation 7.1, inventory correction 9.2, user +management 10.1-10.3, RBAC 10.4). Those actions write the acting `user_id` into `audit_log` +(3.20). This resolves the circular justification that dropped `commande_event` in v0.1 +(events were considered useless because accounts were shared): accountability is recorded +where it matters, at near-zero friction for the routine 95%. `customer_order.acting_user_id` +captures the staff for counter/drive orders taken under PIN; kiosk orders stay anonymous. + +**Auth lifecycle.** `password_reset_token_hash` + `password_reset_expires_at` enable a reset +path (the token is stored hashed, the raw token is e-mailed once). Brute-force resistance uses +degressive throttling rather than a hard indefinite lock: `failed_login_attempts` + +`lockout_until` implement an exponential backoff per (account + source IP), so a fat-finger +streak does not lock out a whole kitchen mid-service (15 h continuous). Failed logins are +written to `audit_log`. + +**RGPD anonymisation vs audit retention.** `user` PII (`email`, `first_name`, `last_name`) +is subject to the right to erasure (Cr 3.d). Erasure **anonymises** rather than hard-deletes: +the row is kept, `email` becomes a non-identifying unique placeholder (`anon-@wakdo.invalid`, +RFC 2606 reserved domain), names are cleared, `password_hash`/`pin_hash` are invalidated, and +`anonymized_at` is set. The `audit_log` retains its own retention window (~12 months, +legitimate-interest / fiscal traceability) and keeps pointing at the anonymised principal, so +erasure and accountability coexist without breaking referential integrity. + +**Abuse resistance on the anonymous kiosk.** `customer_order.idempotency_key` (client UUID, +UNIQUE) deduplicates a retried `POST /api/orders` so a network retry does not create a +duplicate paid order. Stock is decremented with a single atomic statement +(`UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id`): no operation +gates on a stock read, so the row self-locks for the duration of the write — no lost update and +no deadlock-ordering concern. This replaces the earlier pessimistic `SELECT ... FOR UPDATE` +approach (treatment-layer rule, see `mlt.md`); it adds no column here. + +**Percentage stock model + computed availability.** `ingredient` carries `stock_capacity` (the +100% reference), `low_stock_pct` (warning band) and `critical_stock_pct` (auto-out-of-stock +floor) — see 3.6. `stock_quantity` is signed and may go negative (oversell magnitude surfaced to +managers); the system does not block an order on stock. Effective product orderability is +computed (rule RG-T21 in `mlt.md`): `product.is_available = 1` AND each non-removable +(`is_removable=0`) ingredient of its `product_ingredient` has +`stock_quantity > stock_capacity * critical_stock_pct/100`. At the critical band a product +auto-goes out-of-stock with no write and no cascade; a manual pull (`product.is_available=0`) is +a hard override; restock above the critical band makes the product orderable again on its own. + +**Per-IP brute-force throttle.** `login_throttle` (3.21) tracks `failed_attempts` and +`lockout_until` per source IP (one upserted row per IP), complementing the per-account counter +on `user`. This adds a second throttling dimension so a single IP hammering many accounts is +slowed independently of any one account's counter. A daily cron purges idle, non-locked rows. + +References: `docs/notes/revue-alignement-p1.md` §7 (D-decisions), security-by-design impact +map (2026-06-11). Threat model and data-classification matrix: `PROJECT_CONTEXT.md` §19 (to come). + --- ## 5. Entity count summary @@ -773,11 +906,20 @@ where it is genuinely needed (inventory reconciliation). | 17 | `permission` | reference | v0.1 `permission` (translated, catalogue frozen) | | 18 | `role_permission` | join | v0.1 `role_permission` (unchanged) | | 19 | `stock_movement` | audit | new — append-only stock audit log | +| 20 | `audit_log` | audit | new (security-by-design) — append-only sensitive-action log | +| 21 | `login_throttle` | security | new (security-by-design) - per-IP brute-force throttle | **Dropped from v0.1**: `commande_event` (replaced by phase timestamps on `customer_order`), `menu_produit` (replaced by `menu_slot` + `menu_slot_option` model). -**Total: 19 entities.** +**Total: 21 entities** (19 prod-like v0.2 + `audit_log` and `login_throttle` from the +security-by-design layer). + +Security-by-design also adds columns (beyond the two new entities): `user` auth-lifecycle + +`pin_hash` + `anonymized_at` (3.14), `customer_order.acting_user_id` + `idempotency_key` (3.10), +and the percentage stock model on `ingredient` (3.6) — `stock_capacity`, `critical_stock_pct`, +plus the rename of `low_stock_threshold` to `low_stock_pct`. `login_throttle` (3.21) is the 21st +entity. See note 13. --- diff --git a/docs/merise/mcd.md b/docs/merise/mcd.md index 80fcbf9..e910e23 100644 --- a/docs/merise/mcd.md +++ b/docs/merise/mcd.md @@ -1,10 +1,10 @@ # Conceptual Data Model (MCD) — Wakdo **Merise phase** : P1 - Conception, step 2 (data dictionary first, mantra #33) -**Version** : v0.2 — prod-like, 19 entities -**Date** : 2026-06-04 +**Version** : v0.2 — prod-like, 21 entities (19 prod-like + security-by-design layer) +**Date** : 2026-06-04 (security-by-design additions 2026-06-11) **Branch** : `feat/p1-conception` -**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7) +**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design layer (audit_log + accountability/auth columns) in progress **Author** : BYAN (methodology layer) --- @@ -21,7 +21,7 @@ structure: how many X per Y, whether participation is mandatory, whether associa their own attributes. **Sources**: -- `docs/merise/dictionary.md` (v0.2 — 19 entities, source of truth for all names, types, ENUMs) +- `docs/merise/dictionary.md` (v0.2 — 21 entities, source of truth for all names, types, ENUMs) - `docs/notes/revue-alignement-p1.md` §7 (decision table D1-D8 + stock) - `docs/PROJECT_CONTEXT.md` (business rules: menu composition, order flow, RBAC, service modes) - `docs/merise/_sources/` (school data: 9 categories, 53 products, 13 menus) @@ -62,7 +62,7 @@ N-N associations that carry their own attributes become **associative entities** ## 3. Decomposition by sub-domain -The 19-entity model is split into 4 sub-domains for readability. Beyond approximately +The 21-entity model is split into 4 sub-domains for readability. Beyond approximately 5 entities, a single flat diagram becomes difficult to read; decomposition is the standard Merise practice for models of this size. @@ -71,12 +71,21 @@ Merise practice for models of this size. | Catalogue | category, product, menu, menu_slot, menu_slot_option | 5 | | Ingredients & Stock | ingredient, product_ingredient, allergen, ingredient_allergen, stock_movement | 5 | | Order | customer_order, order_item, order_item_selection, order_item_modifier | 4 | -| RBAC | user, role, role_visible_source, permission, role_permission | 5 | +| RBAC & Audit | user, role, role_visible_source, permission, role_permission, audit_log, login_throttle | 7 | -**Note on the absence of a global diagram**: a single 19-entity ER diagram would be +> **Security-by-design layer (2026-06-11)**: `audit_log` (entity 20) is a cross-cutting, +> append-only log of sensitive actions; it is placed in the RBAC & Audit sub-domain because +> its references (`actor_user_id`, `actor_role_id`) are RBAC entities. `login_throttle` +> (entity 21) is a per-source-IP brute-force throttle, keyed by IP and carrying no FK; it sits +> in the same sub-domain because it guards the authentication path. New columns on existing +> entities: `user` auth-lifecycle + `pin_hash` + `anonymized_at`, `customer_order.acting_user_id` +> + `idempotency_key`. See dictionary note 13. + +**Note on the absence of a global diagram**: a single 21-entity ER diagram would be unreadable and unmaintainable. The sub-domain decomposition below is the intentional -structural choice. The `.drawio` source files will be regenerated from this document as the -single reference once the MCD is stabilised (regeneration tracked in `docs/notes/`). +structural choice. Each sub-domain is a Mermaid `erDiagram` (authoritative, rendered +natively) with a portable SVG render in `docs/merise/_diagrams/`; see section 11 for the +sources and regeneration command. --- @@ -174,15 +183,18 @@ erDiagram varchar name varchar unit int stock_quantity + int stock_capacity smallint pack_size varchar pack_label - smallint low_stock_threshold + smallint low_stock_pct + smallint critical_stock_pct tinyint is_active } product_ingredient { int product_id FK int ingredient_id FK - smallint quantity + smallint quantity_normal + smallint quantity_maxi tinyint is_removable tinyint is_addable int extra_price_cents @@ -238,13 +250,15 @@ erDiagram ### 5.3 Notes on the Ingredients & Stock sub-domain -**`product_ingredient` as an associative entity**: the N-N association between `product` and `ingredient` carries four attributes (`quantity`, `is_removable`, `is_addable`, `extra_price_cents`). It becomes a join table in the MLD with composite PK `(product_id, ingredient_id)`. +**`product_ingredient` as an associative entity**: the N-N association between `product` and `ingredient` carries five attributes (`quantity_normal`, `quantity_maxi`, `is_removable`, `is_addable`, `extra_price_cents`). It becomes a join table in the MLD with composite PK `(product_id, ingredient_id)`. **`ingredient_allergen` as a pure join table**: no own attributes. The allergen set for a product is computed at query time by joining `product_ingredient -> ingredient_allergen -> allergen`; no manual per-product entry is needed. **`stock_movement` immutability**: this table is append-only. No UPDATE or DELETE is permitted at application layer. Corrections are new rows with `movement_type = 'inventory_correction'` and a signed `delta`. -**Low-stock alert**: computed at display time (`stock_quantity <= low_stock_threshold`); no additional stored column. +**Percentage-based stock model**: stock health is anchored on a per-ingredient `stock_capacity` (the 100% reference, `CHECK > 0`). `stock_quantity` is signed and may go negative when sales outrun counted stock; the system does not block an order on a low stock read. `stock_pct = ROUND(stock_quantity / stock_capacity * 100)` is computed, not stored. Two percentage thresholds drive a three-band behaviour: `low_stock_pct` (warning band, default 10%) and `critical_stock_pct` (auto-out-of-stock floor, default 5%), with the table-level invariant `critical_stock_pct < low_stock_pct`. Above the low band is normal; between critical and low the product stays orderable and a manager alert is raised (the manager either pulls the product via `product.is_available = 0` or restocks to clear the alert); at or below the critical band the product auto-goes out-of-stock (computed, see below). + +**Computed product availability (rule RG-T21, see `mlt.md`)**: effective orderability is derived, not stored. A product is orderable when `product.is_available = 1` AND each non-removable (`is_removable = 0`) ingredient in its `product_ingredient` has `stock_quantity > stock_capacity * critical_stock_pct / 100`. A required ingredient reaching the critical band makes the product auto-out-of-stock with no write and no cascade; a manual pull (`product.is_available = 0`) is a hard override; restock above the critical band makes the product orderable again on its own. A removable/optional ingredient at the critical band does not block the product (only its add-on becomes unavailable). The dashboard distinguishes a manual pull from a stock-driven OOS. --- @@ -257,7 +271,9 @@ erDiagram customer_order { int id PK varchar order_number + varchar idempotency_key enum source + int acting_user_id FK enum service_mode enum status int total_ht_cents @@ -358,6 +374,12 @@ the MLD). `preparing` and `ready` are dropped (decision D4, `revue-alignement-p1.md` §7). KPI timing is `delivered_at - paid_at`; KDS colour coding is computed from `NOW() - paid_at`. +**Security-by-design columns (2026-06-11)**: `idempotency_key` (client UUID, UNIQUE) +deduplicates a retried `POST /api/orders`. `acting_user_id` (FK -> `user`, ON DELETE SET NULL) +records the counter/drive staff who took the order under PIN; NULL for anonymous kiosk orders. +This adds a `customer_order |o--o| user : "taken_by"` association (cardinality: an order is +taken by (0,1) user; a user takes (0,N) orders). See dictionary note 13. + --- ## 7. Sub-domain: RBAC @@ -370,11 +392,15 @@ erDiagram int id PK varchar email varchar password_hash + varchar pin_hash varchar first_name varchar last_name int role_id FK tinyint is_active datetime last_login_at + smallint failed_login_attempts + datetime lockout_until + datetime anonymized_at } role { int id PK @@ -399,13 +425,38 @@ erDiagram int role_id FK int permission_id FK } + audit_log { + int id PK + int actor_user_id FK + int actor_role_id FK + varchar action_code + varchar entity_type + int entity_id + varchar summary + json details + datetime created_at + } + login_throttle { + int id PK + varchar ip_address UK + smallint failed_attempts + datetime window_started_at + datetime lockout_until + datetime last_attempt_at + } user }o--|| role : "holds" role ||--o{ role_visible_source : "sees_source" role ||--o{ role_permission : "grants" permission ||--o{ role_permission : "granted_to" + user |o--o{ audit_log : "performs" + role |o--o{ audit_log : "context_of" ``` +> `login_throttle` is a standalone entity with no association: it is keyed by source IP +> (`ip_address UNIQUE`), not by a modelled actor, so it carries no FK and connects to no +> other entity in the diagram. + ### 7.2 Association cardinalities | # | Association | Side A | Cardinality A | Side B | Cardinality B | Justification | @@ -414,6 +465,8 @@ erDiagram | R2 | sees_source | role | (0,N) | role_visible_source | (1,1) | A role may see 0 or more order sources on the preparation dashboard (admin/manager use a global view with no source filter). Each visibility row belongs to exactly one role. | | R3 | grants | role | (0,N) | role_permission | (1,1) | A role may have no permissions (a newly created role before assignment) or many. Each mapping row belongs to one role. | | R4 | granted_to | permission | (0,N) | role_permission | (1,1) | A permission may be granted to no roles yet (declared at seed, not yet distributed) or to several. Each mapping row references one permission. | +| R5 | performs | user | (0,1) | audit_log | (0,N) | A sensitive action captured under PIN records its acting user; automated/non-attributable entries carry NULL. A user may have logged any number of actions. ON DELETE SET NULL preserves the trail on user anonymisation/removal. | +| R6 | context_of | role | (0,1) | audit_log | (0,N) | Each audit row may denormalise the actor's role at action time (NULL allowed). A role may be the context of many audit rows. ON DELETE SET NULL preserves the trail. | ### 7.3 Notes on the RBAC sub-domain @@ -430,11 +483,28 @@ erDiagram **Seed roles** (5 roles, frozen at DDL; extendable without code change): `admin`, `manager`, `kitchen`, `counter`, `drive`. +**`audit_log` (security-by-design)**: append-only log of sensitive actions, immutable like +`stock_movement`. Both FKs (`actor_user_id`, `actor_role_id`) are nullable with ON DELETE +SET NULL, so the trail survives user anonymisation (RGPD) and role removal. The `actor_role_id` +is denormalised on purpose: even if the user is later anonymised, the role context of the +action is preserved. It carries no PII (the `details` JSON stores changed field names, not +values for user-targeted actions). See dictionary 3.20 and note 13. + +**`login_throttle` (security-by-design)**: per-source-IP brute-force throttle, complementing +the per-account counter already on `user` (`failed_login_attempts` / `lockout_until`). One row +per IP (`ip_address VARCHAR(45) UNIQUE`, 45 chars to hold a full IPv6 literal), upserted on each +failed login: `failed_attempts` counts consecutive failures from this IP in the current window, +`window_started_at` marks the start of that window (which resets when expired), `lockout_until` +holds the end of the degressive backoff (NULL = not throttled), `last_attempt_at` the timestamp +of the last failed attempt. It has no FK (an IP is not a modelled entity) and no association. A +daily cron purges rows with no active lockout whose `last_attempt_at` is older than 24h. See +dictionary 3.21 and note 13. + --- ## 8. Cross-validation MCD <-> dictionary -Verification that all 19 dictionary entities appear in the MCD and vice versa. +Verification that all 21 dictionary entities appear in the MCD and vice versa. | # | Dictionary entity (section 3) | Sub-domain in MCD | Present | |---|---|---|---| @@ -457,17 +527,21 @@ Verification that all 19 dictionary entities appear in the MCD and vice versa. | 17 | `permission` (3.17) | RBAC | Yes | | 18 | `role_permission` (3.18) | RBAC | Yes | | 19 | `stock_movement` (3.19) | Ingredients & Stock | Yes | +| 20 | `audit_log` (3.20) | RBAC & Audit | Yes | +| 21 | `login_throttle` (3.21) | RBAC & Audit | Yes | -**Result**: 19/19 entities traced. No entity from the dictionary is absent from the MCD. -No entity in the MCD falls outside the dictionary. +**Result**: 21/21 entities traced (19 prod-like + `audit_log` and `login_throttle` +security-by-design). No entity from the dictionary is absent from the MCD. No entity in the MCD +falls outside the dictionary. **Entities appearing in multiple sub-domains** (cross-domain shared entities): - `product`: Catalogue (sold item, slot eligibility) + Ingredients (recipe) + Order (line reference, slot choice) - `menu`: Catalogue (definition, slots) + Order (line reference) - `menu_slot`: Catalogue (slot definition) + Order (slot choices via `order_item_selection`) - `ingredient`: Ingredients (recipe, stock) + Order (modifiers) -- `customer_order`: Order (order lifecycle) + Ingredients (stock movement trigger) -- `user`: RBAC (authentication) + Ingredients (stock movement author) +- `customer_order`: Order (order lifecycle) + Ingredients (stock movement trigger) + RBAC & Audit (taken_by staff via `acting_user_id`) +- `user`: RBAC (authentication) + Ingredients (stock movement author) + Order (`acting_user_id` on counter/drive orders) + Audit (actor of `audit_log`) +- `role`: RBAC (permissions, visible sources) + Audit (denormalised `actor_role_id` context on `audit_log`) This is expected in a normalised model. The sub-domain split is for readability; the actual relational schema is a unified graph. @@ -518,16 +592,36 @@ Pre-validation: each entity participates in at least one treatment. | `permission` | Admin permission matrix management | | `role_permission` | Admin permission matrix management | | `stock_movement` | Automatic at `paid` transition; manual restock and inventory correction | +| `audit_log` | Written by sensitive operations: UPDATE/DELETE product/menu (8.2/8.3/8.6), CANCEL_ORDER (7.1), RESTOCK/INVENTORY_COUNT (9.1/9.2), user ops (10.1-10.3), MANAGE_RBAC (10.4), and failed/successful logins (12.1) | +| `login_throttle` | Read and written by AUTHENTICATE_USER (12.1): per-source-IP throttle upserted on each failed login, read to enforce the backoff window, purged by a daily cron | Cross-validation MCD <-> MCT (mantra #34) to be completed exhaustively in `mct.md` -once the MCT is updated to the 4-state machine and 19-entity model. +once the MCT incorporates the security-by-design operations (PIN-gated sensitive actions, +audit writes, reset/lockout, anonymisation). The treatment-layer additions are tracked there. --- -## 11. Note on .drawio diagram regeneration +## 11. Diagram sources and regeneration -The `.drawio` XML sources in `docs/merise/_diagrams/` reflect the v0.1 model (11 entities, -French naming). They are scheduled for regeneration from this v0.2 MCD as a separate task. -Until regenerated, this Markdown document is the authoritative conceptual model. The Mermaid -`erDiagram` blocks in sections 4-7 render natively on GitHub and serve as the interim -graphical reference. +The authoritative graphical model is the set of Mermaid `erDiagram` blocks in sections 4-7, +one per sub-domain. They render natively on Forgejo and GitHub. The MCD is decomposed by +sub-domain on purpose: a single 21-entity diagram cannot be laid out without crossing +relationship lines (intrinsic planarity limit, and `erDiagram` offers no manual layout +control). Each sub-domain stays at 5-8 entities, which auto-layout handles cleanly. The +integrated view across sub-domains is the cross-validation table in section 8. + +Portable SVG renders live in `docs/merise/_diagrams/` (for PDF export / offline viewing): + +| Sub-domain | Source | Render | +|---|---|---| +| Catalogue | `mcd-catalogue.mmd` | `mcd-catalogue.svg` | +| Ingredients & Stock | `mcd-ingredients-stock.mmd` | `mcd-ingredients-stock.svg` | +| Order | `mcd-order.mmd` | `mcd-order.svg` | +| RBAC | `mcd-rbac.mmd` | `mcd-rbac.svg` | + +The `.mmd` files are extracted from the `erDiagram` blocks above; the `.svg` are produced by +`make docs-render` (mmdc). If a block here changes, re-extract the matching `.mmd` and re-run +`make docs-render`. The legacy v0.1 `.drawio` sources have been removed: drawio gave manual +layout control but required hand-editing and did not render in the Markdown previews, whereas +the decomposed Mermaid blocks are version-controlled, render everywhere, and stay in sync with +this document. diff --git a/docs/merise/mct.md b/docs/merise/mct.md index 05b3935..e618deb 100644 --- a/docs/merise/mct.md +++ b/docs/merise/mct.md @@ -1,10 +1,10 @@ # Model of Conceptual Treatments (MCT) — Wakdo **Merise phase** : P1 - Conception, step 3 (after MCD) -**Version** : v0.2 — prod-like, 4-state machine -**Date** : 2026-06-04 +**Version** : v0.2 — prod-like, 4-state machine (+ security-by-design layer 2026-06-11) +**Date** : 2026-06-04 (security-by-design additions 2026-06-11) **Branch** : `feat/p1-conception` -**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7) +**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design ops added (ERASE_USER_PII, RESET_PASSWORD, PIN-gated sensitive set, audit_log writes, auth throttling) — 28 operations **Author** : BYAN (methodology layer) --- @@ -56,6 +56,17 @@ the ticket and act. The single staff gesture is "deliver". KPI is total time and `MARK_READY` (`MARQUER_PRETE`) are removed because their intermediate states no longer exist. `DELIVER_ORDER` becomes the sole status-advancing action for counter/drive staff. +**Security-by-design layer (2026-06-11)**: two operations are added — `RESET_PASSWORD` (12.3) +and `ERASE_USER_PII` (10.5, RGPD anonymisation). A subset of operations is **PIN-gated**: +back-office sessions stay shared per workstation, but a per-staff PIN re-authorises the +sensitive set — `CANCEL_ORDER` (7.1), `UPDATE_PRODUCT`/`DELETE_PRODUCT` (8.2/8.3), +`DELETE_MENU` (8.6), `INVENTORY_COUNT` (9.2), user management (10.1-10.3), `MANAGE_RBAC` +(10.4), `ERASE_USER_PII` (10.5). These non-stock actions append an immutable `audit_log` row +(actor, action, target); stock actions record attribution in `stock_movement`. The treatment +logic (PIN, audit, throttling, idempotency, atomic stock decrement, computed product +availability) is specified in `mlt.md` (rules RG-T13-T21). This adds entities 20 `audit_log` +and 21 `login_throttle` to the model. + --- ## 2. Representation conventions @@ -102,7 +113,7 @@ For each operation the document provides: | **Synchronisation** | None (single event) | | **Condition** | The kiosk is in service (within business hours 10:00-01:00) | | **Operation** | LOAD_CATALOGUE | -| **Description** | Retrieval of active categories, available products, and available menus (with their slots and eligible options) for display on the kiosk screen. | +| **Description** | Retrieval of active categories, available products, and available menus (with their slots and eligible options) for display on the kiosk screen. Product availability is COMPUTED: a product is orderable only if its `is_available` flag is set AND each non-removable (`is_removable=0`) ingredient in its `product_ingredient` is above the critical band (`stock_quantity > stock_capacity * critical_stock_pct/100`). See rule RG-T21 in `mlt.md`. | | **MCD entities** | R: `category` (is_active=1), `product` (is_available=1), `menu` (is_available=1), `menu_slot`, `menu_slot_option`, `ingredient` (is_active=1), `allergen`, `ingredient_allergen` | | **Result** | Catalogue loaded; kiosk displays the home screen | @@ -337,7 +348,7 @@ For each operation the document provides: | **Synchronisation** | OR (create ingredient, update ingredient, update composition, update allergen mapping) | | **Condition** | Actor holds permission `ingredient.manage`. | | **Operation** | MANAGE_INGREDIENT | -| **Description** | CRUD on `ingredient` (name, unit, pack_size, pack_label, low_stock_threshold, is_active). Manage `product_ingredient` composition (quantity_normal, quantity_maxi, is_removable, is_addable, extra_price_cents) for any product. Manage `ingredient_allergen` mapping (14 EU regulated allergens). Deactivating an ingredient (`is_active=0`) hides it from the configurator without deletion. Physical deletion of `ingredient` is blocked if referenced in `product_ingredient` (FK `ON DELETE RESTRICT`) or `stock_movement` (FK `ON DELETE RESTRICT`). | +| **Description** | CRUD on `ingredient` (name, unit, pack_size, pack_label, stock_capacity, low_stock_pct, critical_stock_pct, is_active). Manage `product_ingredient` composition (quantity_normal, quantity_maxi, is_removable, is_addable, extra_price_cents) for any product. Manage `ingredient_allergen` mapping (14 EU regulated allergens). Deactivating an ingredient (`is_active=0`) hides it from the configurator without deletion. Physical deletion of `ingredient` is blocked if referenced in `product_ingredient` (FK `ON DELETE RESTRICT`) or `stock_movement` (FK `ON DELETE RESTRICT`). | | **MCD entities** | R: `product` (FK validation), `allergen` (FK validation) — W: `ingredient` (INSERT/UPDATE/DELETE conditional), `product_ingredient` (INSERT/UPDATE/DELETE), `ingredient_allergen` (INSERT/DELETE) | | **Result** | Ingredient / composition / allergen mapping updated | @@ -384,7 +395,7 @@ For each operation the document provides: | **Synchronisation** | None | | **Condition** | Actor holds permission `stock.read`. | | **Operation** | READ_STOCK | -| **Description** | Read `ingredient` list with current `stock_quantity`, `low_stock_threshold`, `pack_size`, `pack_label`. Low-stock alert computed at display time: `stock_quantity <= low_stock_threshold`. Optional: read `stock_movement` history for a given ingredient, filtered by date range. | +| **Description** | Read `ingredient` list with current `stock_quantity`, `stock_capacity`, computed `stock_pct`, `low_stock_pct`, `critical_stock_pct`, `pack_size`, `pack_label`. Stock bands computed at display time: `low_stock` when `stock_quantity <= stock_capacity * low_stock_pct/100`, `critical_stock` when `stock_quantity <= stock_capacity * critical_stock_pct/100`. Optional: read `stock_movement` history for a given ingredient, filtered by date range. | | **MCD entities** | R: `ingredient`, `stock_movement` (optional history) | | **Result** | Stock list displayed with low-stock indicators | @@ -452,6 +463,21 @@ For each operation the document provides: --- +### 10.5 ERASE_USER_PII (security-by-design) + +| Field | Value | +|-------|-------| +| **Triggering event** | A RGPD erasure request is processed for a back-office user | +| **Actor** | ADMIN (PIN-gated) | +| **Synchronisation** | None | +| **Condition** | Actor holds permission `user.update` and has re-authorised via PIN. Target user exists and is not already anonymised. | +| **Operation** | ERASE_USER_PII | +| **Description** | RGPD right-to-erasure honoured by **anonymisation**, not physical deletion: PII (`email`, `first_name`, `last_name`) is cleared/replaced by a non-identifying placeholder, credentials invalidated, `anonymized_at` set. The row persists so referential links (`stock_movement`, `customer_order`, `audit_log`) stay valid and resolve to an anonymised principal. See `mlt.md` 10.5 and dictionary note 13. | +| **MCD entities** | W: `user` (UPDATE — PII cleared, `anonymized_at` set), `audit_log` (INSERT) | +| **Result** | User anonymised; PII removed; accountability links preserved; one `audit_log` row recorded | + +--- + ## 11. Domain 9 — Stats and KPI ### 11.1 READ_STATS @@ -478,11 +504,11 @@ For each operation the document provides: | **Triggering event** | An actor submits the login form | | **Actor** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN | | **Synchronisation** | None | -| **Condition** | Email exists in database. Password matches argon2id hash. User `is_active=1`. | +| **Condition** | Account not in a throttling window (`lockout_until`). Email exists in database. Password matches argon2id hash. User `is_active=1`. | | **Operation** | AUTHENTICATE_USER | -| **Description** | Credential verification. If valid: session ID regeneration (protection against session fixation), storage of `user_id` and `role_id` in session, UPDATE `last_login_at`. Idle timeout: 4h. Absolute timeout: 10h. Redirect to `role.default_route`. | -| **MCD entities** | R: `user` (verification), `role` (load permissions, default_route), `role_permission` — W: `user` (UPDATE last_login_at) | -| **Result** | Session opened, redirect to role-specific default view | +| **Description** | Credential verification. If valid: session ID regeneration (protection against session fixation), storage of `user_id` and `role_id` in session, UPDATE `last_login_at`, reset of the login failure counter. On failure: increment `failed_login_attempts` and apply a degressive backoff (`lockout_until`), enumeration-safe generic error. Idle timeout: 4h. Absolute timeout: 10h. Redirect to `role.default_route`. See `mlt.md` 12.1. | +| **MCD entities** | R: `user` (verification), `role` (load permissions, default_route), `role_permission`, `login_throttle` (the per-IP throttle gate) — W: `user` (UPDATE last_login_at, `failed_login_attempts`, `lockout_until`), `login_throttle` (upsert `failed_attempts`/`lockout_until` on failure, clear on success), `audit_log` (INSERT login success/failure) | +| **Result** | Session opened, redirect to role-specific default view; or throttled failure logged | --- @@ -501,6 +527,21 @@ For each operation the document provides: --- +### 12.3 RESET_PASSWORD (security-by-design) + +| Field | Value | +|-------|-------| +| **Triggering event** | A user requests a password reset, then confirms it via the emailed link | +| **Actor** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN | +| **Synchronisation** | Sequential two-phase: request, then confirm | +| **Condition** | Request: the submitted email is processed enumeration-safely (same neutral response whether or not it exists). Confirm: a valid, non-expired token is presented. | +| **Operation** | RESET_PASSWORD | +| **Description** | Request phase generates a random token, stores its hash + expiry, and e-mails the raw token once. Confirm phase validates the token hash + expiry, replaces `password_hash` (argon2id), clears the token, and resets the login failure counter. See `mlt.md` 12.3. | +| **MCD entities** | W: `user` (UPDATE `password_reset_token_hash` + `password_reset_expires_at` on request; UPDATE `password_hash`, clear token, reset `failed_login_attempts`/`lockout_until` on confirm), `audit_log` (INSERT) | +| **Result** | Password reset via a one-time, time-bound token; one `audit_log` row recorded | + +--- + ## 13. State machine — customer_order.status Summary of transitions covered by MCT operations. @@ -573,8 +614,17 @@ single delivery action (DELIVER_ORDER) collapses the v0.1 three-step sequence in | 24 | READ_STATS | Stats | MANAGER/ADMIN | — | customer_order, order_item | | 25 | AUTHENTICATE_USER | Auth | ALL BACK | user | user, role, role_permission | | 26 | LOGOUT_USER | Auth | ALL BACK | — | — | +| 27 | ERASE_USER_PII | RBAC | ADMIN | user, audit_log | user | +| 28 | RESET_PASSWORD | Auth | ALL BACK | user, audit_log | user | -**Total: 26 operations** covering the complete Wakdo business lifecycle. +**Total: 28 operations** (26 prod-like + `ERASE_USER_PII` and `RESET_PASSWORD` from the +security-by-design layer). + +**Audit log writes (security-by-design)**: the sensitive operations 7.1 (cancel), 8.2/8.3 +(product update/delete), 8.6 (menu delete), 10.1-10.5 (user/RBAC/erasure) and 12.1 (login) +also write an `audit_log` row (W entity not repeated per row above to keep the table legible). +Stock operations 9.1/9.2 record their attribution via `stock_movement.user_id`. PIN-gated set +per `mlt.md` RG-T13. --- @@ -603,9 +653,20 @@ Verification that each MCD entity participates in at least one MCT operation. | `permission` | 23 | — (static seed) | OK (*) | | `role_permission` | 25 | 23 | OK | | `stock_movement` | 19 | 3, 5, 8, 17, 18 | OK | +| `audit_log` | (admin audit view) | 8, 10, 11, 14, 20, 21, 22, 23, 25, 27, 28 | OK | +| `login_throttle` | 25 | 25 | OK | (*) `allergen` and `permission` are read-only at the MCT level: their values are declared in seed migrations and are not modifiable via the UI. `allergen` is managed indirectly via `ingredient_allergen` in MANAGE_INGREDIENT. -**Conclusion**: 19/19 entities covered. MCT <-> MCD consistency validated. +(**) `audit_log` (entity 20, security-by-design) is write-mostly: it is appended by the +sensitive operations above and read through an admin audit view (a dedicated read operation +can be formalised when the audit UI is specified at P3). + +(***) `login_throttle` (entity 21, security-by-design) is the per-source-IP brute-force +throttle gate: it is read AND written (upserted) by `AUTHENTICATE_USER` (25). Its daily purge +of stale rows is a cron, documented in `mlt.md`, outside MCT operation scope. + +**Conclusion**: 21/21 entities covered (19 prod-like + `audit_log` + `login_throttle`). MCT <-> MCD consistency +validated. diff --git a/docs/merise/mld.md b/docs/merise/mld.md index a48d6e2..5cd6563 100644 --- a/docs/merise/mld.md +++ b/docs/merise/mld.md @@ -1,10 +1,10 @@ # Logical Data Model (MLD) — Wakdo **Merise phase** : P1 - Conception, step 5 (after MCD, MCT, MLT) -**Version** : v0.2 — prod-like, 19 tables -**Date** : 2026-06-04 +**Version** : v0.2 — prod-like, 21 tables (19 prod-like + security-by-design layer) +**Date** : 2026-06-04 (security-by-design additions 2026-06-11) **Branch** : `feat/p1-conception` -**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7) +**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design layer (audit_log + accountability/auth columns) in progress **Author** : BYAN (methodology layer) --- @@ -93,7 +93,7 @@ in addition to the composite FK PK. Applied to `product_ingredient`. --- -## 4. Relational schema (19 tables) +## 4. Relational schema (21 tables) Tables are ordered by dependency (no-FK tables first, then tables that depend on them). @@ -245,14 +245,16 @@ No timestamps. Pure join table. ### 4.6 `ingredient` ``` -ingredient (id, name, unit, stock_quantity, pack_size, [pack_label], - low_stock_threshold, is_active, created_at, updated_at) +ingredient (id, name, unit, stock_quantity, stock_capacity, pack_size, [pack_label], + low_stock_pct, critical_stock_pct, is_active, created_at, updated_at) PK : id UK : name - CHK : stock_quantity >= 0 + CHK : stock_capacity > 0 CHK : pack_size > 0 - CHK : low_stock_threshold >= 0 + CHK : low_stock_pct BETWEEN 0 AND 100 + CHK : critical_stock_pct BETWEEN 0 AND 100 + CHK : critical_stock_pct < low_stock_pct ``` | Column | Type | NULL | Notes | @@ -260,16 +262,38 @@ ingredient (id, name, unit, stock_quantity, pack_size, [pack_label], | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | | `name` | VARCHAR(120) | NO | Unique name, e.g. "Sesame Bun" | | `unit` | VARCHAR(40) | NO | Packaging unit label (free-form, not ENUM) | -| `stock_quantity` | INT NOT NULL DEFAULT 0 | NO | Current stock. Signed INT to detect negative (alert) | +| `stock_quantity` | INT NOT NULL DEFAULT 0 | NO | Current stock. Signed INT that may go negative when sales outrun counted stock (oversell magnitude, surfaced to managers); the system does not block an order on stock | +| `stock_capacity` | INT NOT NULL | NO | Reference "full" level in units = the 100% used to compute the stock percentage; CHECK > 0 also guards the percentage division against divide-by-zero | | `pack_size` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Units per restocking pack | | `pack_label` | VARCHAR(80) | YES | Human label of the pack | -| `low_stock_threshold` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Alert threshold | +| `low_stock_pct` | SMALLINT UNSIGNED NOT NULL DEFAULT 10 | NO | Warning band, percent of capacity (CHECK BETWEEN 0 AND 100) | +| `critical_stock_pct` | SMALLINT UNSIGNED NOT NULL DEFAULT 5 | NO | Auto-out-of-stock floor, percent of capacity (CHECK BETWEEN 0 AND 100; table CHECK `critical_stock_pct < low_stock_pct`) | | `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Deactivate obsolete ingredients | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | No FK. Root table for the Ingredients & Stock sub-domain. +**Percentage-based stock model**: the stock state is computed (NOT stored) as +`stock_pct = ROUND(stock_quantity / stock_capacity * 100)`. Two bands derive from it: +`LOW` when `stock_quantity <= stock_capacity * low_stock_pct/100`, and +`CRITICAL` when `stock_quantity <= stock_capacity * critical_stock_pct/100`. +Three-band behaviour: above `low` = normal; between `critical` and `low` = orderable +plus manager alert (the manager either pulls the product via `product.is_available=0`, or +restocks to clear the alert); at or below `critical` = auto out-of-stock (computed, rule +RG-T21). `stock_quantity` is signed and may go negative; the system does not block an order +on stock, so a negative value records the oversell magnitude for managers. + +**Computed availability (rule RG-T21)**: a product is effectively orderable when +`product.is_available = 1` AND each non-removable (`is_removable=0`) ingredient in its +`product_ingredient` has `stock_quantity > stock_capacity * critical_stock_pct/100`. At the +critical band a product auto-goes out-of-stock with no write and no cascade; a manual pull +(`product.is_available=0`) is a hard override; a restock above the critical band makes the +product orderable again on its own; a removable/optional ingredient at the critical band does +not block the product (only its add-on becomes unavailable). The dashboard distinguishes a +manual pull (`is_available=0`) from a stock-driven OOS (`is_available=1` but a required +ingredient is critical). + --- ### 4.7 `product_ingredient` @@ -382,8 +406,10 @@ No FK. Root table for RBAC. ### 4.11 `user` ``` -user (id, email, password_hash, first_name, last_name, #role_id, - is_active, [last_login_at], created_at, updated_at) +user (id, email, password_hash, [pin_hash], first_name, last_name, #role_id, + is_active, [last_login_at], failed_login_attempts, [last_failed_login_at], + [lockout_until], [password_reset_token_hash], [password_reset_expires_at], + [anonymized_at], created_at, updated_at) PK : id UK : email @@ -394,19 +420,32 @@ user (id, email, password_hash, first_name, last_name, #role_id, | Column | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `email` | VARCHAR(254) | NO | RFC 5321 max length | +| `email` | VARCHAR(254) | NO | RFC 5321 max length. PII (RGPD anonymisation, see below) | | `password_hash` | VARCHAR(255) | NO | argon2id hash | -| `first_name` | VARCHAR(60) | NO | | -| `last_name` | VARCHAR(60) | NO | | +| `pin_hash` | VARCHAR(255) | YES | argon2id hash of the per-staff PIN (sensitive-action authorisation). Security-by-design | +| `first_name` | VARCHAR(60) | NO | PII | +| `last_name` | VARCHAR(60) | NO | PII | | `role_id` | INT UNSIGNED | NO | FK -> role | | `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Deactivation without deletion | | `last_login_at` | DATETIME | YES | Audit, dormant account detection | +| `failed_login_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Brute-force counter (degressive throttling) | +| `last_failed_login_at` | DATETIME | YES | Timestamp of last failed login | +| `lockout_until` | DATETIME | YES | End of current throttling window (backoff, not indefinite lock) | +| `password_reset_token_hash` | VARCHAR(255) | YES | Hash of the reset token (not the raw token) | +| `password_reset_expires_at` | DATETIME | YES | Reset token expiry | +| `anonymized_at` | DATETIME | YES | RGPD tombstone marker; PII nulled/replaced when set | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | **ON DELETE RESTRICT** on `role_id`: a role cannot be deleted while users hold it. Deactivate the role first (`is_active = 0`), then reassign users before deleting. +**RGPD anonymisation** (security-by-design, dict. note 13): the right to erasure is honoured by +anonymising, not hard-deleting. `email` becomes a unique non-identifying placeholder +(`anon-@wakdo.invalid`, RFC 2606 reserved domain — preserves the UNIQUE constraint), +`first_name`/`last_name` are cleared, `password_hash`/`pin_hash` are invalidated, `is_active=0`, +`anonymized_at = NOW()`. The row persists so `audit_log` and `stock_movement` FKs stay valid. + --- ### 4.12 `role_visible_source` @@ -488,13 +527,16 @@ No timestamps. Pure join table. ### 4.15 `customer_order` ``` -customer_order (id, order_number, source, service_mode, status, +customer_order (id, order_number, [idempotency_key], source, [#acting_user_id], + service_mode, status, total_ht_cents, total_vat_cents, total_ttc_cents, [paid_at], [delivered_at], [cancelled_at], created_at, updated_at) PK : id UK : order_number + UK : idempotency_key + FK : acting_user_id -> user(id) ON DELETE SET NULL IDX : (status, created_at) IDX : (source, created_at) IDX : created_at @@ -509,7 +551,9 @@ customer_order (id, order_number, source, service_mode, status, |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | | `order_number` | VARCHAR(20) | NO | Format `K/C/D-YYYY-MM-DD-NNN` by channel | +| `idempotency_key` | VARCHAR(36) | YES | Client UUID, UNIQUE; deduplicates retried POST (security-by-design) | | `source` | ENUM('kiosk','counter','drive') | NO | Input channel | +| `acting_user_id` | INT UNSIGNED | YES | FK -> user; counter/drive staff under PIN; NULL for kiosk | | `service_mode` | ENUM('dine_in','takeaway','drive') | NO | Consumption mode (stats only, no fiscal role) | | `status` | ENUM('pending_payment','paid','delivered','cancelled') NOT NULL DEFAULT 'pending_payment' | NO | 4-state machine | | `total_ht_cents` | INT UNSIGNED | NO | Ex-VAT total snapshot | @@ -521,8 +565,11 @@ customer_order (id, order_number, source, service_mode, status, | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Used as `service_day` base | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -No FK toward `user`: staff attribution is not stored on the order. Operational accountability -is covered by `stock_movement.user_id` for stock actions. +**Staff attribution (security-by-design)**: `acting_user_id` (FK -> `user`, ON DELETE SET NULL) +records the counter/drive staff who took the order under PIN; NULL for anonymous kiosk orders. +Kiosk orders stay anonymous by design. `stock_movement.user_id` covers attribution of stock +actions. `idempotency_key` (UNIQUE, nullable) deduplicates a retried `POST /api/orders` +(multiple NULLs allowed by the UNIQUE index, so non-idempotent legacy paths are tolerated). **4-state machine**: `pending_payment -> paid -> delivered` (+ `cancelled`). States `preparing` and `ready` are dropped (decision D4). KPI: `delivered_at - paid_at` (target SLA ~10 min). @@ -693,6 +740,76 @@ No `updated_at`. Immutable append-only table. --- +### 4.20 `audit_log` + +Append-only log of sensitive back-office actions (security-by-design, dict. 3.20). + +``` +audit_log (id, [#actor_user_id], [#actor_role_id], action_code, + [entity_type], [entity_id], [summary], [details], created_at) + + PK : id + FK : actor_user_id -> user(id) ON DELETE SET NULL + FK : actor_role_id -> role(id) ON DELETE SET NULL + IDX : (actor_user_id, created_at) + IDX : (entity_type, entity_id) + IDX : (action_code, created_at) +``` + +| Column | Type | NULL | Notes | +|---|---|---|---| +| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | +| `actor_user_id` | INT UNSIGNED | YES | FK -> user; acting staff (PIN-captured) or NULL if not attributable | +| `actor_role_id` | INT UNSIGNED | YES | FK -> role; denormalised role context (survives user anonymisation) | +| `action_code` | VARCHAR(60) | NO | MCT operation / permission code, e.g. `product.update`, `order.cancel` | +| `entity_type` | VARCHAR(40) | YES | Affected table name | +| `entity_id` | INT UNSIGNED | YES | PK of the affected row | +| `summary` | VARCHAR(255) | YES | Short non-personal change description | +| `details` | JSON | YES | Optional before/after diff (field names for user-targeted actions, not PII values) | +| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Immutable timestamp | + +**ON DELETE SET NULL** on both FKs: the trail is preserved when a user is anonymised/removed +or a role deleted; only the link is severed (the denormalised `actor_role_id` keeps the role +context even after user anonymisation). + +**Immutability rule**: no UPDATE or DELETE at application layer. **Retention**: a scheduled +cron purge removes rows older than the retention window (~12 months, legitimate-interest / +fiscal traceability), decoupled from the user PII lifecycle (dict. note 13). + +No `updated_at`. Immutable append-only table. + +--- + +### 4.21 `login_throttle` + +Per-source-IP brute-force throttle (security-by-design). Complements the per-account counter +already on `user` (`failed_login_attempts` / `lockout_until`). + +``` +login_throttle (id, ip_address, failed_attempts, window_started_at, + [lockout_until], last_attempt_at) + + PK : id + UK : ip_address + IDX : lockout_until +``` + +| Column | Type | NULL | Notes | +|---|---|---|---| +| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | +| `ip_address` | VARCHAR(45) | NO | Source IP, one row per IP, upserted; 45 chars holds a full IPv6 literal. UNIQUE | +| `failed_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Consecutive failed logins from this IP in the current window | +| `window_started_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Start of the current counting window | +| `lockout_until` | DATETIME | YES | End of the degressive backoff window; NULL = not throttled | +| `last_attempt_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Timestamp of the last failed attempt | + +No FK: an IP is not a modelled entity. Append/upsert by IP; the window resets when expired. A +daily cron purges rows with no active lockout whose `last_attempt_at` is older than 24h. + +No `updated_at`: rows are upserted by IP, not edited through a UI. + +--- + ## 5. Referential integrity summary | FK column | References | ON DELETE | Rationale | @@ -722,6 +839,9 @@ No `updated_at`. Immutable append-only table. | `stock_movement.ingredient_id` | `ingredient(id)` | RESTRICT | Ingredient with history cannot be deleted | | `stock_movement.order_id` | `customer_order(id)` | SET NULL | Audit preserved, order link lost | | `stock_movement.user_id` | `user(id)` | SET NULL | Audit preserved, user attribution lost | +| `customer_order.acting_user_id` | `user(id)` | SET NULL | Staff attribution preserved as anonymised principal; order kept | +| `audit_log.actor_user_id` | `user(id)` | SET NULL | Audit trail preserved on user anonymisation; only the link is severed | +| `audit_log.actor_role_id` | `role(id)` | SET NULL | Role context kept until role deletion; denormalised so it survives user anonymisation | **Key used**: CASCADE = child has no meaning without parent; RESTRICT = parent deletion blocked while children exist; SET NULL = child is preserved, only the link is severed. @@ -736,9 +856,11 @@ blocked while children exist; SET NULL = child is preserved, only the link is se | `product` | `vat_rate IN (55, 100)` | Only two legal VAT rates for this model | | `menu` | `price_normal_cents > 0` | Same as product | | `menu` | `price_maxi_cents > 0` | Same | -| `ingredient` | `stock_quantity >= 0` | Negative stock is an alert, not a valid state | +| `ingredient` | `stock_capacity > 0` | The 100% reference must be positive; also guards the percentage division against divide-by-zero | | `ingredient` | `pack_size > 0` | Pack size of zero makes restock logic incoherent | -| `ingredient` | `low_stock_threshold >= 0` | Threshold cannot be negative | +| `ingredient` | `low_stock_pct BETWEEN 0 AND 100` | Warning band is a percent of capacity | +| `ingredient` | `critical_stock_pct BETWEEN 0 AND 100` | Auto-out-of-stock floor is a percent of capacity | +| `ingredient` | `critical_stock_pct < low_stock_pct` | Critical floor sits below the warning band | | `product_ingredient` | `quantity_normal > 0` | Recipe quantity of zero is meaningless | | `product_ingredient` | `quantity_maxi >= quantity_normal` | Maxi consumes at least as much as Normal (side/drink more, burger/sauce equal) | | `product_ingredient` | `extra_price_cents >= 0` | No negative surcharge | @@ -776,6 +898,10 @@ MCT / MLT. | `stock_movement` | `(movement_type, created_at)` | Stats: cancellations per week, restocks per month | | `role_permission` | `permission_id` | Reverse query: "which roles have this permission?" | | `user` | `(is_active, role_id)` | Login check + permission resolution | +| `audit_log` | `(actor_user_id, created_at)` | Per-actor audit history | +| `audit_log` | `(entity_type, entity_id)` | "what happened to this product/order/user?" | +| `audit_log` | `(action_code, created_at)` | Audit by action type over a time range | +| `login_throttle` | `lockout_until` | Daily cron purge of rows with no active lockout | **Indexes not added** (intentional): - `customer_order.order_number`: UK index is sufficient; no range query expected on this column. @@ -787,7 +913,8 @@ MCT / MLT. ## 8. Cross-validation MLD <-> MCD -Verification that all 19 MCD entities map to a table, and that all tables trace to the MCD. +Verification that all 21 MCD entities (19 prod-like + 2 security-by-design) map to a table, +and that all tables trace to the MCD. | MCD entity | MLD table | Mapping type | Notes | |---|---|---|---| @@ -810,8 +937,14 @@ Verification that all 19 MCD entities map to a table, and that all tables trace | `order_item_selection` (C17) | `order_item_selection` (4.17) | 1:1 entity | New entity (v0.2) | | `order_item_modifier` (C18) | `order_item_modifier` (4.18) | 1:1 entity | New entity (v0.2) | | `stock_movement` (C19) | `stock_movement` (4.19) | 1:1 entity | New entity (v0.2) | +| `audit_log` (R5/R6) | `audit_log` (4.20) | 1:1 entity | New entity (security-by-design) | +| `login_throttle` (R7) | `login_throttle` (4.21) | 1:1 entity | New entity (security-by-design) | -**Result**: 19/19 entities mapped. No entity without a table; no table outside the MCD. +**Result**: 21/21 entities mapped (19 prod-like + `audit_log` + `login_throttle`). No entity +without a table; no table outside the MCD. New columns on existing tables: `user` +(auth-lifecycle + `pin_hash` + `anonymized_at`), `customer_order` (`idempotency_key`, +`acting_user_id`), `ingredient` (`stock_capacity`, `low_stock_pct`, `critical_stock_pct`; +`low_stock_threshold` repurposed). **Dropped from v0.1**: `commande_event` (replaced by `paid_at`, `delivered_at`, `cancelled_at` phase timestamps on `customer_order` — decision 2.A); `menu_produit` fixed-composition model @@ -842,8 +975,11 @@ phase timestamps on `customer_order` — decision 2.A); `menu_produit` fixed-com | `order_item_selection` | ~300k | 150 bytes | ~45 MB | | `order_item_modifier` | ~150k | 80 bytes | ~12 MB | | `stock_movement` | ~500k | 180 bytes | ~90 MB | +| `audit_log` | ~5k-10k | 200 bytes | ~2 MB | +| `login_throttle` | ~100-1k | 80 bytes | < 1 MB | -**Estimated total**: ~190 MB data + ~60-80 MB for indexes = ~250-270 MB over 6 months. +**Estimated total**: ~190 MB data + ~60-80 MB for indexes = ~250-270 MB over 6 months +(`audit_log` is negligible: sensitive actions are orders of magnitude rarer than orders). Manageable on the MariaDB container (`wakdo_db_data` named volume in `docker-compose.yml`). `stock_movement` is the highest-volume table (~5-15 rows per order across all ingredients). @@ -889,6 +1025,11 @@ history; it will carry meaningful write amplification at scale. - `order_item_selection` (depends on `order_item`, `menu_slot`, `product`) - `order_item_modifier` (depends on `order_item`, `ingredient`) - `stock_movement` (depends on `ingredient`, `customer_order`, `user`) + - `audit_log` (depends on `user`, `role`) + - `login_throttle` (no FK, can be created at any point) + + Note: `customer_order` now carries `acting_user_id -> user`, so `user` must be created + before `customer_order` (already the case: the RBAC block precedes `customer_order`). 2. **Seed** (`db/seeds/0001_demo_data.sql`): - 9 categories + 53 products + 13 menus from JSON sources (`docs/merise/_sources/`) diff --git a/docs/merise/mlt.md b/docs/merise/mlt.md index abcff87..60954c4 100644 --- a/docs/merise/mlt.md +++ b/docs/merise/mlt.md @@ -1,10 +1,10 @@ # Model of Logical Treatments (MLT) — Wakdo **Merise phase** : P1 - Conception, step 4 (derived from MCT) -**Version** : v0.2 — prod-like, 4-state machine -**Date** : 2026-06-04 +**Version** : v0.2 — prod-like, 4-state machine (+ security-by-design layer 2026-06-11) +**Date** : 2026-06-04 (security-by-design additions 2026-06-11) **Branch** : `feat/p1-conception` -**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7) +**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design rules added (RG-T13-T21: PIN, audit, escaping, allowlists, idempotency, atomic decrement, computed product availability (RG-T21); ops RESET_PASSWORD, ERASE_USER_PII, auth throttling; per-IP throttle table `login_throttle`) **Author** : BYAN (methodology layer) --- @@ -49,6 +49,15 @@ These rules apply to multiple operations and are centralised here to avoid repet | **RG-T10** | VAT computation is line-by-line: each `order_item` carries its own `vat_rate_snapshot` (per-mille integer snapshotted from `product.vat_rate`). Order totals (`total_ht_cents`, `total_vat_cents`, `total_ttc_cents`) are the sum of line-level amounts. | 3.3, 4.1 | | **RG-T11** | Stock decrements at the `pending_payment -> paid` transition and re-credits at `paid -> cancelled` are within the same database transaction as the status update (no orphan decrement). | 3.3, 4.1, 7.1 | | **RG-T12** | Dashboard filter by source: each role's visible sources are read from `role_visible_source`; the query uses `WHERE customer_order.source IN (role_visible_sources)`. | 6.1 | +| **RG-T13** | **Sensitive-action PIN** (security-by-design): the set of sensitive operations requires a per-staff PIN re-authorisation before execution: verify the submitted PIN against `user.pin_hash` (`password_verify`, argon2id). On success the acting `user_id` is captured for the audit log; on failure the operation is rejected. Sensitive set: 7.1 (cancel), 8.2/8.3 (product update/delete), 8.6 (menu delete), 9.2 (inventory correction), 10.1/10.2/10.3 (user mgmt), 10.4 (RBAC), 10.5 (PII erasure). Sessions stay shared per workstation for the routine 95%. | 7.1, 8.2, 8.3, 8.6, 9.2, 10.1-10.5 | +| **RG-T14** | **Audit log write**: non-stock sensitive operations append one immutable `audit_log` row in the same transaction as their effect: `actor_user_id` (from RG-T13 PIN), `actor_role_id`, `action_code` (permission/operation code), `entity_type` + `entity_id` of the affected row, `summary` (non-personal change description), `details` JSON (changed field **names** for user-targeted actions, not PII values). No UPDATE/DELETE on `audit_log`. Stock actions (9.1 restock, 9.2 inventory) record their attribution via `stock_movement.user_id` (PIN-captured), which already provides the append-only stock trail — they are not double-logged. | 7.1, 8.2, 8.3, 8.6, 10.1-10.5, 12.1 | +| **RG-T15** | **Output escaping** (anti-XSS): free-text fields (`product.name`/`description`, `ingredient.name`, `user.first_name`/`last_name`, notes) are context-escaped at render. Server-rendered admin views use `htmlspecialchars($v, ENT_QUOTES, 'UTF-8')`; the vanilla-JS kiosk injects text via `textContent` (or an explicit escaper), not `innerHTML`. | All views rendering stored text | +| **RG-T16** | **Mass-assignment allowlist**: INSERT/UPDATE statements bind only an explicit per-operation column allowlist from the request; extra/unknown fields are dropped. Prevents tampering with `price_cents`, `vat_rate`, `role_id`, `is_active`, `status` via injected form fields. | 8.1, 8.2, 8.4, 8.5, 10.1, 10.2 | +| **RG-T17** | **Dynamic identifier allowlist**: column/direction tokens used in dynamic `ORDER BY` / `GROUP BY` are resolved against a fixed allowlist of column names before query build (RG-T06 covers values via bind parameters; SQL identifiers cannot be bound, so they are allowlisted). | 5.1, 9.3, 11.1 | +| **RG-T18** | **Server-side validation and length bounds**: every input is re-validated server-side regardless of client checks — type, range, max length (matching the dictionary VARCHAR sizes), enum membership, FK existence. Client-side validation is a UX aid, not a trust boundary. | All write operations | +| **RG-T19** | **Idempotency**: `POST /api/orders` carries a client-generated `idempotency_key` (UUID). Before creating, look it up on `customer_order.idempotency_key` (UNIQUE); if a row exists, return that order instead of creating a duplicate (replayed network retry). | 3.3, 4.1 | +| **RG-T20** | **Atomic stock decrement**: during the `paid` transition, each affected `ingredient` is decremented with a single self-locking statement `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` — no preceding read-gate, no `SELECT ... FOR UPDATE`. Concurrent orders on the same ingredient apply their deltas without a lost update and without a deadlock-ordering concern. `stock_quantity` is signed and may go negative when sales outrun counted stock (oversell magnitude surfaced to managers); the decrement does not block on a floor. | 3.3, 4.1 | +| **RG-T21** | **Computed product availability**: a product's effective orderability is computed, not stored. It is orderable when `product.is_available = 1` AND each non-removable (`is_removable = 0`) ingredient in its `product_ingredient` has `stock_quantity > stock_capacity * critical_stock_pct / 100`. At the critical band a required ingredient takes the product out-of-stock with no write and no cascade; restock above the critical band makes it orderable again on its own; a manual pull (`product.is_available = 0`) is a hard override; a removable/optional ingredient at the critical band does not block the product (only its add-on becomes unavailable). | 3.1, 3.3, 4.1, 5.1 | --- @@ -107,10 +116,13 @@ These rules apply to multiple operations and are centralised here to avoid repet | **[RG-2 — service_day]** | `service_day` for a given order is computed at query time as: `CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END`. Cutoff is 10:00. This is NOT stored as a column — computed at query time only. The v0.1 formula with `INTERVAL 4 HOUR 30 MINUTE` was incorrect and is dropped. | | **[RG-3 — order number]** | Order number format: `K-YYYY-MM-DD-NNN` where NNN is the sequential counter for the current service_day for the `kiosk` source (SELECT COUNT + 1 with a table-level lock or serialised insert to avoid duplicate generation under concurrency). Source is `kiosk` (set by the kiosk endpoint, derived from the public entry point). | | **[RG-4 — VAT by line]** | For each `order_item`: `vat_rate_snapshot` is copied from `product.vat_rate`. Line amounts: `unit_ttc = unit_price_cents_snapshot`; `unit_ht = ROUND(unit_ttc * 1000 / (1000 + vat_rate_snapshot))`; `unit_vat = unit_ttc - unit_ht`. Order totals: `total_ttc_cents = SUM(unit_ttc * quantity)` across all lines; `total_ht_cents = SUM(unit_ht * quantity)`; `total_vat_cents = total_ttc_cents - total_ht_cents`. Invariant: `total_ttc_cents = total_ht_cents + total_vat_cents` (verified before INSERT). | -| **[RG-5 — atomic transaction]** | All writes within one database transaction: (1) INSERT `customer_order` (status `pending_payment`, source `kiosk`, service_mode from cart, computed totals); (2) INSERT `order_item` rows (label_snapshot, unit_price_cents_snapshot, vat_rate_snapshot, quantity, format, item_type, product_id or menu_id); (3) INSERT `order_item_selection` rows for each slot filled in a menu item (order_item_id, menu_slot_id, product_id, label_snapshot); (4) INSERT `order_item_modifier` rows for each ingredient modification (order_item_id, ingredient_id, action, extra_price_cents snapshot); (5) for each ingredient consumed: compute units = `(order_item.format = 'maxi' ? product_ingredient.quantity_maxi : product_ingredient.quantity_normal) * order_item.quantity`, adjusted by modifiers (remove => no decrement for that ingredient; add => extra decrement); UPDATE `ingredient.stock_quantity -= units`; INSERT `stock_movement` (type `sale`, delta = -units, order_id, user_id = NULL for kiosk); (6) UPDATE `customer_order` SET status = `paid`, `paid_at = NOW()`. All six steps commit together or roll back entirely. | +| **[RG-5 — atomic transaction]** | All writes within one database transaction: (1) INSERT `customer_order` (status `pending_payment`, source `kiosk`, service_mode from cart, computed totals); (2) INSERT `order_item` rows (label_snapshot, unit_price_cents_snapshot, vat_rate_snapshot, quantity, format, item_type, product_id or menu_id); (3) INSERT `order_item_selection` rows for each slot filled in a menu item (order_item_id, menu_slot_id, product_id, label_snapshot); (4) INSERT `order_item_modifier` rows for each ingredient modification (order_item_id, ingredient_id, action, extra_price_cents snapshot); (5) for each ingredient consumed: compute units = `(order_item.format = 'maxi' ? product_ingredient.quantity_maxi : product_ingredient.quantity_normal) * order_item.quantity`, adjusted by modifiers (remove => no decrement for that ingredient; add => extra decrement); apply the atomic decrement `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` (single self-locking statement, no preceding read-gate, RG-T20); `stock_quantity` is signed and may go negative (oversell magnitude, surfaced to managers) — the decrement does not gate on a floor; INSERT `stock_movement` (type `sale`, delta = -units, order_id, user_id = NULL for kiosk); (6) UPDATE `customer_order` SET status = `paid`, `paid_at = NOW()`. All six steps commit together or roll back entirely. | | **[RG-6 — cross-constraint]** | Source `kiosk` implies no particular service_mode constraint; the customer selects `dine_in` or `takeaway`. The drive cross-constraint (RG-T09) does not apply to kiosk-originated orders. | | **[RG-7 — immutability]** | After INSERT, `label_snapshot`, `unit_price_cents_snapshot`, and `vat_rate_snapshot` are not modified even if the source product is later renamed or repriced (see RG-T05). | -| **[POST-1]** | One `customer_order` row exists with `status = 'paid'`, `source = 'kiosk'`, all totals computed, `paid_at` set. The `pending_payment` phase is not observable outside the transaction. | +| **[RG-8 — idempotency]** | The body carries a client `idempotency_key` (UUID). Before any write, `SELECT id, order_number, status FROM customer_order WHERE idempotency_key = :key`. If found, skip creation and return that order (deduplicates a replayed retry — RG-T19). The key is stored on the new `customer_order` row. | +| **[RG-9 — server-side modifier re-validation]** | The ingredient modifiers in the body are re-validated server-side against `product_ingredient`: an `action='remove'` requires `is_removable=1`; an `action='add'` requires `is_addable=1` and snapshots the current `extra_price_cents`. Client-side checks (3.2 RG-4) are not trusted; a crafted POST adding a non-addable ingredient is rejected (HTTP 422). | +| **[RG-10 — atomic stock decrement]** | No operation gates on a stock read, so the decrement is a single atomic statement `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` (RG-T20). The row self-locks for the duration of the update, so concurrent kiosk orders on the same ingredient apply their deltas without a lost update and without a deadlock-ordering concern; `stock_quantity` is signed and may go negative (oversell magnitude surfaced to managers). | +| **[POST-1]** | One `customer_order` row exists with `status = 'paid'`, `source = 'kiosk'`, all totals computed, `paid_at` set, `idempotency_key` stored. The `pending_payment` phase is not observable outside the transaction. | | **[POST-2]** | N `order_item` rows exist, each referencing either a `product_id` (item_type='product') or a `menu_id` (item_type='menu') — exclusivity constraint verified. | | **[POST-3]** | `customer_order.order_number` is unique in the database (UNIQUE constraint). | | **[POST-4]** | `ingredient.stock_quantity` decremented for each consumed ingredient unit; one `stock_movement` row of type `sale` per affected ingredient. | @@ -152,7 +164,8 @@ These rules apply to multiple operations and are centralised here to avoid repet | **[RG-2 — cross-constraint]** | If `source = 'drive'` then `service_mode` must be `'drive'` (RG-T09); verified before INSERT. HTTP 422 if violated. | | **[RG-3 — order number]** | Format: `C-YYYY-MM-DD-NNN` for counter source; `D-YYYY-MM-DD-NNN` for drive source. Sequential NNN counter is per source per service_day. | | **[RG-4 — stock]** | Same stock decrement logic as CREATE_ORDER RG-5; `stock_movement.user_id` is set to the authenticated staff member's id. | -| **[POST-1]** | One `customer_order` row with `status = 'paid'`, `source = 'counter'` or `'drive'`, `paid_at` set. | +| **[RG-5 — staff attribution + decrement]** | `customer_order.acting_user_id` is set to the authenticated staff member's id (targeted accountability on counter/drive orders; kiosk orders stay NULL). Server-side modifier re-validation (3.3 RG-9), idempotency (RG-T19) and the atomic stock decrement (RG-T20) apply identically. No PIN is required to create an order (the `order.create` permission suffices); order creation is not in the sensitive-action set. | +| **[POST-1]** | One `customer_order` row with `status = 'paid'`, `source = 'counter'` or `'drive'`, `paid_at` set, `acting_user_id` set. | | **[POST-2]** | N `order_item` rows with snapshots. Slot selections and modifiers written identically to kiosk flow. | | **[POST-3]** | Stock decremented; movements logged with actor `user_id`. | | **[OUT-1]** | HTTP 201: `{data: {id: int, order_number: string, status: 'paid'}}`. Order number communicated to customer. | @@ -218,7 +231,8 @@ These rules apply to multiple operations and are centralised here to avoid repet | **[RG-3 — stock re-credit — conditional]** | Re-credit applies only if the order was at status `paid` before cancellation. Orders at `pending_payment` had not yet decremented stock (the decrement occurs at the `paid` transition). For each `order_item` line of a `paid` order, recompute ingredient units consumed: `(order_item.format = 'maxi' ? product_ingredient.quantity_maxi : product_ingredient.quantity_normal) * order_item.quantity`, adjusted by `order_item_modifier` rows (remove modifier -> ingredient was not decremented, so no re-credit; add modifier -> ingredient had extra decrement, so extra re-credit). UPDATE `ingredient.stock_quantity += units`. INSERT `stock_movement` (type `cancellation`, delta = +units, order_id, user_id of actor). | | **[RG-4 — transaction]** | Status update and stock re-credit (when applicable) execute in the same database transaction (RG-T11). | | **[RG-5 — history]** | Order is not physically deleted; retained for history and stats. Cancelled orders are excluded from revenue totals but included in volume counts in READ_STATS. `order_item` rows are not deleted (ON DELETE CASCADE is not triggered); they allow reconstruction of what was ordered. | -| **[POST-1]** | `customer_order.status = 'cancelled'`, `cancelled_at` set, terminal state. | +| **[RG-6 — PIN + audit]** | Cancellation is a sensitive money-handling action: it requires the per-staff PIN (RG-T13) and writes one `audit_log` row in the same transaction (RG-T14): `action_code='order.cancel'`, `entity_type='customer_order'`, `entity_id=:id`, `summary` with prior status and re-credited amount. | +| **[POST-1]** | `customer_order.status = 'cancelled'`, `cancelled_at` set, terminal state. One `audit_log` row recorded with the acting staff. | | **[POST-2]** | If prior status was `paid`: `ingredient.stock_quantity` re-credited; one `stock_movement` row of type `cancellation` per affected ingredient. | | **[OUT-1]** | HTTP 200 with cancellation confirmation | | **[ERR-1]** | Attempt to cancel a delivered or already cancelled order: HTTP 422, `{error: {code: "CANNOT_CANCEL_IN_STATE", current_status: "..."}}` | @@ -258,7 +272,8 @@ These rules apply to multiple operations and are centralised here to avoid repet | **[RG-1]** | Same validations as CREATE_PRODUCT on modified fields | | **[RG-2]** | If a new image is uploaded, the old image file is deleted from the filesystem (volume cleanup) | | **[RG-3]** | `label_snapshot`, `unit_price_cents_snapshot`, `vat_rate_snapshot` in historical `order_item` rows are not modified (see RG-T05) | -| **[POST-1]** | `product` updated, `updated_at` refreshed | +| **[RG-4 — PIN + audit + allowlist]** | A price/VAT change is a sensitive action: it requires the per-staff PIN (RG-T13) and writes one `audit_log` row (RG-T14) with `action_code='product.update'`, `entity_type='product'`, `entity_id=:id`, and a `summary` recording changed values (e.g. `price_cents 880 -> 920`). Only the allowlisted columns (`name`, `description`, `price_cents`, `vat_rate`, `image_path`, `is_available`, `display_order`, `category_id`) are bound from the request (RG-T16). | +| **[POST-1]** | `product` updated, `updated_at` refreshed; one `audit_log` row recorded | | **[OUT-1]** | Redirect to product list with success message | --- @@ -275,7 +290,8 @@ These rules apply to multiple operations and are centralised here to avoid repet | **[RG-2]** | Pre-check (PHP): is the product the `burger_product_id` of any `menu`? If yes, block with message to delete or reassign the menu first. | | **[RG-3]** | Pre-check (PHP): is the product referenced in `order_item.product_id` (historical orders)? FK `ON DELETE RESTRICT` blocks at DB level. Recommended response: propose deactivation (`is_available=0`) rather than deletion. | | **[RG-4]** | FK constraints (`menu_slot_option.product_id ON DELETE RESTRICT`, `order_item.product_id ON DELETE RESTRICT`) enforce the constraint even if the PHP check is bypassed. | -| **[POST-1]** | Product deleted if no FK constraint was blocking | +| **[RG-5 — PIN + audit]** | Deletion is a sensitive action: it requires the per-staff PIN (RG-T13) and writes one `audit_log` row (RG-T14) with `action_code='product.delete'`, `entity_type='product'`, `entity_id=:id`, `summary` capturing the product name before deletion (recorded before the row is removed). | +| **[POST-1]** | Product deleted if no FK constraint was blocking; one `audit_log` row recorded | | **[OUT-1]** | Redirect to product list with success message | | **[ERR-1]** | Product in menu slot: HTTP 422 or inline message with blocking menu list | | **[ERR-2]** | Product in historical orders: message proposing deactivation instead | @@ -327,7 +343,8 @@ These rules apply to multiple operations and are centralised here to avoid repet | **[PRE-2]** | Target `menu.id` exists | | **[RG-1]** | Pre-check (PHP): is the menu referenced in `order_item.menu_id`? FK `ON DELETE RESTRICT`. If yes, propose deactivation (`is_available=0`) instead of deletion. | | **[RG-2]** | If no historical reference: DELETE `menu` triggers CASCADE to `menu_slot` (which cascades to `menu_slot_option`) | -| **[POST-1]** | `menu`, its `menu_slot` rows, and its `menu_slot_option` rows deleted | +| **[RG-3 — PIN + audit]** | Deletion is a sensitive action: per-staff PIN (RG-T13) + one `audit_log` row (RG-T14), `action_code='menu.delete'`, `entity_type='menu'`, `entity_id=:id`, `summary` capturing the menu name before deletion. | +| **[POST-1]** | `menu`, its `menu_slot` rows, and its `menu_slot_option` rows deleted; one `audit_log` row recorded | | **[OUT-1]** | Redirect with success message | | **[ERR-1]** | Menu in historical orders: message proposing deactivation instead | @@ -357,8 +374,8 @@ These rules apply to multiple operations and are centralised here to avoid repet | Tag | Content | |-----|---------| | **[PRE-1]** | Actor authenticated, holds permission `ingredient.manage` | -| **[RG-CREATE-ING]** | `name` non-empty and UNIQUE; `unit` non-empty; `pack_size >= 1`; `low_stock_threshold >= 0`; `stock_quantity` defaults to 0 at creation | -| **[RG-UPDATE-ING]** | UPDATE `name`, `unit`, `pack_size`, `pack_label`, `low_stock_threshold`, `is_active` | +| **[RG-CREATE-ING]** | `name` non-empty and UNIQUE; `unit` non-empty; `pack_size >= 1`; `stock_capacity >= 1` (the 100% reference); `low_stock_pct` and `critical_stock_pct` in 0-100 with `critical_stock_pct < low_stock_pct` (defaults 10 / 5); `stock_quantity` defaults to 0 at creation | +| **[RG-UPDATE-ING]** | UPDATE `name`, `unit`, `pack_size`, `pack_label`, `stock_capacity`, `low_stock_pct`, `critical_stock_pct`, `is_active` | | **[RG-DEACTIVATE-ING]** | `is_active=0` hides ingredient from configurator. Physical deletion blocked if referenced in `product_ingredient` (FK `ON DELETE RESTRICT`) or `stock_movement` (FK `ON DELETE RESTRICT`). | | **[RG-COMPOSITION]** | UPDATE `product_ingredient`: for each ingredient in a product's recipe, set `quantity_normal`, `quantity_maxi`, `is_removable`, `is_addable`, `extra_price_cents`. Delete-and-reinsert pattern within transaction. | | **[RG-ALLERGEN]** | Manage `ingredient_allergen`: INSERT or DELETE `(ingredient_id, allergen_id)` pairs. Allergen list is read-only (14 rows fixed by EU regulation 1169/2011). | @@ -398,7 +415,8 @@ These rules apply to multiple operations and are centralised here to avoid repet | **[RG-1]** | `delta = actual_quantity - ingredient.stock_quantity` (may be negative if actual < theoretical) | | **[RG-2]** | Transaction: `UPDATE ingredient SET stock_quantity = :actual_quantity WHERE id = :id`; INSERT `stock_movement` (ingredient_id, movement_type=`inventory_correction`, delta=computed, order_id=NULL, user_id=actor, note=optional) | | **[RG-3]** | `delta = 0` is a valid correction (physical count matches theoretical); a movement row is still inserted for audit completeness | -| **[POST-1]** | `ingredient.stock_quantity = actual_quantity`. One `stock_movement` row of type `inventory_correction` inserted. | +| **[RG-4 — PIN attribution]** | An inventory correction can mask shrinkage, so it requires the per-staff PIN (RG-T13). The PIN-captured `user_id` is written to `stock_movement.user_id`, making the correction attributable to a person even on a shared workstation. No separate `audit_log` row (the `stock_movement` trail already records it). | +| **[POST-1]** | `ingredient.stock_quantity = actual_quantity`. One `stock_movement` row of type `inventory_correction` inserted with the acting `user_id`. | | **[OUT-1]** | Confirmation with reconciled stock level and discrepancy displayed | --- @@ -411,10 +429,11 @@ These rules apply to multiple operations and are centralised here to avoid repet |-----|---------| | **[PRE-1]** | Actor authenticated, holds permission `stock.read` | | **[RG-1]** | `SELECT * FROM ingredient WHERE is_active = 1 ORDER BY name ASC` | -| **[RG-2]** | Low-stock alert computed at render time: `stock_quantity <= low_stock_threshold` -> flag `low_stock: true` in response. Not stored as a column. | +| **[RG-2]** | Stock bands computed at render time from the percentage thresholds: `low_stock: true` when `stock_quantity <= stock_capacity * low_stock_pct / 100`, `critical_stock: true` when `stock_quantity <= stock_capacity * critical_stock_pct / 100`; `stock_pct = ROUND(stock_quantity / stock_capacity * 100)` is also returned. Not stored as columns. | | **[RG-3]** | Optional movement history for a given ingredient: `SELECT * FROM stock_movement WHERE ingredient_id = :id ORDER BY created_at DESC LIMIT :n` | +| **[RG-4 — attribution visibility]** | The `stock_movement.user_id` (who restocked / who corrected) is included for `manager`/`admin` only; line staff (`kitchen`/`counter`/`drive`) see the movement deltas without the actor identity. This limits intra-team exposure while preserving accountability for those who manage. The `details` allowlist is applied at the query/serialisation layer. | | **[POST-1]** | No database write | -| **[OUT-1]** | Ingredient list with `stock_quantity`, `low_stock_threshold`, `pack_size`, `pack_label`, `low_stock` flag | +| **[OUT-1]** | Ingredient list with `stock_quantity`, `stock_capacity`, computed `stock_pct`, `low_stock_pct`, `critical_stock_pct`, `pack_size`, `pack_label`, `low_stock` / `critical_stock` flags; movement history with actor visible to manager/admin only | --- @@ -432,7 +451,8 @@ These rules apply to multiple operations and are centralised here to avoid repet | **[RG-1]** | Validation: `email` conforms to RFC 5321 (PHP `FILTER_VALIDATE_EMAIL`), `first_name` and `last_name` non-empty, `role_id` valid | | **[RG-2]** | Password hash: `password_hash($password, PASSWORD_ARGON2ID)`. Minimum password length: 8 characters. | | **[RG-3]** | `is_active = 1` by default; `last_login_at = NULL` at creation | -| **[POST-1]** | One `user` row with argon2id `password_hash`, valid `role_id` | +| **[RG-4 — PIN + audit + allowlist]** | Creating a back-office account is a sensitive action: per-staff PIN (RG-T13) + one `audit_log` row (RG-T14), `action_code='user.create'`, `entity_type='user'`, `entity_id=:new_id`, `details` recording the assigned `role_id` (field names/role, not the password). Only the allowlisted columns are bound (RG-T16): `email`, `first_name`, `last_name`, `role_id` (+ the hashed password); `is_active` and any other field are server-set, not request-bound. | +| **[POST-1]** | One `user` row with argon2id `password_hash`, valid `role_id`; one `audit_log` row recorded | | **[OUT-1]** | Redirect to user list with success message | | **[ERR-1]** | Duplicate email: message "This email is already in use" | | **[ERR-2]** | Password too short: inline validation message | @@ -450,7 +470,8 @@ These rules apply to multiple operations and are centralised here to avoid repet | **[RG-1]** | If a new password is supplied (non-empty field): rehash via `PASSWORD_ARGON2ID` and replace existing hash | | **[RG-2]** | If password field is empty: existing hash is preserved unchanged | | **[RG-3]** | Email update subject to UNIQUE constraint (pre-check before UPDATE) | -| **[POST-1]** | `user` updated, `updated_at` refreshed | +| **[RG-4 — PIN + audit + allowlist]** | Editing an account (incl. `role_id`, the privilege-escalation vector) is sensitive: per-staff PIN (RG-T13) + one `audit_log` row (RG-T14), `action_code='user.update'`, `entity_type='user'`, `entity_id=:id`, `details` listing changed field names (not values, no PII). Only the allowlisted columns are bound (RG-T16): `first_name`, `last_name`, `email`, `role_id`, `is_active` (+ optional password rehash). | +| **[POST-1]** | `user` updated, `updated_at` refreshed; one `audit_log` row recorded | | **[OUT-1]** | Redirect with success message | --- @@ -465,7 +486,8 @@ These rules apply to multiple operations and are centralised here to avoid repet | **[PRE-2]** | Actor is not targeting their own account (`$targetUserId !== $currentUserId`) | | **[RG-1]** | `UPDATE user SET is_active = 0, updated_at = NOW() WHERE id = :id` | | **[RG-2]** | The user's potentially active session is invalidated on next request: middleware checks `user.is_active = 1` on each authenticated request | -| **[POST-1]** | `user.is_active = 0`; user cannot log in; history remains intact | +| **[RG-3 — PIN + audit]** | Sensitive action: per-staff PIN (RG-T13) + one `audit_log` row (RG-T14), `action_code='user.deactivate'`, `entity_type='user'`, `entity_id=:id`. | +| **[POST-1]** | `user.is_active = 0`; user cannot log in; history remains intact; one `audit_log` row recorded | | **[OUT-1]** | Redirect with success message | | **[ERR-1]** | Self-deactivation attempt: HTTP 403, `{error: {code: "SELF_DEACTIVATION_FORBIDDEN"}}` | @@ -485,11 +507,31 @@ These rules apply to multiple operations and are centralised here to avoid repet | **[RG-3]** | Effect is immediate for new requests; sessions of users bearing this role see the change on the next permission check (sessions store `role_id`; permissions are reloaded from DB on each check). | | **[RG-4 — custom role]** | Creating a custom role: INSERT `role` (code UNIQUE, label, description, default_route nullable, order_source nullable); INSERT `role_visible_source` rows as needed. | | **[RG-5 — order_source]** | `role.order_source` controls the auto-tagging of `customer_order.source` when this role creates an order. NULL for admin and manager (they can create on behalf of any channel). | -| **[POST-1]** | `role_permission` reflects exactly the selected permissions for this role | +| **[RG-6 — PIN + audit change-log]** | RBAC changes are high-impact (privilege escalation): per-staff PIN (RG-T13) + one `audit_log` row (RG-T14) per change, `action_code='role.manage'`, `entity_type='role'`, `entity_id=:role_id`. Because permissions are rewritten delete-and-reinsert (RG-1), the `details` JSON records the **diff** — permission codes added and removed — computed before the rewrite, so the trail shows exactly which capabilities a role gained or lost and who granted them. | +| **[POST-1]** | `role_permission` reflects exactly the selected permissions for this role; one `audit_log` row recorded with the permission diff | | **[OUT-1]** | Redirect with success message | --- +### 10.5 ERASE_USER_PII (RGPD anonymisation) + +**Security-by-design operation (no v0.1 / v0.2 MCT predecessor). Honours the RGPD right to +erasure (Cr 3.d) without breaking referential integrity or the audit trail (dict. note 13).** + +| Tag | Content | +|-----|---------| +| **[PRE-1]** | Actor authenticated, holds permission `user.update` (erasure is an admin operation) | +| **[PRE-2]** | Per-staff PIN verified (RG-T13) — sensitive action | +| **[PRE-3]** | Target `user.id` exists and `anonymized_at IS NULL` (not already anonymised) | +| **[RG-1 — anonymise, not delete]** | In one transaction: `UPDATE user SET email = CONCAT('anon-', id, '@wakdo.invalid'), first_name = '', last_name = '', password_hash = '', pin_hash = NULL, password_reset_token_hash = NULL, is_active = 0, anonymized_at = NOW() WHERE id = :id`. The placeholder domain is RFC 2606 reserved (`.invalid`), keeps `email` UNIQUE and non-identifying. | +| **[RG-2 — preserve links]** | The row persists, so FKs pointing at it (`stock_movement.user_id`, `customer_order.acting_user_id`, `audit_log.actor_user_id`) stay valid and now resolve to an anonymised principal. Accountability for past actions is preserved in form (who-as-id) without retaining PII. | +| **[RG-3 — audit]** | One `audit_log` row (RG-T14): `action_code='user.erase_pii'`, `entity_type='user'`, `entity_id=:id`. The `summary`/`details` record the erasure event and its legal basis, not the erased values. | +| **[POST-1]** | `user` row anonymised: PII fields cleared/placeholdered, credentials invalidated, `anonymized_at` set, `is_active = 0`. Referential links intact. | +| **[OUT-1]** | Confirmation; the user disappears from active lists, remains as an anonymised tombstone in history. | +| **[ERR-1]** | Already anonymised: HTTP 409, `{error: {code: "ALREADY_ANONYMISED"}}` | + +--- + ## 11. Domain 9 — Stats and KPI ### 11.1 READ_STATS @@ -519,17 +561,21 @@ These rules apply to multiple operations and are centralised here to avoid repet |-----|---------| | **[PRE-1]** | Login form submitted with email and password | | **[PRE-2]** | CSRF token of the form is valid (anti-CSRF protection) | +| **[PRE-3 — throttle gate]** | If the account is in a throttling window (`user.lockout_until IS NOT NULL AND lockout_until > NOW()`), reject with the generic error before any password check. Throttling is also keyed per source IP via the `login_throttle` table: if a row exists for the source IP with `lockout_until IS NOT NULL AND lockout_until > NOW()`, reject with the same generic error, so distributed attempts on many accounts are slowed too. | | **[RG-1]** | Lookup: `SELECT * FROM user WHERE email = :email AND is_active = 1 LIMIT 1` | -| **[RG-2]** | Password verification: `password_verify($password, $user->password_hash)`. On failure: same generic error whether the email does not exist or the password is wrong (protection against email enumeration). | +| **[RG-2]** | Password verification: `password_verify($password, $user->password_hash)`. On failure: same generic error whether the email does not exist or the password is wrong (protection against email enumeration). To keep timing comparable when the email is unknown, a dummy `password_verify` against a fixed decoy hash is run. | | **[RG-3]** | On success: `session_regenerate(true)` (session ID regeneration, protection against session fixation) | | **[RG-4]** | Session storage: `$_SESSION['user_id']`, `$_SESSION['role_id']`, `$_SESSION['logged_in_at']` | | **[RG-5]** | UPDATE: `UPDATE user SET last_login_at = NOW() WHERE id = :id` | | **[RG-6]** | Session timeouts: idle timeout 4h (detection via last-activity timestamp in session); absolute timeout 10h (detection via `logged_in_at`) | | **[RG-7]** | Redirect target is `role.default_route` (dynamic; no hardcoded role name in routing logic) | -| **[POST-1]** | PHP session open with `user_id` and `role_id`; `user.last_login_at` updated | +| **[RG-8 — failure handling, degressive backoff]** | On a failed verification, the per-account counter on `user`: `UPDATE user SET failed_login_attempts = failed_login_attempts + 1, last_failed_login_at = NOW()`, and once a threshold is reached (suggestion: 5) set `lockout_until = NOW() + INTERVAL (base * 2^(attempts - threshold)) SECOND`, capped (suggestion: cap a few minutes). In the same step, the per-IP dimension is recorded in the `login_throttle` table: upsert the row keyed on `ip_address` (insert if absent, else increment `failed_attempts`; reset the window when expired via `window_started_at`), update `last_attempt_at = NOW()`, and once the IP threshold is reached set `lockout_until` with the same degressive backoff. This is a degressive backoff, not an indefinite lock — it slows brute force without letting a fat-finger streak deny service to a kitchen mid-shift. Write one `audit_log` row (`action_code='auth.login_failed'`, `actor_user_id` if the email resolved, else NULL). | +| **[RG-9 — success reset]** | On success, reset the per-account counter `failed_login_attempts = 0`, clear `lockout_until = NULL`, and also clear the per-IP `login_throttle` row for the source IP (reset `failed_attempts = 0`, `lockout_until = NULL`, restart `window_started_at`), then write one `audit_log` row (`action_code='auth.login_success'`, `actor_user_id`, `actor_role_id`). | +| **[POST-1]** | PHP session open with `user_id` and `role_id`; `user.last_login_at` updated; `failed_login_attempts` reset | | **[OUT-1]** | Redirect to `role.default_route` | -| **[ERR-1]** | Incorrect credentials or inactive account: generic message "Email or password incorrect" (no distinction to prevent enumeration) | +| **[ERR-1]** | Incorrect credentials or inactive account: generic message "Email or password incorrect" (no distinction to prevent enumeration); failure counter incremented (RG-8) | | **[ERR-2]** | Invalid CSRF token: HTTP 403 | +| **[ERR-3]** | Account in throttling window (PRE-3): same generic message; the attempt does not reveal that the account exists or is locked | --- @@ -548,7 +594,21 @@ These rules apply to multiple operations and are centralised here to avoid repet --- -## 13. Automated treatments — Crons (outside user interactions) +### 12.3 RESET_PASSWORD + +**Security-by-design operation (no v0.1 predecessor). Two phases: request, then confirm.** + +| Tag | Content | +|-----|---------| +| **[PRE-1]** | Request phase: a `user` submits the "forgot password" form with an email; CSRF token valid | +| **[RG-1 — request, enumeration-safe]** | Look up the email. The same neutral response ("if the account exists, an email has been sent") is returned whether or not the email exists, to avoid account enumeration. | +| **[RG-2 — token generation]** | If the email resolves to an active user: generate a cryptographically random token (e.g. 32 bytes from a CSPRNG); store its **hash** in `password_reset_token_hash` and `password_reset_expires_at = NOW() + INTERVAL 1 HOUR`. The **raw** token is sent once in the reset link (not stored in clear). | +| **[PRE-2]** | Confirm phase: the user opens the reset link with the raw token and submits a new password; CSRF token valid | +| **[RG-3 — confirm]** | Hash the submitted token and match it against `password_reset_token_hash` where `password_reset_expires_at > NOW()`. On match: `password_hash = password_hash($new, PASSWORD_ARGON2ID)` (min length 8), then clear `password_reset_token_hash = NULL` and `password_reset_expires_at = NULL`, and reset `failed_login_attempts = 0`, `lockout_until = NULL`. One-time use. | +| **[RG-4 — audit]** | Write one `audit_log` row (RG-T14), `action_code='auth.password_reset'`, `actor_user_id = :id`. | +| **[POST-1]** | Password replaced with a new argon2id hash; reset token consumed and cleared | +| **[OUT-1]** | Confirmation; redirect to login | +| **[ERR-1]** | Invalid or expired token: generic message inviting a new reset request (no detail on which condition failed) | These treatments are executed by the `wakdo-cron` service container in the maintenance window 01:30-09:30 (outside active service). They are outside the MCT scope (technical @@ -581,6 +641,24 @@ treatments, no user trigger) but are documented here for consistency with PROJEC | **[RG-2]** | Retention: keep the last 7 dumps; delete older ones | | **[POST-1]** | SQL dump available for restoration | +### 13.4 Audit log retention purge (cron daily) + +| Tag | Content | +|-----|---------| +| **[TRIGGER]** | Cron: `15 4 * * *` (maintenance window) | +| **[RG-1]** | `DELETE FROM audit_log WHERE created_at < NOW() - INTERVAL :retention_months MONTH` (suggestion: 12 months, legitimate-interest / fiscal traceability — configurable in `.env`). | +| **[RG-2]** | The window is decoupled from user PII lifecycle: anonymisation (10.5) removes PII immediately on request, while the audit trail ages out on its own schedule (dict. note 13). | +| **[POST-1]** | `audit_log` rows older than the retention window removed; recent accountability preserved. | + +### 13.5 login_throttle purge (cron daily) + +| Tag | Content | +|-----|---------| +| **[TRIGGER]** | Cron: `45 4 * * *` (maintenance window) | +| **[RG-1]** | `DELETE FROM login_throttle WHERE (lockout_until IS NULL OR lockout_until < NOW()) AND last_attempt_at < NOW() - INTERVAL 24 HOUR` — purge rows with no active lockout whose last failed attempt is older than 24h. | +| **[RG-2]** | Rows still serving an active lockout are retained; the per-IP counter (S1) is bounded by this purge so the table does not grow unbounded from one-off attempts. | +| **[POST-1]** | Stale `login_throttle` rows removed; active throttles and recent activity preserved. | + --- ## 14. State machine — consistency recap (MLT) diff --git a/docs/uml/security-sequence.md b/docs/uml/security-sequence.md new file mode 100644 index 0000000..1d39c69 --- /dev/null +++ b/docs/uml/security-sequence.md @@ -0,0 +1,232 @@ +# Diagramme de sequence securite - Annulation de commande avec PIN (CANCEL_ORDER) + +**Phase UML** : P1 - Conception, complement UML (passe security-by-design) +**Statut** : v0.2 - flux sensible PIN-gate + audit_log (controle anti-fraude interne) +**Date** : 2026-06-12 +**Branche** : `feat/p1-conception` +**Auteur methodologie** : BYAN + +--- + +## 1. Objet du document + +Ce document decrit le **flux temporel securise** de l'annulation d'une commande +en back-office (`CANCEL_ORDER`). L'annulation est une action de **manipulation +d'argent** : annuler une commande deja `paid` peut servir a masquer un +detournement d'especes (l'equipier encaisse, annule, garde le cash). Le flux +ci-dessous materialise le controle qui adresse ce risque : une **re-authentification +par PIN par equipier** (`RG-T13`) avant l'execution, et l'ecriture d'une ligne +**`audit_log` immuable** dans la meme transaction que l'effet (`RG-T14`), de sorte +que chaque annulation est rattachee a une personne meme sur un poste partage. + +Le diagramme reste au niveau conceptuel / logique. Il nomme les echanges entre +participants sans detailler l'implementation PHP ni le SQL exact. Il complete +l'operation `CANCEL_ORDER` du `docs/merise/mlt.md` (7.1), la transition T5 de +`docs/uml/state-commande.md` (`paid -> cancelled`, re-credit du stock) et le cas +d'utilisation "Annuler une commande" de `docs/uml/use-cases.md` (UC13). + +**Sources** : +- `docs/merise/mlt.md` 7.1 `CANCEL_ORDER` (PRE-1..PRE-3, RG-1..RG-6, POST, ERR) +- `docs/merise/mlt.md` section 2 : `RG-T13` (PIN sensible), `RG-T14` (audit_log), `RG-T11` (re-credit dans la meme transaction), `RG-T07` (garde de concurrence), `RG-T08` (transaction atomique) +- `docs/merise/dictionary.md` 3.14 (`user.pin_hash`, argon2id) et 3.20 (`audit_log`) +- `docs/uml/state-commande.md` T5 (`paid -> cancelled`, `stock_movement` type `cancellation`) + +--- + +## 2. Participants + +| Participant | Role | Couche | +|---|---|---| +| **Equipier** | Counter / Drive / Admin titulaire de `order.cancel`, sur poste partage | Acteur | +| **Admin UI** | Interface back-office (Bloc 3, formulaire d'annulation + saisie PIN) | Presentation | +| **Controleur** | Back-end REST sous `/api/*` (Bloc 2), orchestre la transaction | Application | +| **Verif PIN** | Service de verification du PIN (`password_verify` argon2id) | Application | +| **BDD** | Base de donnees MariaDB | Persistance | + +La session est **partagee par poste de travail** pour le routine 95% ; le PIN +re-introduit une attribution individuelle sur le sous-ensemble sensible +(`mlt.md` `RG-T13`). Le PIN n'est pas une session : il est verifie a chaque action +sensible et sert a capturer le `user_id` qui sera ecrit dans `audit_log`. + +--- + +## 3. Diagramme de sequence + +```mermaid +sequenceDiagram + actor Equipier + participant AdminUI as Admin UI + participant Ctrl as Controleur + participant PIN as Verif PIN + participant BDD + + Note over Equipier,BDD: Phase 1 - Demande et controle de permission + + Equipier->>AdminUI: demander l'annulation d'une commande + AdminUI->>Ctrl: POST /api/orders/{id}/cancel + Ctrl->>Ctrl: verifier session active + is_active = 1 (RG-T02) + Ctrl->>BDD: lire role_permission (permission order.cancel ?) (RG-T03, PRE-1) + BDD-->>Ctrl: permission presente + Ctrl->>BDD: lire customer_order (existe ? statut ?) (PRE-2, PRE-3) + BDD-->>Ctrl: status courant + + alt status hors ['pending_payment','paid'] (delivered ou cancelled) + Ctrl-->>AdminUI: 422 CANNOT_CANCEL_IN_STATE {current_status} + AdminUI-->>Equipier: refus, aucun changement d'etat (ERR-1) + else status annulable + Note over Equipier,BDD: Phase 2 - Re-authentification PIN (RG-T13) + + Ctrl-->>AdminUI: demander la saisie du PIN + Equipier->>AdminUI: saisir le PIN + AdminUI->>Ctrl: soumettre le PIN (re-auth action sensible) + Ctrl->>PIN: verifier le PIN soumis + PIN->>BDD: lire user.pin_hash de l'equipier + BDD-->>PIN: pin_hash (argon2id) + PIN->>PIN: password_verify(pin, pin_hash) + + alt PIN incorrect + PIN-->>Ctrl: echec + Ctrl-->>AdminUI: refus du PIN, action rejetee + AdminUI-->>Equipier: PIN incorrect, aucun changement d'etat + else PIN correct + PIN-->>Ctrl: succes, acting user_id capture (RG-T13) + + Note over Equipier,BDD: Phase 3 - Transaction atomique (RG-T08, RG-T11) + + Ctrl->>BDD: BEGIN transaction + Ctrl->>BDD: UPDATE customer_order SET status = 'cancelled', cancelled_at = NOW() WHERE id = :id AND status IN ('pending_payment','paid') (RG-1, RG-T07) + BDD-->>Ctrl: lignes affectees + + alt 0 ligne affectee (annulation concurrente) + Ctrl->>BDD: ROLLBACK + Ctrl-->>AdminUI: 409 INVALID_TRANSITION (ERR-2) + AdminUI-->>Equipier: deja annulee par un autre poste + else 1 ligne affectee + opt statut anterieur = paid (re-credit du stock) + Ctrl->>BDD: UPDATE ingredient SET stock_quantity = stock_quantity + :units (par ingredient consomme) (RG-3) + Ctrl->>BDD: INSERT stock_movement (type cancellation, delta +units, order_id, user_id de l'equipier) (RG-3) + end + Ctrl->>BDD: INSERT audit_log (action_code order.cancel, actor_user_id, actor_role_id, entity_type customer_order, entity_id, summary [statut anterieur + montant re-credite]) (RG-6, RG-T14) + Ctrl->>BDD: COMMIT + BDD-->>Ctrl: transaction validee + Ctrl-->>AdminUI: 200 annulation confirmee (OUT-1) + AdminUI-->>Equipier: commande annulee, trace enregistree + end + end + end +``` + +--- + +## 4. Notes de modelisation : chaque pas et sa regle + +Le tableau ci-dessous mappe chaque interaction du diagramme a la regle +`mlt.md` 7.1 ou a la regle transverse correspondante, et a l'entite ecrite. + +| # | Interaction | Regle (mlt.md) | Entite ecrite / lue | +|---|---|---|---| +| 1 | Verifier session active + `is_active = 1` | `RG-T02` | `user` (lecture) | +| 2 | Verifier `order.cancel` via `role_permission` | `RG-T03`, 7.1 PRE-1 | `role_permission` (lecture) | +| 3 | Charger la commande et lire son `status` | 7.1 PRE-2, PRE-3 | `customer_order` (lecture) | +| 4 | Bloquer si `status` est `delivered` ou `cancelled` | 7.1 ERR-1 | aucune ecriture (HTTP 422) | +| 5 | Demander + verifier le PIN (`password_verify` argon2id) | `RG-T13`, 7.1 RG-6 | `user.pin_hash` (lecture) | +| 6 | Rejeter si PIN incorrect, sans changement d'etat | `RG-T13` | aucune ecriture | +| 7 | Capturer l'`acting user_id` pour l'audit | `RG-T13` | (en memoire, sert aux pas 11-12) | +| 8 | `BEGIN` transaction | `RG-T08` | transaction | +| 9 | `UPDATE customer_order SET status='cancelled'` avec garde `AND status IN (...)` | 7.1 RG-1, `RG-T07` | `customer_order` (ecriture) | +| 10 | `ROLLBACK` + 409 si 0 ligne affectee (concurrence) | 7.1 ERR-2, `RG-T07` | aucune ecriture nette | +| 11 | Re-credit conditionnel du stock si statut anterieur `paid` | 7.1 RG-3, `RG-T11` | `ingredient`, `stock_movement` (type `cancellation`) | +| 12 | `INSERT audit_log` dans la meme transaction | 7.1 RG-6, `RG-T14` | `audit_log` (ecriture) | +| 13 | `COMMIT` (tout ou rien) | `RG-T08`, `RG-T11` | transaction | +| 14 | Reponse 200 de confirmation | 7.1 OUT-1 | aucune ecriture | + +### 4.1 Re-credit conditionnel du stock (`RG-T11`) + +Le re-credit ne s'applique que si la commande etait au statut `paid` avant +l'annulation (7.1 RG-3). Une commande `pending_payment` n'avait pas encore +decremente le stock (le decrement a lieu a la transition `paid`), donc il n'y a +rien a re-crediter. Pour chaque `order_item` d'une commande `paid`, les unites +consommees sont recalculees (format `normal`/`maxi`, ajustees par les +`order_item_modifier`), `ingredient.stock_quantity` est re-incremente et un +`stock_movement` de type `cancellation` est insere. `RG-T11` garantit que ce +re-credit et l'`UPDATE` du statut sont dans la **meme transaction** : il n'y a pas +de decrement orphelin si une etape echoue. + +### 4.2 Garde de concurrence (`RG-T07`) + +L'`UPDATE` porte la clause `AND status IN ('pending_payment','paid')`. Si deux +postes tentent d'annuler la meme commande au meme instant, seul le premier +obtient une ligne affectee ; le second recoit 0 ligne et le controleur repond +409 `INVALID_TRANSITION` apres `ROLLBACK` (7.1 ERR-2). Cette garde optimiste +reduit le risque d'une double annulation et d'un double re-credit du stock. + +### 4.3 PIN distinct de la session (`RG-T13`) + +La session reste **partagee par poste** pour le flux routine. Le PIN est verifie +a chaque action du sous-ensemble sensible (annulation, prix/VAT, RBAC, gestion +utilisateur, correction d'inventaire), et c'est lui qui fournit l'`actor_user_id` +ecrit dans `audit_log`. Le `pin_hash` est un hash argon2id (`dictionary.md` 3.14), +compare via `password_verify` ; il fait partie des champs RESTRICTED tenus hors +des logs et des reponses API. + +--- + +## 5. Menace adressee : repudiation et detournement d'especes + +L'annulation d'une commande `paid` est le geste qui permet le schema de fraude +"encaisser puis annuler pour garder le cash" (insider cash-skim). Sans controle, +sur un poste a session partagee, une annulation ne serait rattachee a personne : +l'auteur pourrait nier l'avoir faite (repudiation). Le flux ci-dessus reduit le +risque de ce schema en combinant deux mecanismes concrets : + +- **PIN par equipier (`RG-T13`)** : l'annulation exige une re-authentification + individuelle. Sur un poste partage, cela rattache l'acte a une personne et non + au seul poste. Le PIN tend a dissuader l'usage opportuniste d'une session + laissee ouverte par un collegue. +- **`audit_log` immuable (`RG-T14`)** : chaque annulation ecrit une ligne + `audit_log` (`action_code = order.cancel`, `actor_user_id`, `actor_role_id`, + `entity_type`, `entity_id`, `summary` avec le statut anterieur et le montant + re-credite) dans la **meme transaction** que l'`UPDATE` du statut. La table + n'accepte ni `UPDATE` ni `DELETE` au niveau applicatif (`dictionary.md` 3.20). + Une annulation ne peut donc pas exister sans sa trace, et la trace ne peut pas + etre effacee par l'auteur. + +L'effet combine : un pic d'annulations rattachees a un meme `actor_user_id` +devient visible et opposable lors d'une revue. Ceci ne supprime pas le risque, +mais le **reduit** en transformant un acte anonyme et niable en un acte attribue +et trace. La residualite (collusion, partage de PIN) releve de controles +organisationnels hors du modele de donnees. + +> Note : `audit_log` enregistre des **noms de champs** et un `summary` +> non-personnel (`details` stocke les noms de champs modifies, pas de PII), +> conformement a `RG-T14` et a la classification de `PROJECT_CONTEXT.md` section 19. +> L'attribution `stock_movement.user_id` du re-credit complete la trace cote stock +> sans double journalisation. + +--- + +## 6. Coherence avec les autres livrables + +| Verification | Resultat | +|---|---| +| Statuts annulables coherents avec `state-commande.md` | Oui : `pending_payment` et `paid` (T3, T5) ; `delivered` non annulable (7.1 ERR-1) | +| Transition `paid -> cancelled` avec re-credit | Couverte par T5 et 7.1 RG-3 (`stock_movement` type `cancellation`) | +| Entites ecrites presentes au dictionnaire | `customer_order` (3.10), `ingredient`, `stock_movement`, `audit_log` (3.20) | +| Regle PIN appliquee | `RG-T13` (sous-ensemble sensible inclut 7.1) ; `user.pin_hash` (3.14) | +| Regle audit appliquee | `RG-T14` ; colonnes conformes a `audit_log` (3.20) | +| Atomicite re-credit + statut + audit | `RG-T08` + `RG-T11` (une transaction, `COMMIT` / `ROLLBACK`) | +| Codes d'erreur | 422 `CANNOT_CANCEL_IN_STATE` (ERR-1), 409 `INVALID_TRANSITION` (ERR-2) | + +--- + +## 7. Arbitrage tranche + +Le flux retient la re-authentification **par PIN** plutot qu'une re-saisie du mot +de passe complet : le PIN couvre le sous-ensemble sensible sans casser le routine +95% a session partagee (`RG-T13`), tout en fournissant l'attribution +individuelle. L'`audit_log` est ecrit dans la **meme transaction** que l'effet +(`RG-T14` + `RG-T08`) : une annulation sans trace ne peut pas etre committee. Le +re-credit du stock est conditionnel au statut anterieur `paid` (`RG-T11`), ce qui +ecarte un re-credit indu sur une commande `pending_payment` qui n'a pas ete +decrementee. Les codes d'erreur (`CANNOT_CANCEL_IN_STATE`, `INVALID_TRANSITION`) +reprennent ceux de `mlt.md` 7.1, sans en inventer de nouveaux. diff --git a/docs/uml/sequence-passer-commande.md b/docs/uml/sequence-passer-commande.md index 3b00cfe..9ffd0a8 100644 --- a/docs/uml/sequence-passer-commande.md +++ b/docs/uml/sequence-passer-commande.md @@ -1,8 +1,8 @@ # Diagramme de sequence - Passer une commande (borne client) **Phase UML** : P1 - Conception, complement UML (apres MCD) -**Statut** : v0.1 -**Date** : 2026-05-21 +**Statut** : v0.2 - prod-like, creation atomique (create + pay) +**Date** : 2026-06-11 **Branche** : `feat/p1-conception` **Auteur methodologie** : BYAN @@ -12,18 +12,24 @@ Ce document decrit le **flux temporel** du parcours "passer une commande" cote **Client sur la borne kiosk** : navigation dans les categories, selection d'un -produit ou composition d'un menu, gestion du panier, validation avec saisie du -numero de retrait, paiement, puis confirmation. +produit ou composition d'un menu (slots + format Normal/Maxi + modifiers +d'ingredients), gestion du panier, validation avec saisie du numero de retrait, +et confirmation. Dans le modele v0.2, la **creation et le paiement sont +atomiques** : un seul appel `POST /api/orders` cree la commande, la fait passer a +`paid`, decremente le stock et journalise les mouvements, dans une meme +transaction. -Le diagramme reste au niveau **conceptuel / logique**. Il nomme les echanges -entre participants sans detailler l'implementation PHP (controllers, models) -ni le SQL exact. Il complete le cas d'utilisation "Passer une commande" de -`docs/uml/use-cases.md` et la machine a etats de `docs/uml/state-commande.md`. +Le diagramme reste au niveau conceptuel / logique. Il nomme les echanges entre +participants sans detailler l'implementation PHP ni le SQL exact. Il complete le +cas d'utilisation "Passer une commande" de `docs/uml/use-cases.md` (4.1), la +machine a etats de `docs/uml/state-commande.md` (T1/T2) et l'operation +`CREATE_ORDER` du `docs/merise/mct.md` (3.3). **Sources** : - `docs/PROJECT_CONTEXT.md` section 2 (processus metier), section 7 (endpoints API) -- `docs/merise/dictionary.md` (`commande`, `ligne_commande`, `menu`, `produit`) -- `docs/uml/state-commande.md` (transitions `pending_payment -> paid`) +- `docs/merise/dictionary.md` 3.10-3.13 (`customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`) +- `docs/merise/mct.md` 3.3 CREATE_ORDER (transaction, snapshots, decrement stock) +- `docs/uml/state-commande.md` (transitions T1/T2, atomicite `pending_payment -> paid`) --- @@ -71,16 +77,18 @@ sequenceDiagram alt Produit a la carte Client->>Borne: selectionner un produit - Client->>Borne: regler taille / options + opt Personnaliser les ingredients + Client->>Borne: retirer / ajouter un ingredient + end Borne->>Borne: ajouter la ligne au panier local else Composition d'un menu Client->>Borne: selectionner un menu - Borne->>API: GET /api/menus (composition du menu) - API->>BDD: lire menu et composition - BDD-->>API: menu + produits par role + Borne->>API: GET /api/menus (slots + options eligibles) + API->>BDD: lire menu, menu_slot, menu_slot_option + BDD-->>API: menu + slots + options API-->>Borne: composition (JSON) - Borne-->>Client: afficher les choix par slot (burger, accompagnement, boisson, sauce) - Client->>Borne: choisir chaque composant + tailles + Borne-->>Client: afficher les slots (boisson, accompagnement, sauce) + format Normal/Maxi + Client->>Borne: choisir chaque slot + format + modifiers du burger Borne->>Borne: ajouter la ligne menu au panier local end @@ -94,36 +102,35 @@ sequenceDiagram Borne-->>Client: panier mis a jour end - Note over Client,BDD: Phase 4 - Validation du panier et saisie du numero + Note over Client,BDD: Phase 4 - Validation, saisie du numero, creation atomique (create + pay) Client->>Borne: valider la commande Client->>Borne: saisir le numero de retrait - Borne->>Borne: valider le panier (au moins 1 ligne) - Borne->>API: POST /api/orders (lignes + mode_consommation + numero) + Borne->>Borne: valider le panier (au moins 1 ligne, numero non vide) + Borne->>API: POST /api/orders (lignes + selections + modifiers + service_mode + numero) - API->>API: recalculer les totaux cote serveur - API->>BDD: creer la commande (statut pending_payment) - API->>BDD: creer les lignes (snapshot libelle + prix) - BDD-->>API: commande persistee {id, numero, statut: pending_payment} - API-->>Borne: 201 Created {id, numero, statut: pending_payment, total} - Borne-->>Client: afficher le total a regler + API->>API: recalculer les totaux cote serveur (HT / TVA / TTC, taux par produit) + API->>BDD: BEGIN transaction + API->>BDD: INSERT customer_order (status pending_payment, source kiosk) + API->>BDD: INSERT order_item (snapshot libelle + prix + vat_rate) + API->>BDD: INSERT order_item_selection (par slot de menu rempli) + API->>BDD: INSERT order_item_modifier (par modification d'ingredient) + API->>BDD: UPDATE ingredient.stock_quantity (decrement, ajuste par modifiers) + API->>BDD: INSERT stock_movement (type sale, par unite consommee) + API->>BDD: UPDATE customer_order status -> paid, paid_at = NOW() + API->>BDD: COMMIT + BDD-->>API: commande persistee {id, order_number, status: paid} - Note over Client,BDD: Phase 5 - Paiement (pending_payment -> paid) + Note over Client,BDD: Phase 5 - Confirmation - Client->>Borne: payer la commande - Borne->>API: POST /api/orders/{id}/pay - API->>BDD: enregistrer le paiement, passer la commande a paid (paye_a) - BDD-->>API: commande mise a jour {id, numero, statut: paid} - - Note over Client,BDD: Phase 6 - Confirmation - - API-->>Borne: 200 OK {id, numero, statut: paid} - Borne-->>Client: ecran de confirmation avec le numero + API-->>Borne: 201 Created {id, order_number, status: paid, total_ttc} + Borne-->>Client: ecran de confirmation avec le numero de retrait Note over Client,BDD: Cas d'erreur - alt Panier vide ou donnees invalides - API-->>Borne: 4xx {error: code, message} + alt Panier vide, produit indisponible ou donnees invalides + API->>BDD: ROLLBACK (si transaction entamee) + API-->>Borne: 4xx {error: {code, message}} Borne-->>Client: message d'erreur, retour au panier end ``` @@ -132,27 +139,31 @@ sequenceDiagram ## 4. Notes de modelisation -### 4.1 Recalcul des totaux cote serveur +### 4.1 Recalcul des totaux cote serveur (controle de securite) La Borne affiche un total **provisoire** calcule localement pour l'experience utilisateur. L'API recalcule les totaux a la reception du `POST /api/orders` a -partir des prix en base, puis fige les snapshots -(`prix_unitaire_ttc_cents_snapshot`, `libelle_snapshot` dans `ligne_commande`, -voir `dictionary.md` 3.6). Le total affiche par le client n'est pas considere -comme la source de verite : ceci limite la falsification du prix cote client. +partir des prix en base (HT, TVA ligne par ligne via `vat_rate`, TTC), puis fige +les snapshots (`unit_price_cents_snapshot`, `vat_rate_snapshot`, +`label_snapshot` sur `order_item`, voir `dictionary.md` 3.11). Le total affiche +par le client n'est pas considere comme la source de verite : ceci limite la +falsification du prix cote client. -### 4.2 Transitions de statut +### 4.2 Creation atomique (create + pay) Le parcours materialise les transitions T1 et T2 de -`docs/uml/state-commande.md`, en deux phases successives conformes a la regle -metier : +`docs/uml/state-commande.md` dans **un seul appel et une seule transaction** : -- `POST /api/orders` cree la commande composee en `pending_payment` (T1). -- `POST /api/orders/{id}/pay` enregistre le paiement et fait passer la commande - a `paid` (T2), avec l'horodatage `paye_a`. +- `POST /api/orders` cree la commande en `pending_payment` (T1) puis la fait + passer a `paid` (T2) avant le `COMMIT`. `paid_at` est renseigne. +- La saisie du numero de retrait tient lieu de paiement (cadre RNCP) ; il n'y a + pas d'appel `POST /api/orders/{id}/pay` separe (supprime par rapport au v0.1). +- Le decrement du stock (`ingredient.stock_quantity`) et la journalisation + (`stock_movement` type `sale`) sont inclus dans la meme transaction que + l'insert de la commande : soit tout reussit, soit tout est annule (`ROLLBACK`). -La separation des deux appels reflete les deux phases du cycle de vie : -composer la commande, puis la payer. +Le statut `pending_payment` n'est donc pas observable en dehors de la +transaction (coherent avec `mct.md` section 13). ### 4.3 Panier local jusqu'a la validation @@ -166,9 +177,20 @@ navigateur peut etre envisage plus tard. `PROJECT_CONTEXT.md` section 4 prevoit un mode de repli ou la Borne lit des fichiers JSON statiques si l'API est indisponible. Ce mode concerne uniquement -les lectures (phases 1 a 2). La validation (phase 4) et le paiement (phase 5) -requierent l'API ; sans elle, la commande n'est ni persistee ni payee. Ce cas -degrade n'est pas detaille dans le diagramme nominal ci-dessus. +les lectures (phases 1 a 2). La validation et la creation (phase 4) requierent +l'API ; sans elle, la commande n'est ni persistee ni payee. Ce cas degrade +n'est pas detaille dans le diagramme nominal ci-dessus. + +### 4.5 Garde-fous securite a venir (passe security-by-design) + +Le flux ci-dessus est la cible **fonctionnelle** v0.2. La passe security-by-design +ajoutera, en complement (append, sans reecrire ce flux), des garde-fous sur le +`POST /api/orders` anonyme : cle d'idempotence (`idempotency_key` UNIQUE pour +dedupliquer les POST rejoues), limitation de debit / anti-spam, et verrou +pessimiste `SELECT ... FOR UPDATE` sur les ingredients pendant le decrement +(anti-oversell multi-bornes). Ces ajouts dependent de decisions encore a +trancher (oversell/idempotence, throttling) et seront documentes dans un artefact +`docs/uml/security-sequence.md` dedie. --- @@ -176,18 +198,21 @@ degrade n'est pas detaille dans le diagramme nominal ci-dessus. | Verification | Resultat | |---|---| -| Endpoints utilises existent dans `PROJECT_CONTEXT.md` section 7 | `GET /api/categories`, `GET /api/products`, `GET /api/menus`, `POST /api/orders` ; `POST /api/orders/{id}/pay` est a confirmer en section 7 du brief | -| Entites manipulees presentes au MCD | Oui : `categorie`, `produit`, `menu`, `menu_produit`, `commande`, `ligne_commande` | -| Statuts utilises coherents avec `state-commande.md` | Oui : `pending_payment` puis `paid` (T1, T2), valeurs ENUM anglaises | -| Format de reponse JSON | Coherent avec `PROJECT_CONTEXT.md` section 7 (`{data, error}`) et la reponse `{id, number, status}` du POST orders | +| Endpoints utilises existent dans `PROJECT_CONTEXT.md` section 7 | `GET /api/categories`, `GET /api/products`, `GET /api/menus`, `POST /api/orders` ; l'appel `POST /api/orders/{id}/pay` du v0.1 est supprime (creation atomique) | +| Entites manipulees presentes au MCD / dictionnaire | Oui : `category`, `product`, `menu`, `menu_slot`, `menu_slot_option`, `ingredient`, `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `stock_movement` | +| Statuts utilises coherents avec `state-commande.md` | Oui : `pending_payment` puis `paid` (T1, T2), atomiques | +| Operation MCT correspondante | `mct.md` 3.3 CREATE_ORDER (transaction unique, snapshots, decrement stock, transition atomique) | +| Format de reponse JSON | Coherent avec `PROJECT_CONTEXT.md` section 7 (`{data, error}`) et la reponse `{id, order_number, status, total_ttc}` du POST orders | --- ## 6. Arbitrage tranche -La phase de paiement est integree au flux conformement a la regle metier des -deux phases (composer puis payer). La sequence suit la machine canonique de -`state-commande.md` : creation en `pending_payment` (T1) puis paiement vers -`paid` (T2), avec des valeurs ENUM en anglais. Point a confirmer au MCT : -l'endpoint de paiement (`POST /api/orders/{id}/pay`) doit etre reporte dans la -section 7 du brief s'il n'y figure pas encore. +La phase de paiement separee du v0.1 (`POST /api/orders/{id}/pay`) est supprimee : +la creation et le passage a `paid` sont atomiques dans `POST /api/orders`, +conformement au MCT v0.2 (3.3) et a la regle metier (saisie du numero = substitut +de paiement). Le decrement de stock et la journalisation `stock_movement` sont +inclus dans la meme transaction, garantissant la coherence stock/commande. Les +valeurs ENUM sont en anglais (`pending_payment`, `paid`). Les garde-fous de +securite (idempotence, rate-limit, verrou pessimiste) relevent de la passe +security-by-design et seront ajoutes en complement (section 4.5). diff --git a/docs/uml/state-commande.md b/docs/uml/state-commande.md index a99f309..55c38cd 100644 --- a/docs/uml/state-commande.md +++ b/docs/uml/state-commande.md @@ -1,8 +1,8 @@ # Diagramme d'etats-transitions - Commande **Phase UML** : P1 - Conception, complement UML (apres MCD) -**Statut** : v0.1 -**Date** : 2026-05-21 +**Statut** : v0.2 - prod-like, machine a 4 etats +**Date** : 2026-06-11 **Branche** : `feat/p1-conception` **Auteur methodologie** : BYAN @@ -10,38 +10,41 @@ ## 1. Objet du document -Ce document formalise la **machine a etats** de l'attribut `commande.statut`. +Ce document formalise la **machine a etats** de l'attribut `customer_order.status`. Il decrit les etats possibles d'une commande, les transitions autorisees entre ces etats, les **evenements** qui les declenchent et les **gardes** (conditions) qui les conditionnent. -Il complete le MCD (`docs/merise/mcd.md` section 9, qui esquisse le cycle de -vie) et le dictionnaire (`docs/merise/dictionary.md` 3.5, qui declare l'ENUM). +Il complete le MCD (`docs/merise/mcd.md`, cycle de vie de la commande), le +dictionnaire (`docs/merise/dictionary.md` 3.10, qui declare l'ENUM `status`) et +le MCT (`docs/merise/mct.md` section 13, qui resume les transitions par +operation). --- ## 2. Source de verite et regle metier -La regle metier confirmee fixe deux phases successives dans le cycle de vie -d'une commande : le client **compose** sa commande, **puis** il **paie**. Une -fois payee, la commande entre en preparation. Le paiement fait partie integrante -du cycle. Les valeurs d'etat sont en anglais et alignees sur l'ENUM du -dictionnaire. +Le modele v0.2 (prod-like) reduit la machine a **quatre etats**. La regle metier +distingue la **composition payee** de la **remise** : une commande est creee et +payee en une operation atomique (la saisie du numero de retrait tient lieu de +paiement dans le cadre RNCP), puis elle est remise au client en un geste unique. | Source | Valeurs de statut | |---|---| -| `dictionary.md` 3.5 (ENUM SQL) | `pending_payment`, `paid`, `preparing`, `ready`, `delivered`, `cancelled` | -| Regle metier confirmee | composer -> payer -> preparer -> pret -> remettre | +| `dictionary.md` 3.10 (ENUM SQL) | `pending_payment`, `paid`, `delivered`, `cancelled` | +| `mct.md` section 13 (transitions) | creer+payer -> remettre, annulation depuis tout etat non terminal | -**Machine a etats canonique** : la machine ci-dessous est la seule autorisee. -Elle suit l'ENUM du dictionnaire et la regle metier des deux phases : +> Le dictionnaire (`dictionary.md` 3.10) et la machine ci-dessous partagent la +> meme ENUM a 4 valeurs, ce qui maintient la coherence entre le modele de +> donnees et le modele d'etats (cross-validation, mantra #34). -- `pending_payment` : commande composee, en attente de paiement. -- `paid` : paiement effectue ; la commande peut entrer en file de preparation. - -> Le dictionnaire (`dictionary.md` 3.5) et la machine ci-dessous partagent la -> meme ENUM, ce qui maintient la coherence entre le modele de donnees et le -> modele d'etats (cross-validation, mantra #34). +**Etats supprimes par rapport au v0.1** : `preparing` et `ready`. En contexte +fast-food, l'affichage cuisine (KDS) est un dispositif visuel : l'equipier lit +le ticket et agit. Ces deux etats intermediaires ajoutaient des transitions sans +valeur metier proportionnelle. La cuisine est en **lecture seule** ; la remise +(`DELIVER_ORDER`) est le geste unique qui fait avancer le statut. Le KPI est le +temps total `delivered_at - paid_at` (SLA ~10 min) ; la couleur du KDS est +calculee a l'affichage depuis `now - paid_at`, sans etat stocke supplementaire. --- @@ -49,12 +52,16 @@ Elle suit l'ENUM du dictionnaire et la regle metier des deux phases : | Etat | Valeur ENUM | Signification | Acteur qui declenche l'entree | |---|---|---|---| -| En attente de paiement | `pending_payment` | Commande composee, panier fige, en attente de paiement. | Client (kiosk) ou Accueil (counter/drive) | -| Payee | `paid` | Paiement effectue ; la commande peut entrer en file de preparation. | Client (paiement) ou Accueil | -| En preparation | `preparing` | Prise en charge par la Preparation, en cuisine. | Preparation | -| Prete | `ready` | Preparation terminee, prete au comptoir. | Preparation | -| Livree | `delivered` | Remise effectuee au client. Etat **final**. | Accueil | -| Annulee | `cancelled` | Commande abandonnee ou annulee. Etat **final**. | Client, Accueil ou Administration | +| En attente de paiement | `pending_payment` | Etat initial transitoire : commande composee, en attente de paiement. Non observable hors transaction (voir section 7). | Client (kiosk) ou Counter/Drive (back-office) | +| Payee | `paid` | Paiement effectue ; la commande entre en file de preparation (lecture seule cuisine). | Client (kiosk) ou Counter/Drive | +| Livree | `delivered` | Remise effectuee au client. Etat **final**. | Counter ou Drive | +| Annulee | `cancelled` | Commande abandonnee ou annulee avant remise. Etat **final**. | Counter, Drive ou Admin | + +`pending_payment` est l'etat par defaut a l'INSERT (`dictionary.md` 3.10), mais +la transition vers `paid` est realisee dans la **meme transaction** que la +creation (operations `CREATE_ORDER` / `CREATE_COUNTER_ORDER` du MCT). Il est donc +conserve dans l'ENUM pour la lisibilite du cycle et pour laisser la porte +ouverte a un paiement reel ulterieur sans migration destructive. --- @@ -62,19 +69,13 @@ Elle suit l'ENUM du dictionnaire et la regle metier des deux phases : ```mermaid stateDiagram-v2 - [*] --> pending_payment : creer commande (kiosk / counter / drive) + [*] --> pending_payment : creer la commande (kiosk / counter / drive) - pending_payment --> paid : payer\n[panier contient au moins 1 ligne] - pending_payment --> cancelled : abandonner\n[avant paiement] + pending_payment --> paid : payer\n[atomique dans CREATE_ORDER / CREATE_COUNTER_ORDER\nsaisie du numero = substitut de paiement] + pending_payment --> cancelled : annuler\n[avant remise] - paid --> preparing : prendre en charge\n[acteur Preparation, file triee par heure croissante] - paid --> cancelled : annuler\n[Accueil ou Administration] - - preparing --> ready : declarer preparee\n[acteur Preparation] - preparing --> cancelled : annuler\n[rupture produit / decision Administration] - - ready --> delivered : remettre au client\n[acteur Accueil] - ready --> cancelled : annuler\n[client absent / non recuperee] + paid --> delivered : remettre au client\n[acteur Counter / Drive, geste unique] + paid --> cancelled : annuler\n[Counter / Drive / Admin, re-credit du stock] delivered --> [*] cancelled --> [*] @@ -86,29 +87,29 @@ stateDiagram-v2 | # | De | Vers | Evenement declencheur | Garde (condition) | Acteur | |---|---|---|---|---|---| -| T1 | (initial) | `pending_payment` | Creation de la commande composee | Au moins un item ajoute au panier en cours | Client / Accueil | -| T2 | `pending_payment` | `paid` | Paiement de la commande | La commande contient au moins une `ligne_commande` ; le paiement aboutit | Client / Accueil | -| T3 | `pending_payment` | `cancelled` | Abandon avant paiement | Commande pas encore payee | Client / Accueil | -| T4 | `paid` | `preparing` | Prise en charge en file | La commande est la plus ancienne non traitee (tri par heure de livraison croissante) | Preparation | -| T5 | `paid` | `cancelled` | Annulation avant preparation | Decision operationnelle | Accueil / Administration | -| T6 | `preparing` | `ready` | Declaration "preparee" | Preparation terminee | Preparation | -| T7 | `preparing` | `cancelled` | Annulation pendant preparation | Rupture produit ou decision Administration | Preparation / Administration | -| T8 | `ready` | `delivered` | Remise physique au client | Le client se presente avec le bon numero | Accueil | -| T9 | `ready` | `cancelled` | Annulation apres preparation | Client non present / commande non recuperee | Accueil / Administration | +| T1 | (initial) | `pending_payment` | Creation de la commande composee | Au moins une ligne (`order_item`) ; numero de retrait non vide | Client / Counter / Drive | +| T2 | `pending_payment` | `paid` | Paiement (atomique a la creation) | La commande contient au moins une `order_item` ; decrement du stock et insert dans la meme transaction | Client / Counter / Drive | +| T3 | `pending_payment` | `cancelled` | Abandon avant paiement | Commande pas encore payee | Counter / Drive / Admin | +| T4 | `paid` | `delivered` | Remise physique au client | La commande est `paid` ; l'acteur detient `order.deliver` ; source compatible avec son role (`role_visible_source`) | Counter / Drive | +| T5 | `paid` | `cancelled` | Annulation avant remise | L'acteur detient `order.cancel` ; le stock consomme est re-credite (`stock_movement` type `cancellation`) | Counter / Drive / Admin | ### Invariants de la machine a etats - `delivered` et `cancelled` sont des etats **finaux** : aucune transition n'en sort. -- Aucune transition ne revient en arriere (pas de `preparing -> paid`). Une - erreur operationnelle se traite par annulation puis nouvelle commande, pour - preserver l'integrite de l'historique et des snapshots de prix. -- La transition vers `cancelled` est possible depuis tous les etats **sauf** - `delivered` (une commande remise ne s'annule pas dans ce modele). Ceci est - coherent avec `mcd.md` section 9 : "Annuler : transition vers `cancelled` - (depuis tout statut sauf `delivered`)". -- `paye_a` (DATETIME, `dictionary.md` 3.5) est renseigne au moment de la - transition T2 (`pending_payment -> paid`) et reste NULL avant. +- Aucune transition ne revient en arriere. Une erreur operationnelle se traite + par annulation puis nouvelle commande, pour preserver l'integrite de + l'historique et des snapshots (`label_snapshot`, `unit_price_cents_snapshot`, + `vat_rate_snapshot` sur `order_item`). +- La transition vers `cancelled` est possible depuis `pending_payment` et + `paid`, mais pas depuis `delivered` (une commande remise ne s'annule pas dans + ce modele). Coherent avec `mct.md` 7.1 (`CANCEL_ORDER`). +- `paid_at` (DATETIME, `dictionary.md` 3.10) est renseigne a la transition T2. + `delivered_at` est renseigne a T4. `cancelled_at` est renseigne a T3/T5. Les + trois colonnes sont NULL tant que la transition correspondante n'a pas eu lieu. +- La cuisine (`kitchen`) ne declenche aucune transition : son acces a la file + des commandes `paid` est en **lecture seule** (`mct.md` 5.1, + `LIST_ORDERS_DISPLAY`). --- @@ -116,29 +117,36 @@ stateDiagram-v2 | Verification | Resultat | |---|---| -| Tous les etats du diagramme existent dans l'ENUM `dictionary.md` 3.5 | Oui (6 valeurs, toutes utilisees) | -| La regle "annulation possible sauf depuis delivered" de `mcd.md` 9 | Respectee (T5, T7, T9 ; pas de transition depuis `delivered`) | -| Cycle de vie esquisse dans `mcd.md` 9 | Couvert : `pending_payment` -> `paid` (payer), `paid` -> `preparing` (preparer), `preparing` -> `ready` (marquer pret), `ready` -> `delivered` (remettre) | -| Acteurs de `use-cases.md` | Preparation declenche T4/T6/T7 ; Accueil declenche T8/T9 ; Administration peut annuler | +| Tous les etats du diagramme existent dans l'ENUM `dictionary.md` 3.10 | Oui (4 valeurs, toutes utilisees) | +| La regle "annulation possible sauf depuis delivered" de `mct.md` 7.1 | Respectee (T3, T5 ; pas de transition depuis `delivered`) | +| Transition `paid -> delivered` en geste unique de `mct.md` 6.1 | Couvert par T4 (`DELIVER_ORDER`) | +| Atomicite `pending_payment -> paid` de `mct.md` 3.3 / 4.1 / section 13 | Couvert par T2 (saisie du numero = substitut de paiement) | +| Acteurs de `use-cases.md` | Counter/Drive declenchent T4/T5 ; Admin peut annuler (T3/T5) ; Kitchen en lecture seule | +| Timestamps de phase `paid_at` / `delivered_at` / `cancelled_at` | Renseignes a T2 / T4 / T3-T5 | --- ## 7. Arbitrage tranche -La divergence historique entre l'ENUM du dictionnaire et un parcours sans -paiement est resolue par la regle metier confirmee : le cycle de vie comporte -deux phases successives, la composition de la commande puis son paiement. Le -paiement fait partie integrante du cycle. - -La machine canonique retenue est donc : +La machine retenue est reduite a quatre etats (Decision 4, +`docs/notes/revue-alignement-p1.md` section 7) : ``` -pending_payment -> paid -> preparing -> ready -> delivered - (cancelled atteignable depuis pending_payment, paid, preparing) +pending_payment -> paid -> delivered + | | + +------------+--------> cancelled (depuis pending_payment ou paid) ``` -Cette machine est la source de verite partagee par `dictionary.md` 3.5, -`use-cases.md` (cas "Payer la commande" cote Client) et -`sequence-passer-commande.md` (etape paiement entre validation du panier et -confirmation). La colonne `paye_a` est renseignee a la transition T2. A -revalider lors du MCT. +**Note sur la transition `pending_payment -> paid`** : dans le cadre RNCP, le +paiement est remplace par la saisie du numero de commande (kiosk) ou par la +validation de l'equipier (counter/drive). La transition est **atomique** dans +`CREATE_ORDER` et `CREATE_COUNTER_ORDER` : le statut `pending_payment` n'est pas +observable en dehors de la transaction de creation. Il reste declare dans l'ENUM +pour exprimer le cycle de vie complet et pour autoriser un paiement reel +ulterieur (cout d'une valeur d'ENUM). + +Les etats `preparing` et `ready` du v0.1 sont supprimes : la cuisine est un +affichage visuel en lecture seule, et la remise fusionne preparation+remise en +un geste unique (`DELIVER_ORDER`). Effet de bord propage : les operations +`MARK_IN_PREPARATION` et `MARK_READY` disparaissent du MCT (voir `mct.md` +sections 1 et 13). diff --git a/docs/uml/use-cases.md b/docs/uml/use-cases.md index c9897be..3a615a7 100644 --- a/docs/uml/use-cases.md +++ b/docs/uml/use-cases.md @@ -1,8 +1,8 @@ # Diagramme de cas d'utilisation - Wakdo **Phase UML** : P1 - Conception, complement UML (apres MCD) -**Statut** : v0.1 -**Date** : 2026-05-21 +**Statut** : v0.2 - prod-like, 5 roles RBAC + catalogue de 23 permissions +**Date** : 2026-06-11 **Branche** : `feat/p1-conception` **Auteur methodologie** : BYAN @@ -12,174 +12,247 @@ Ce document recense les **cas d'utilisation** de Wakdo, c'est-a-dire les fonctionnalites observables du systeme du point de vue de ses acteurs. Il -complete le MCD (`docs/merise/mcd.md`) et le dictionnaire -(`docs/merise/dictionary.md`) en passant de la vue **donnees** a la vue -**usages**. +complete le MCD (`docs/merise/mcd.md`), le dictionnaire +(`docs/merise/dictionary.md`) et le MCT (`docs/merise/mct.md`, 26 operations) en +passant de la vue **donnees / traitements** a la vue **usages**. -Le diagramme reste au niveau conceptuel. Il ne prejuge pas de l'ecran ou de -l'endpoint qui realisera chaque cas, mais identifie qui fait quoi. +Le diagramme reste au niveau conceptuel : il identifie qui fait quoi, sans +prejuger de l'ecran ou de l'endpoint qui realise chaque cas. Chaque cas +back-office est rattache a la **permission** qui le conditionne (catalogue fige +de 23 codes, `dictionary.md` 3.17), conformement a la regle RBAC +permission-driven : le code teste une permission, pas un nom de role. **Sources** : -- `docs/PROJECT_CONTEXT.md` sections 2 (acteurs, processus), 7 (scope RBAC) -- `docs/merise/dictionary.md` (entites `commande`, `role`, `user`) +- `docs/PROJECT_CONTEXT.md` sections 2 (acteurs, processus), 7 (scope back-office) +- `docs/merise/dictionary.md` 3.14-3.18 (`user`, `role`, `role_visible_source`, `permission`, `role_permission`) +- `docs/merise/mct.md` (operations, acteurs, permissions par operation) --- ## 2. Acteurs - perimetre et challenge de pertinence -Le brief (`PROJECT_CONTEXT.md` section 2 et section 7) definit les acteurs -metier. Avant de les retenir, chaque acteur propose dans la consigne initiale -est confronte au perimetre reel du projet. +Le brief initial (`PROJECT_CONTEXT.md` section 2) decrivait quatre acteurs +metier (Client, Accueil, Preparation, Administration) adosses a 3 roles RBAC. Le +modele v0.2 (prod-like, Decision 4 de `revue-alignement-p1.md` section 7) raffine +le back-office en **5 roles** pour coller a l'organisation reelle d'un fast-food +multi-canal. Chaque acteur candidat est confronte au perimetre reel. -| Acteur candidat | Statut | Justification (perimetre reel) | +| Acteur candidat (brief) | Statut v0.2 | Justification (perimetre reel) | |---|---|---| -| **Client (borne kiosk)** | Retenu | Acteur central du Bloc 1. Compose et valide une commande sur la borne tactile autonome (canal `kiosk`). Non authentifie. | -| **Manager / Admin** | Retenu, fusionne en **Administration** | Le brief ne distingue pas "manager" et "admin" comme deux roles. Le role RBAC reel est `admin` (section 7). Il porte le CRUD catalogue, la gestion des utilisateurs/roles et les stats. On nomme l'acteur **Administration** pour coller au vocabulaire du brief. | -| **Cuisine** | Retenu, renomme **Preparation** | Correspond au role RBAC `preparation` (section 7). Voit la file des commandes a preparer triees par heure de livraison croissante et fait avancer leur statut. Le terme "Cuisine" est un synonyme metier ; le role technique est `preparation`. | -| **Caisse** | Ecarte comme acteur distinct | Challenge : il n'existe pas de role RBAC `caisse` (les 3 roles sont `admin`, `preparation`, `accueil`). Le paiement existe dans le cycle (cote Client sur la borne et cote Accueil au comptoir/drive), mais aucun acteur "Caisse" dedie n'est modelise. L'equivalent operationnel le plus proche est l'**Accueil** (role `accueil`) qui saisit les commandes au comptoir/drive et remet les commandes livrees. | -| **Accueil** | Retenu (non liste dans la consigne mais present au brief) | Role RBAC `accueil`. Saisit les commandes au comptoir (canal `counter`) ou au drive (canal `drive`), puis remet les commandes au client (passage a `delivered`). C'est l'acteur qui recouvre le besoin que la consigne attribuait a "Caisse". | +| **Client (borne kiosk)** | Retenu (acteur `CUSTOMER`) | Acteur central du Bloc 1. Compose et valide une commande sur la borne tactile autonome (canal `kiosk`). **Non authentifie**. | +| **Accueil** | **Scinde** en `counter` et `drive` | Le besoin "Accueil" recouvre deux canaux operationnels distincts : le comptoir (`counter`) et le drive (`drive`). Le v0.2 les separe car le tag `source` de la commande et le filtre de dashboard (`role_visible_source`) different. Tous deux saisissent des commandes, les remettent et les annulent. | +| **Preparation** | Retenu, renomme `kitchen` | Role RBAC `kitchen`. Voit la file des commandes `paid` triees par `paid_at` croissant. **Lecture seule** : ne declenche aucune transition de statut (le KDS est un dispositif visuel ; la remise revient a `counter`/`drive`). | +| **Administration** | **Scinde** en `admin` et `manager` | Le v0.1 fusionnait "Manager/Admin". Le v0.2 distingue : `admin` (gestion des utilisateurs, des roles et permissions, suppressions catalogue) et `manager` (catalogue create/update, stock/reappro, stats), sans acces aux utilisateurs ni au RBAC. Resout le point ouvert v0.1 "Manager vs Admin". | +| **Caisse** | Ecarte (recouvert par `counter`/`drive`) | Aucun role `caisse` n'existe. L'encaissement est atomique a la creation de commande (saisie du numero = substitut de paiement) ; il est realise par le Client (kiosk) ou par `counter`/`drive` (back-office). Resout le point ouvert v0.1 "Caisse absente du RBAC". | +| **Systeme** | Retenu (acteur `SYS`) | Logique interne (generation du numero, reponse API de confirmation). Apparait dans le MCT (3.4 `DISPLAY_CONFIRMATION`) ; non represente comme acteur humain au diagramme. | ### Decision sur les acteurs retenus -Quatre acteurs sont conserves au diagramme : +Six acteurs sont conserves : un acteur public et cinq roles back-office. -1. **Client** (borne, non authentifie) -2. **Administration** (role `admin`) -3. **Preparation** (role `preparation`, ex-"Cuisine") -4. **Accueil** (role `accueil`, recouvre le besoin "Caisse") +1. **Customer** (borne kiosk, non authentifie) +2. **Admin** (role `admin`) +3. **Manager** (role `manager`) +4. **Kitchen** (role `kitchen`, ex-"Preparation", lecture seule) +5. **Counter** (role `counter`, ex-"Accueil" comptoir) +6. **Drive** (role `drive`, ex-"Accueil" drive) -> Decision actee : il n'y a **pas** de parcours employe dedie modelise a part. -> Les cas d'usage des employes (Administration, Preparation, Accueil) sont -> couverts directement ici. Cette decision suit le mantra du Rasoir d'Ockham -> (#37) : on evite une couche de modelisation redondante tant qu'aucun besoin -> ne la justifie. +> Regle RBAC permission-driven (`dictionary.md` 3.15) : les rattachements +> acteur -> cas ci-dessous refletent la **matrice de permissions par defaut au +> seed**. Le gardien reel est la permission, pas le nom du role : un role +> personnalise (ex. "chef-patissier") dote des bonnes permissions ouvre les +> memes cas, sans changement de code. Les 5 roles seed sont un point de depart, +> pas une liste fermee. --- ## 3. Diagramme de cas d'utilisation -Mermaid ne fournit pas de type `usecase` natif. La representation ci-dessous -utilise un `flowchart` : les acteurs sont des noeuds a gauche, les cas -d'utilisation sont des noeuds arrondis regroupes par sous-systeme, et les -fleches portent les relations (`<>`, `<>`) la ou elles -ont du sens. +Mermaid ne fournit pas de type `usecase` natif. La representation utilise un +`flowchart` : les acteurs sont a gauche, les cas d'utilisation regroupes par +sous-systeme. La permission qui conditionne chaque cas back-office est precisee +en section 4. ```mermaid flowchart LR %% Acteurs - Client(("Client
borne kiosk")) - Admin(("Administration
role admin")) - Prep(("Preparation
role preparation")) - Accueil(("Accueil
role accueil")) + Customer(("Customer
borne kiosk
non authentifie")) + Admin(("Admin
role admin")) + Manager(("Manager
role manager")) + Kitchen(("Kitchen
role kitchen
lecture seule")) + Counter(("Counter
role counter")) + Drive(("Drive
role drive")) %% Sous-systeme Borne client - subgraph BORNE["Borne client - Bloc 1"] + subgraph BORNE["Borne client - Bloc 1 (public)"] UC1(["Consulter le catalogue"]) - UC2(["Composer un menu"]) - UC3(["Passer une commande"]) - UC4(["Saisir le numero de retrait"]) - UC5(["Recevoir la confirmation"]) - UC6(["Payer la commande"]) + UC2(["Composer le panier"]) + UC3(["Consulter les allergenes"]) + UC4(["Passer une commande"]) + UC5(["Saisir le numero de retrait"]) + UC6(["Recevoir la confirmation"]) end - %% Sous-systeme Back-office - subgraph BACK["Back-office - Bloc 2"] - UC10(["Gerer le catalogue
categories, produits, menus"]) - UC11(["Gerer les utilisateurs et roles"]) - UC12(["Consulter les statistiques"]) - UC20(["Consulter la file de preparation"]) - UC21(["Faire avancer une commande"]) - UC30(["Saisir une commande
comptoir ou drive"]) - UC31(["Remettre la commande au client"]) - UC40(["S'authentifier"]) + %% Sous-systeme Operations commande + subgraph OPS["Operations commande - back-office"] + UC10(["Saisir une commande
comptoir / drive"]) + UC11(["Consulter la file de preparation"]) + UC12(["Remettre la commande"]) + UC13(["Annuler une commande"]) end - %% Relations Client - Client --> UC1 - Client --> UC2 - Client --> UC3 - Client --> UC6 - Client --> UC5 + %% Sous-systeme Catalogue + subgraph CAT["Catalogue - back-office"] + UC20(["Gerer produits"]) + UC21(["Gerer menus et slots"]) + UC22(["Gerer categories"]) + UC23(["Gerer ingredients,
compositions, allergenes"]) + end - %% include / extend cote borne - UC3 -. include .-> UC4 - UC3 -. include .-> UC6 + %% Sous-systeme Stock + subgraph STK["Stock - back-office"] + UC30(["Consulter le stock"]) + UC31(["Compter l'inventaire"]) + UC32(["Reapprovisionner"]) + end + + %% Sous-systeme Administration + subgraph ADM["Administration - back-office"] + UC40(["Gerer les utilisateurs"]) + UC41(["Gerer roles et permissions"]) + UC42(["Consulter les statistiques"]) + end + + %% Transverse + UC50(["S'authentifier"]) + UC51(["Se deconnecter"]) + + %% Relations Customer + Customer --> UC1 + Customer --> UC2 + Customer --> UC4 + Customer --> UC6 UC2 -. include .-> UC1 - UC3 -. extend .-> UC2 + UC2 -. extend .-> UC3 + UC4 -. include .-> UC5 + UC4 -. include .-> UC2 - %% Relations Administration + %% Relations Counter / Drive (operations commande + stock) + Counter --> UC10 + Counter --> UC11 + Counter --> UC12 + Counter --> UC13 + Drive --> UC10 + Drive --> UC11 + Drive --> UC12 + Drive --> UC13 + UC10 -. include .-> UC1 + + %% Kitchen (lecture seule) + Kitchen --> UC11 + + %% Stock (kitchen / counter / drive / manager / admin) + Kitchen --> UC30 + Kitchen --> UC31 + Counter --> UC30 + Counter --> UC31 + Drive --> UC30 + Drive --> UC31 + Manager --> UC30 + Manager --> UC31 + Manager --> UC32 + + %% Catalogue (manager + admin) + Manager --> UC20 + Manager --> UC21 + Manager --> UC22 + Manager --> UC23 + Admin --> UC20 + Admin --> UC21 + Admin --> UC22 + Admin --> UC23 + + %% Administration (admin) + stats (manager + admin) Admin --> UC40 - Admin --> UC10 + Admin --> UC41 + Admin --> UC42 + Admin --> UC30 + Admin --> UC31 + Admin --> UC32 Admin --> UC11 - Admin --> UC12 + Admin --> UC13 + Manager --> UC42 - %% Relations Preparation - Prep --> UC40 - Prep --> UC20 - Prep --> UC21 - - %% Relations Accueil - Accueil --> UC40 - Accueil --> UC30 - Accueil --> UC31 - UC30 -. include .-> UC1 - - %% Authentification mutualisee - UC10 -. include .-> UC40 - UC11 -. include .-> UC40 - UC20 -. include .-> UC40 - UC30 -. include .-> UC40 + %% Authentification mutualisee (tout cas back-office) + UC10 -. include .-> UC50 + UC11 -. include .-> UC50 + UC20 -. include .-> UC50 + UC30 -. include .-> UC50 + UC40 -. include .-> UC50 + UC42 -. include .-> UC50 ``` --- ## 4. Description des cas d'utilisation -### 4.1 Acteur Client (borne kiosk) +### 4.1 Acteur Customer (borne kiosk, non authentifie) -| Cas | Description | Entites manipulees | +| Cas | Operation MCT | Description | Entites manipulees | +|---|---|---|---| +| Consulter le catalogue | 3.1 LOAD_CATALOGUE | Parcourir categories, produits et menus disponibles, charges via `GET /api/categories`, `/api/products`, `/api/menus` (ou JSON fallback). | `category`, `product`, `menu`, `menu_slot`, `menu_slot_option` | +| Composer le panier | 3.2 COMPOSE_CART | Ajouter produits a la carte ou menus ; remplir les slots d'un menu (`order_item_selection`), choisir le format Normal/Maxi, ajouter/retirer des ingredients (`order_item_modifier`). Panier volatil cote front, aucun ecrit BDD a ce stade. | `product`, `menu`, `menu_slot`, `menu_slot_option`, `ingredient`, `product_ingredient` | +| Consulter les allergenes | (derive de 3.1) | Afficher en modal les allergenes d'un produit, **calcules** par jointure `product_ingredient -> ingredient_allergen -> allergen` (INCO 1169/2011). Etend la composition. | `allergen`, `ingredient_allergen`, `product_ingredient` | +| Passer une commande | 3.3 CREATE_ORDER | Valider le panier et saisir le numero de retrait. Creation atomique : INSERT `customer_order` (`pending_payment` puis `paid` dans la meme transaction), `order_item` + selections + modifiers snapshotes, decrement du stock + `stock_movement`. | `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `ingredient`, `stock_movement` | +| Saisir le numero de retrait | (inclus dans 3.3) | Renseigner le numero qui identifie le client. Tient lieu de paiement (cadre RNCP). Cas inclus par "Passer une commande". | `customer_order.order_number` | +| Recevoir la confirmation | 3.4 DISPLAY_CONFIRMATION | Afficher l'ecran de confirmation avec le numero, apres reponse `201` (statut `paid`). La borne se reinitialise. | `customer_order` | + +### 4.2 Acteurs Counter et Drive (roles `counter`, `drive`) + +| Cas | Operation MCT | Permission | Description | Entites | +|---|---|---|---|---| +| Saisir une commande comptoir/drive | 4.1 CREATE_COUNTER_ORDER | `order.create` | Composer une commande pour un client au comptoir (`counter`) ou au drive (`drive`). Logique identique a CREATE_ORDER ; `source` auto-tague depuis `role.order_source`. Numero `C-`/`D-YYYY-MM-DD-NNN`. | `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `ingredient`, `stock_movement` | +| Consulter la file de preparation | 5.1 LIST_ORDERS_DISPLAY | `order.read` | Voir les commandes `paid` triees par `paid_at` croissant, filtrees par `role_visible_source` (counter voit kiosk+counter ; drive voit drive). Couleur KDS = `now - paid_at`. | `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `role_visible_source` | +| Remettre la commande | 6.1 DELIVER_ORDER | `order.deliver` | Geste unique `paid -> delivered`, `delivered_at = NOW()`. | `customer_order` | +| Annuler une commande | 7.1 CANCEL_ORDER | `order.cancel` | Transition vers `cancelled` depuis `pending_payment`/`paid`, `cancelled_at = NOW()`. Re-credit du stock si `paid`. | `customer_order`, `ingredient`, `stock_movement` | + +### 4.3 Acteur Kitchen (role `kitchen`, lecture seule) + +| Cas | Operation MCT | Permission | Description | Entites | +|---|---|---|---|---| +| Consulter la file de preparation | 5.1 LIST_ORDERS_DISPLAY | `order.read` | Voir toutes les sources (kiosk, counter, drive) en lecture seule. **Aucune transition de statut** : le KDS est un affichage visuel. | `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `role_visible_source` | + +### 4.4 Stock (Kitchen, Counter, Drive, Manager, Admin) + +| Cas | Operation MCT | Permission | Description | Entites | +|---|---|---|---|---| +| Consulter le stock | 9.3 READ_STOCK | `stock.read` | Lister les ingredients avec stock courant ; alerte rupture calculee a l'affichage (`stock_quantity <= low_stock_threshold`). | `ingredient`, `stock_movement` | +| Compter l'inventaire | 9.2 INVENTORY_COUNT | `stock.count` | Saisir un comptage physique ; le systeme enregistre l'ecart (`inventory_correction`). Inclut les equipiers (kitchen/counter/drive). | `ingredient`, `stock_movement` | +| Reapprovisionner | 9.1 RESTOCK | `stock.manage` | Enregistrer une livraison en conditionnements (`+= N * pack_size`). Reserve manager/admin. | `ingredient`, `stock_movement` | + +### 4.5 Catalogue (Manager, Admin) + +| Cas | Operation MCT | Permissions | Description | Entites | +|---|---|---|---|---| +| Gerer produits | 8.1-8.3 CREATE/UPDATE/DELETE_PRODUCT | `product.create`/`update` (manager+admin), `product.delete` (admin seul) | CRUD produits (nom, prix, `vat_rate`, image, dispo). La suppression physique est reservee a `admin` et bloquee si reference (FK RESTRICT). | `product`, `category` | +| Gerer menus et slots | 8.4-8.6 CREATE/UPDATE/DELETE_MENU | `menu.create`/`update` (manager+admin), `menu.delete` (admin seul) | CRUD menus avec leur configuration de slots (`menu_slot`, `menu_slot_option`) et le burger fixe. | `menu`, `menu_slot`, `menu_slot_option`, `product` | +| Gerer categories | 8.7 MANAGE_CATEGORY | `category.manage` (manager+admin) | CRUD categories ; desactivation `is_active=0`. | `category` | +| Gerer ingredients, compositions, allergenes | 8.8 MANAGE_INGREDIENT | `ingredient.manage` (manager+admin) | CRUD `ingredient` ; composition `product_ingredient` (quantites Normal/Maxi, retirable/ajoutable, supplement) ; mapping `ingredient_allergen` (14 allergenes UE). | `ingredient`, `product_ingredient`, `ingredient_allergen`, `allergen` | + +### 4.6 Administration (role `admin`) + Stats (Manager, Admin) + +| Cas | Operation MCT | Permissions | Description | Entites | +|---|---|---|---|---| +| Gerer les utilisateurs | 10.1-10.3 CREATE/UPDATE/DEACTIVATE_USER | `user.create`/`update`/`deactivate` (admin) ; `user.read` (admin+manager) | CRUD comptes back-office avec hash argon2id ; desactivation sans suppression (historique preserve). | `user`, `role` | +| Gerer roles et permissions | 10.4 MANAGE_RBAC | `role.manage` (admin) | Editer la matrice `role_permission`, creer/modifier des roles personnalises (`default_route`, `order_source`), regler `role_visible_source`. Permissions statiques (declarees en migration). | `role`, `permission`, `role_permission`, `role_visible_source` | +| Consulter les statistiques | 11.1 READ_STATS | `stats.read` (admin+manager) | Agregats par `service_day` (coupure 10h), top produits, taux d'annulation, temps moyen de remise `delivered_at - paid_at`, repartition par `source`/`service_mode`. | `customer_order`, `order_item` | + +### 4.7 Cas transverses - Authentification + +| Cas | Operation MCT | Description | |---|---|---| -| Consulter le catalogue | Parcourir les categories, produits et menus disponibles. Charges via `GET /api/categories`, `/api/products`, `/api/menus` (ou JSON fallback). | `categorie`, `produit`, `menu` | -| Composer un menu | Choisir burger + accompagnement + boisson + sauce, regler les options de taille (normale / grande) et de personnalisation. Etend "Passer une commande" car un menu compose est une variante d'item au panier. | `menu`, `menu_produit`, `produit` | -| Passer une commande | Valider le panier, declencher la creation de la commande composee. Inclut la saisie du numero de retrait et le paiement. | `commande`, `ligne_commande` | -| Saisir le numero de retrait | Renseigner le numero qui identifie le client au comptoir. Cas inclus par "Passer une commande". | `commande.numero` | -| Payer la commande | Regler la commande une fois le panier compose et valide. Materialise la transition `pending_payment -> paid` de `state-commande.md`. Cas inclus par "Passer une commande". | `commande.statut`, `commande.paye_a` | -| Recevoir la confirmation | Afficher l'ecran de confirmation avec le numero, apres paiement. | `commande` | - -> Note de coherence : le cycle de vie comporte deux phases successives, la -> composition de la commande puis son paiement (regle metier confirmee). Le cas -> "Payer la commande" est retenu cote Client et materialise la transition -> `pending_payment -> paid` de l'ENUM `statut` -> (`dictionary.md` 3.5, `state-commande.md`). - -### 4.2 Acteur Administration (role admin) - -| Cas | Description | Entites manipulees | -|---|---|---| -| Gerer le catalogue | CRUD sur categories, produits et menus (libelles, prix, images, disponibilite, composition de menu). | `categorie`, `produit`, `menu`, `menu_produit` | -| Gerer les utilisateurs et roles | CRUD sur les comptes back-office et leurs roles ; consultation de la matrice de permissions. | `user`, `role`, `permission`, `role_permission` | -| Consulter les statistiques | Voir les commandes du jour de service, le chiffre d'affaires, les produits les plus commandes. | `commande`, `ligne_commande` | - -### 4.3 Acteur Preparation (role preparation, ex-Cuisine) - -| Cas | Description | Entites manipulees | -|---|---|---| -| Consulter la file de preparation | Afficher les commandes a preparer triees par heure de livraison croissante, tous canaux confondus. | `commande`, `ligne_commande` | -| Faire avancer une commande | Declarer une commande "preparee", ce qui declenche une transition de statut (voir `state-commande.md`). | `commande.statut` | - -### 4.4 Acteur Accueil (role accueil, recouvre Caisse) - -| Cas | Description | Entites manipulees | -|---|---|---| -| Saisir une commande | Creer une commande pour un client au comptoir (`counter`) ou au drive (`drive`), en consultant le catalogue. | `commande`, `ligne_commande`, `produit`, `menu` | -| Remettre la commande au client | Declarer une commande "livree" au moment de la remise physique. | `commande.statut` | - -### 4.5 Cas transverse - S'authentifier - -Tous les acteurs du back-office (Administration, Preparation, Accueil) passent -par "S'authentifier" avant d'acceder a leurs cas. Modelise comme cas inclus -(`<>`) par chaque cas back-office pour eviter de surcharger le -diagramme. Le Client de la borne n'est pas authentifie (canal `kiosk` public). +| S'authentifier | 12.1 AUTHENTICATE_USER | Tous les roles back-office passent par ce cas avant d'acceder a leurs cas (relation `<>`). Verification argon2id, regeneration de session (anti-fixation), `is_active=1` requis, redirection vers `role.default_route`. Le Customer du kiosk n'est pas authentifie. | +| Se deconnecter | 12.2 LOGOUT_USER | Destruction de session (`session_destroy()`) sur clic ou expiration (idle 4h / absolu 10h). | --- @@ -188,35 +261,32 @@ diagramme. Le Client de la borne n'est pas authentifie (canal `kiosk` public). | Relation | Type | Justification | |---|---|---| | Passer une commande -> Saisir le numero de retrait | include | La saisie du numero fait partie integrante de toute validation de commande. | -| Passer une commande -> Payer la commande | include | Le paiement suit la composition du panier et fait partie integrante du parcours (phase 2 du cycle de vie). | -| Composer un menu -> Consulter le catalogue | include | Composer un menu suppose de parcourir les produits eligibles a chaque slot. | -| Passer une commande -> Composer un menu | extend | Le menu est un cas optionnel : une commande peut ne contenir que des produits a la carte. La composition etend le parcours seulement si le client choisit un menu. | -| Saisir une commande (Accueil) -> Consulter le catalogue | include | L'equipier consulte le catalogue pour saisir au comptoir / drive. | -| Cas back-office -> S'authentifier | include | Acces conditionne a une session authentifiee. | +| Passer une commande -> Composer le panier | include | Une commande resulte d'un panier compose. | +| Composer le panier -> Consulter le catalogue | include | Composer suppose de parcourir les produits eligibles (a la carte ou par slot). | +| Composer le panier -> Consulter les allergenes | extend | La consultation des allergenes est un cas optionnel declenche a la demande du client sur un produit. | +| Saisir une commande (Counter/Drive) -> Consulter le catalogue | include | L'equipier consulte le catalogue pour saisir au comptoir/drive. | +| Cas back-office -> S'authentifier | include | Acces conditionne a une session authentifiee detenant la permission requise. | --- -## 6. Incoherences remontees vers les autres livrables +## 6. Points resolus par rapport au v0.1 -Ces ecarts entre les sources sont signales pour arbitrage de l'auteur (la -modelisation finale releve de sa decision, mantra de validation humaine). +Les incoherences que le v0.1 remontait pour arbitrage sont desormais tranchees +par le modele v0.2 (`dictionary.md`, `mct.md`). -1. **ENUM `statut` et phase de paiement (tranche)** - Le dictionnaire (`dictionary.md` 3.5) definit - `statut ENUM('pending_payment','paid','preparing','ready','delivered','cancelled')` - avec un paiement explicite. La regle metier confirmee fixe deux phases - successives, la composition de la commande puis son paiement. Le cas - "Payer la commande" est donc retenu cote Client et materialise la transition - `pending_payment -> paid`. Cet ecart est tranche : la machine canonique de - `state-commande.md` fait foi. - -2. **Acteur "Caisse" absent du RBAC** - Aucun role `caisse` n'existe (`PROJECT_CONTEXT.md` section 7 : `admin`, - `preparation`, `accueil`). La fonction d'encaissement de la consigne a ete - rattachee a l'acteur **Accueil**. A confirmer. - -3. **"Manager" vs "Admin"** - La consigne parle de "Manager/Admin" ; le brief ne connait que `admin`. Les - deux ont ete fusionnes en un acteur **Administration**. A confirmer si un - role manager intermediaire est souhaite (le dictionnaire 3.8 mentionne un - role `manager` extensible, non present dans le scope section 7). +1. **Acteur "Caisse"** : ecarte. L'encaissement est atomique a la creation + (saisie du numero = substitut de paiement, `mct.md` section 13) et realise + par le Customer (kiosk) ou par `counter`/`drive` (back-office). Aucun role + `caisse` n'est necessaire. +2. **"Manager" vs "Admin"** : scindes en deux roles distincts. `manager` gere le + catalogue (create/update), le stock/reappro et les stats ; `admin` ajoute les + suppressions catalogue, la gestion des utilisateurs et le RBAC. +3. **"Accueil" unique** : scinde en `counter` et `drive`, car le tag `source` et + le filtre `role_visible_source` different selon le canal. +4. **Machine a etats** : alignee sur les 4 etats du `dictionary.md` 3.10 + (`pending_payment -> paid -> delivered` + `cancelled`). Plus de `preparing` / + `ready` : la cuisine (`kitchen`) est en lecture seule, la remise est un geste + unique (`counter`/`drive`). +5. **Modele permission-driven** : chaque cas back-office est rattache a sa + permission (catalogue de 23 codes fige, `dictionary.md` 3.17). Le diagramme + reflete la matrice seed ; le gardien effectif reste la permission. diff --git a/scripts/forgejo-branch-protection.sh b/scripts/forgejo-branch-protection.sh new file mode 100755 index 0000000..6f38715 --- /dev/null +++ b/scripts/forgejo-branch-protection.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# +# Wakdo - applique (idempotent) les regles de protection de branche sur Forgejo. +# +# Pourquoi un script versionne : la regle de gouvernance devient reproductible +# et auditable (Cr 7.b), pas un clic dans une UI. Roter le token = editer .env. +# +# Regle posee : +# - main et dev : push direct interdit (PR obligatoire), force-push bloque +# - required_approvals = 0 (travail solo : on ne peut pas approuver sa propre PR) +# - status check : OPTIONNEL via REQUIRE_CI=1, contextes dans CI_CONTEXTS +# (a activer en lot D, une fois les jobs .forgejo/workflows/ nommes ; +# activer avant que le workflow n'existe bloquerait tout merge) +# +# Usage : +# scripts/forgejo-branch-protection.sh # baseline (PR requise) +# REQUIRE_CI=1 CI_CONTEXTS='ci' scripts/forgejo-branch-protection.sh # + CI verte requise +# +set -euo pipefail + +REPO_API="https://git.acadenice.com/api/v1/repos/AcadeNice/corentin_wakdo" +ENV_FILE="$(cd "$(dirname "$0")/.." && pwd)/.env" + +TOKEN="$(grep -E '^FORGEJO_TOKEN=' "$ENV_FILE" | cut -d= -f2-)" +if [ -z "${TOKEN:-}" ]; then + echo "ERREUR : FORGEJO_TOKEN absent de $ENV_FILE" >&2 + exit 1 +fi + +REQUIRE_CI="${REQUIRE_CI:-0}" +CI_CONTEXTS="${CI_CONTEXTS:-ci}" + +# Construit le tableau JSON des contextes de status check si REQUIRE_CI=1. +status_check_json="false" +contexts_json="[]" +if [ "$REQUIRE_CI" = "1" ]; then + status_check_json="true" + contexts_json="$(printf '%s' "$CI_CONTEXTS" | awk -F, '{printf "["; for(i=1;i<=NF;i++){printf "%s\"%s\"", (i>1?",":""), $i}; printf "]"}')" +fi + +for branch in main dev; do + payload=$(cat </dev/null +done + +echo "OK - protections appliquees sur main et dev." diff --git a/scripts/forgejo-pr-automerge.sh b/scripts/forgejo-pr-automerge.sh new file mode 100755 index 0000000..46f5952 --- /dev/null +++ b/scripts/forgejo-pr-automerge.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# Wakdo - ouvre une PR Forgejo et planifie son auto-merge quand la CI passe. +# +# Strategie solo dev : la PR reste obligatoire (trace de gouvernance, Cr 4.f) +# mais le merge se declenche tout seul des que les checks requis sont verts. +# Prerequis : status checks requis sur la branche de base +# (voir scripts/forgejo-branch-protection.sh avec REQUIRE_CI=1). +# +# Usage : +# scripts/forgejo-pr-automerge.sh [HEAD] [BASE] ["Titre"] +# Defauts : HEAD = branche courante, BASE = dev, titre = dernier sujet de commit. +# +set -euo pipefail + +REPO_API="https://git.acadenice.com/api/v1/repos/AcadeNice/corentin_wakdo" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="$ROOT/.env" + +TOKEN="$(grep -E '^FORGEJO_TOKEN=' "$ENV_FILE" | cut -d= -f2-)" +[ -n "${TOKEN:-}" ] || { echo "ERREUR : FORGEJO_TOKEN absent de $ENV_FILE" >&2; exit 1; } + +HEAD="${1:-$(git -C "$ROOT" rev-parse --abbrev-ref HEAD)}" +BASE="${2:-dev}" +TITLE="${3:-$(git -C "$ROOT" log -1 --pretty=%s "$HEAD")}" + +if [ "$BASE" = "main" ] && [ "$HEAD" != "dev" ]; then + echo "Garde-fou : seules les PR depuis 'dev' visent 'main'. Abandon." >&2 + exit 1 +fi + +echo "PR : $HEAD -> $BASE" +echo "Titre : $TITLE" + +# 1. Creer la PR (ou recuperer l'index si elle existe deja). +create_resp=$(curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d "$(printf '{"head":"%s","base":"%s","title":"%s"}' "$HEAD" "$BASE" "$TITLE")" \ + "$REPO_API/pulls") +index=$(printf '%s' "$create_resp" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('number',''))" 2>/dev/null || true) + +if [ -z "$index" ]; then + # PR deja existante : la retrouver par branche head. + index=$(curl -s -H "Authorization: token $TOKEN" "$REPO_API/pulls?state=open&limit=50" \ + | python3 -c "import sys,json;hs='$HEAD';d=json.load(sys.stdin);print(next((p['number'] for p in d if p['head']['ref']==hs),''))" 2>/dev/null || true) +fi +[ -n "$index" ] || { echo "Impossible de creer/trouver la PR. Reponse : $create_resp" >&2; exit 1; } +echo "PR #$index" + +# 2. Planifier l'auto-merge (squash) quand les checks requis sont verts. +merge_resp=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ + -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d '{"Do":"squash","merge_when_checks_succeed":true,"delete_branch_after_merge":false}' \ + "$REPO_API/pulls/$index/merge") + +case "$merge_resp" in + 200|201|202) echo "Auto-merge planifie sur PR #$index (squash a la CI verte)." ;; + 405) echo "PR #$index : merge differe - checks pas encore verts, auto-merge en attente." ;; + *) echo "Reponse merge HTTP $merge_resp sur PR #$index (verifier l'etat des checks / protections)." ;; +esac