---
suivi: 1234
date: 2026-08-07
sujet: diag_controle_acces_groupes_permissions
chantier: securite_acces
type: diagnostic
statut: poussé
hash: 00357f1c
fichiers:
  - tools/diag/diag_controle_acces_1234.php
  - docs/suivi/SUIVI_1234_diag_controle_acces.md
---

# SUIVI #1234 — Diagnostic contrôle d'accès (groupes → permissions)

**PROMPT ENVOYÉ** : PHASE 1 DIAG lecture seule — cartographie mécaniques d'autorisation, matrice réelle, existant `feature_permissions`, anomalies GS_*, divergence front/back, hors-périmètre, stratégie de bascule + modèle cible table d'association (objet 8 bis). Aucun code métier / migration / Vue / rebuild / Graph. Reproduction des droits à l'identique ; option A #1233 (pas de renommage GSM_*).

Baseline citée : HEAD prod `9501cf4b` (#1233). Fiches lues : #1233 (auth par NOM, ~710 GS_*, GS_GROUPE/GEMME/PDG hors seeder), #1188/#1189/#1190 (confort enseigne ≠ accès, workflow NV), #1209/#1211/#1214 (visibilité tâches).

---

## SYNTHÈSE (objets 1–9 + 8 bis)

### Découverte structurante (à lire avant tout)

Le modèle « **permission en code + association groupe↔permission en base + écran admin** » **existe déjà** :

| Couche | Emplacement |
|--------|-------------|
| Catalogue (codes) | `config/feature_permissions.php` → `catalog` (~119 clés) |
| Mappings seed | même fichier → `default_mappings` (patterns groupe → permissions) |
| Tables | `features_permissions`, `feature_permission_groups` |
| Résolution runtime | `PermissionService` + `userCan()` / middleware `feature.permission:` |
| Admin UI | `/admin/droits` (`DroitsController`) + `AuditLogPermission` |
| Cache | `user_permissions:{id}` TTL **300 s**, `flushCache()` à chaque update admin |

**Ce n'est donc pas « créer le modèle »** : c'est **arrêter de le contourner** (~31 `m365.groups:` encore en routes, centaines de `GS_*` en services/Vue/`User::isAdminERP`, raccourcis `GS_DSI`/`GS_ADMIN_ERP` **dans** `PermissionService` qui court-circuitent la table).

Volumes routes (`routes/web.php`) : **~170** occurrences `feature.permission:` · **~31** `m365.groups:`.

---

## OBJET 1 — Mécaniques d'autorisation (inventaire)

| # | Mécanique | Vit | Protège | Se fonde sur | Court-circuit |
|---|-----------|-----|---------|--------------|---------------|
| A | Middleware `feature.permission:` | `routes/web.php` + `CheckFeaturePermission` | Routes HTTP | Tables `features_permissions` + groupes user (+ raccourcis DSI/ADMIN dans service) | Gate `isAdminERP` n'est pas sur ce middleware ; DSI = toutes clés catalogue |
| B | Middleware `m365.groups:` | `routes/web.php` + `CheckM365Groups` | Sous-ensemble routes (admin legacy, Aktor, CIE, etc.) | `users.m365_groups` displayName | Bypass si DSI/ADMIN dans middleware ? (à vérifier lot) — parallèle à A |
| C | Policies Laravel | `app/Policies/*` | Objets (AO, TacheSuivi, Projet, DossierFoncier view) | Permissions / groupes / record ACL | `Gate::before` → `User::isAdminERP()` **bypass total** |
| D | `assertGestion` foncier | Contrôleurs Parcelle/Proprio/Secteur… | Écriture foncier | **déjà** `PermissionService` + `foncier.gestion` | Admin ERP via `isAdminERP` ailleurs |
| E | `User::isAdminERP` / `isDsi` / `isCompta`… | Modèle User | Bypass gates, menus, services | Hardcode `GS_ADMIN_ERP` / `GS_DSI` / … | Source de vérité parallèle à la table |
| F | `record_habilitations` + scope | Foncier D62 | Visibilité **par enregistrement** | Table ACL | Hors permission fonctionnelle |
| G | `FactureVisibility` | Compta | Qui voit quelles factures | Règles métier + groupes | Nature 2 |
| H | `ProgrammeAccessService` | Programmes | Accès programme | Appartenance / règles | Mixte |
| I | `TacheSuivi::visiblePourUtilisateur` | Tâches | Liste tâches | Règles #1209+ | Nature 2 |
| J | Front `useUserCan` + `useAuthM365` / AppShell | Vue | Affichage menus/boutons | Permissions Inertia **et** GS_* | **Pas une sécurité** |
| K | Workflow `can_validate` / `can_block` | Validation factures | NV1/NV2 | Workflow + groupes (#1188/#1189) | Hors refonte permissions globales |
| L | `config/erp.php` / modules enseigne | Config | Modules visibles | Enseigne + config | Complément navigation |
| M | Filtre confort enseigne dépôt | #1188/#1190 | UX dépôt | GS_HECTARE/GEMME/ENVOL | **Nature 3 — pas un ACL** |

### Schéma de superposition

```
Requête HTTP
  ├─ auth (session)
  ├─ middleware feature.permission:X  ──► PermissionService (DB + cache 300s)
  │                                         ├─ raccourci GS_DSI → tout catalogue
  │                                         └─ raccourci GS_ADMIN_ERP → catalogue − admin.systeme/maintenance
  ├─ middleware m365.groups:GS_*      ──► CheckM365Groups (JSON users.m365_groups)
  ├─ controller → authorize()/policy  ──► Gate::before(isAdminERP) puis policy
  ├─ assertGestion / services ACL     ──► PermissionService ou GS_* hardcodé
  └─ couche enregistrement            ──► record_habilitations / FactureVisibility / visiblePour…
Front (parallèle, non sécurisant)
  ├─ useUserCan(permission)           ──► props Inertia depuis PermissionService
  └─ hasM365Groupe / AppShell GS_*    ──► confort / menus legacy
```

### Trois natures (ne pas fusionner)

1. **PERMISSION fonctionnelle** — « peut comptabiliser » → **périmètre chantier** (mécaniques A, B→A, E à absorber, parties de C/D).
2. **VISIBILITÉ / cloisonnement** — D62, `record_habilitations`, `FactureVisibility`, tâches → **hors périmètre**.
3. **CONFORT d'affichage** — filtre enseigne dépôt (#1188) → **hors périmètre** (ne jamais en faire une restriction d'accès).

---

## OBJET 2 — Matrice réelle (extrait exhaustif par domaine)

Convention colonnes : `domaine | ressource/action | mécanique | groupes en dur | perm. feature_permissions ? | nature | fichier`

Compteurs (estimation source, à confirmer script OVH §4–5) :

| Compteur | Valeur indicative | Commentaire |
|----------|-------------------|-------------|
| Contrôles **déjà** via `feature_permissions` | **~170** middlewares route + catalogue ~119 clés + `assertGestion`/`userCan` | Socle vivant |
| En dur **migrables** sans changer le comportement | **~31** `m365.groups:` routes + GS_* AppShell/services alignables sur clés existantes | Remplacer par `feature.permission:` équivalente |
| En dur **sans équivalent** → permission à créer | À nommer : ex. `aktor.access` (aujourd'hui `GS_GROUPE`), évent. scissions admin (`admin.systeme` déjà séparée), gaps Aktor/CIE si non couverts | Script §4 liste clés catalogue vs consommées |

### Comptabilité

| ressource / action | mécanique | groupes en dur | perm. existante ? | nature | fichier |
|--------------------|-----------|----------------|-------------------|--------|---------|
| Dépôt factures (routes) | A feature.permission | via mapping | `depot_factures.*` | 1 | routes/web.php |
| Validation NV1/NV2 | A + K workflow | mappings + can_validate | `depot_factures.validation*` | 1+K | Validation services |
| Comptabilisation | A | mappings | `comptabilisation.*` | 1 | ComptabilisationController |
| Visibilité factures | G | règles | — | **2** | FactureVisibility |
| Filtre enseigne dépôt | M confort | GS_HECTARE/GEMME/ENVOL | — | **3** | ProgrammeEnseigneComfortFiltre |
| CIE / prorata / retenues | A et/ou B | selon route | clés CIE si présentes | 1 | routes + controllers |
| Sage / nomenclature | A | mappings | référentiels / intacct | 1 | |

### Foncier

| ressource / action | mécanique | groupes en dur | perm. ? | nature | fichier |
|--------------------|-----------|----------------|---------|--------|---------|
| Lecture module | A | mappings | `foncier.lecture` | 1 | routes |
| Gestion (écriture) | A + D assertGestion | — (via PermissionService) | `foncier.gestion` | 1 | ParcelleController… |
| Prix | A + D | — | `foncier.prix` | 1 | |
| Secteurs / référentiels | A | — | `foncier.secteurs` / `foncier.referentiels` | 1 | |
| View dossier (ACL) | C + F | record | policy + habilitations | **2** | DossierFoncierPolicy |
| Offres / promesses / actes | A (+ policies si) | — | foncier.* | 1 | |

### Programmes / commercial / marchés

| ressource / action | mécanique | groupes en dur | perm. ? | nature | fichier |
|--------------------|-----------|----------------|---------|--------|---------|
| Programmes CRUD | A | mappings | `programmes.*` | 1 | routes |
| Accès programme | H | règles | mixte | 1/2 | ProgrammeAccessService |
| Lots / résa / grille | A | mappings | `lots.*` `reservations.*` `grille_prix.*` | 1 | |
| Marchés / avenants / TMA | A | mappings | `marches.*` | 1 | |
| Appels fonds | A | mappings | `appels_fonds.*` | 1 | |

### GEMME / juridique / annuaire / tâches / GED / admin / Aktor

| ressource / action | mécanique | groupes en dur | perm. ? | nature | fichier |
|--------------------|-----------|----------------|---------|--------|---------|
| Juridique | A | mappings | `juridique.*` | 1 | |
| Annuaire | A | mappings | `annuaire.*` | 1 | |
| Tâches CRUD | A | mappings | `tasks.*` | 1 | |
| Visibilité tâches | I | — | — | **2** | TacheSuivi |
| Documents / SP | A | mappings | `documents.*` | 1 | |
| Admin droits | A | mappings | `admin.droits.*` | 1 | DroitsController |
| Admin users/groupes | A et/ou B | GS_* parfois | `admin.*` | 1 | |
| Aktor | **B** `m365.groups:GS_GROUPE` | **GS_GROUPE** | **NON** (à créer `aktor.*`) | 1 | routes aktor |
| Nav AppShell | J front GS_* | nombreux GS_* | partiel via useUserCan | 3/1 | AppShell.vue |

Matrice route-par-route complète : générée côté OVH par `php tools/diag/diag_controle_acces_1234.php` §5 (écritures sans middleware feature/m365) + inventaire `feature.permission` / `m365.groups` dans le script.

---

## OBJET 3 — Existant `feature_permissions`

- **Fichier** `config/feature_permissions.php` : `catalog`, `default_mappings`, `authenticated_permissions` — **source d'amorçage**, pas la vérité runtime.
- **Vérité runtime** : tables MySQL `features_permissions` + `feature_permission_groups` (modifiable sans déploiement via `/admin/droits`).
- **Modèle** : **groupe → permission** (plusieurs groupes par permission ; patterns de seed).
- **Consommation** : middleware `feature.permission:`, helpers `userCan`, `PermissionService::userHasAnyPermission`, policies/services qui appellent le service ; front `useUserCan`.
- **Peut-il tout porter ?** Oui pour la nature 1, **après** migration des `m365.groups:` et suppression des GS_* hors admin. Nature 2/3 restent séparées.
- **Écran admin** : oui — `/admin/droits` : permissions par catégorie, multi-select groupes, journal `AuditLogPermission`, filtre recherche/groupe.
- **Cache config** : OVH n'utilise pas `config:cache` ; le fichier est relu. Le cache **permissions user** (300 s) est invalidé par `flushCache()` dans `DroitsController::updateCategory` — **déjà le bon pattern** (invalidation depuis l'UI, pas artisan).

---

## OBJET 4 — Anomalies

### GS_GROUPE / GS_GEMME / GS_PDG_ENVOL (soupçons #1233)

| Code | Usage | Si absent `groupes_m365` / jamais ingéré |
|------|-------|------------------------------------------|
| `GS_GROUPE` | Middleware Aktor | **Droit mort** : personne n'entre dans Aktor via ce middleware |
| `GS_GEMME` | Filtre confort enseigne | **Restriction jamais appliquée** (confort) — nature 3 |
| `GS_PDG_ENVOL` | `HandleInertiaRequests` (props) | Props / branche jamais vraie → **comportement mort** |

Mesure définitive : script §1–3 sur OVH (ne pas prendre la base locale stub).

Autres recherches (script + revue) :
- codes code ∉ DB / DB ∉ code ;
- permissions catalogue jamais sur une route ;
- routes POST/PUT/PATCH/DELETE sans `feature.permission` ni `m365.groups` (hors login/logout/csrf) — **failles potentielles** ;
- early-return permissifs / `catch` permissifs dans chemins authz — à scanner en phase 2 (ex. Gate `admin-ou-daf` utilise **GS_COMPTABILITE**, nom trompeur).

---

## OBJET 5 — Divergence front / back

- Front **dual** : `useUserCan` (permissions) **et** `hasM365Groupe` / conditions `GS_*` dans `AppShell.vue`, formulaires dépôt.
- Risques : bouton visible → 403 back ; bouton masqué → action autorisée.
- **Faille** si protection **uniquement** front (pas de middleware/policy) — signaler nominativement après script §5 croisé AppShell.

---

## OBJET 6 — Ne doit pas bouger (garde-fou)

| Élément | Pourquoi hors périmètre | Preuve d'intégrité après lot |
|---------|-------------------------|------------------------------|
| D62 / `record_habilitations` | Visibilité enregistrement | Tests/source + scénario manuel user D62 |
| `FactureVisibility` | Cloisonnement factures | Même jeu de factures visibles avant/après |
| Visibilité tâches #1209+ | Idem | Matrice user×tâche |
| Filtre confort enseigne #1188/#1190 | Nature 3 | Comportement filtre inchangé |
| Workflow `can_validate`/`can_block` | Métier validation | Scénarios NV1/NV2 Sabrina/Rudy |
| Absence SoD (dette 22) | **Ne pas « corriger »** ici | Documenter inchangé |

---

## OBJET 7 — Prérequis qualité / pdo_sqlite

- `pdo_sqlite` **absent** chez Cursor → Feature tests **skipped**.
- ~50+ fichiers Feature skip ; nombreux touchent 403/policy/permission.
- **Alternative** : tests source (modèle `FoncierControllersIntegritySourceTest`), script matrice avant/après, checklist manuelle domaines sensibles.
- **Recommandation** : le chantier **peut commencer sans** `pdo_sqlite` **à condition** (1) script d'équivalence rejouable, (2) tests source anti-régression `GS_*` hors admin, (3) tests manuels compta avec Sabrina/Rudy. Installer `pdo_sqlite` **fortement recommandé** avant bascule massives, **pas bloquant** pour un premier domaine pilote si preuves alternatives.

---

## OBJET 8 — Stratégie et découpage

### Modèle cible (convention existante)

Permissions déjà en `domaine.action` : `depot_factures.comptabiliser`, `marches.suppression`, `foncier.prix`… **S'y conformer.**

### Cohabitation

Pendant la transition, résoudre une permission si :
1. l'utilisateur a la permission via table (actuel), **OU**
2. (optionnel, temporaire) mapping legacy `permission → groupes GS_*` en dur dans un adaptateur **central unique** — pas dans chaque route.

Mieux : pour chaque domaine, remplacer `m365.groups:X` par `feature.permission:Y` **après** avoir vérifié que le mapping DB accorde Y exactement aux mêmes groupes que X. Pas de double vérité longue durée.

### Ordre de bascule (confirmé)

1. **Compta** (plus cadré / plus risqué — traiter en premier) — coordination Sabrina BARTOLI / Rudy BENOR ; test manuel réel si `DepotFactureController` / `ComptabilisationController`.
2. **Foncier**
3. **Commercial / marchés / programmes**
4. **Transverse** (admin menus, Aktor, AppShell)

### Preuve d'équivalence

Script rejouable : pour N users × M permissions, dump booléens avant/après (lecture seule). Diff = 0.

### Retour arrière

Feature flag ou revert commit domaine + `optimize:clear` ; mappings DB restaurables depuis `AuditLogPermission` / re-seed mappings.

### Sérialisation

Fichiers disputés : `routes/web.php`, `AppShell.vue`, `DepotFactureSaisieForm.vue` (~9500 lignes) — **lots sérialisés**, rebase `origin/main` avant build.

---

## OBJET 8 bis — Modèle cible voulu par Robin (instruction, pas code)

### Constat : déjà largement là

| Besoin Robin | Existant |
|--------------|----------|
| Table association groupe ↔ permission | `feature_permission_groups` (`feature_permission_id`, `groupe_m365`) |
| Catalogue permissions | `features_permissions` (+ seed depuis config) |
| Admin sans développeur | `/admin/droits` (cases / multi-select groupes) |
| Audit qui/quand/avant/après | `audit_log_permissions` (`AuditLogPermission`) — **préférer ça** à `FieldAuditService` (spécifique champs entités foncier) |
| Invalidation cache depuis UI | `PermissionService::flushCache()` déjà appelé après update |

### Schéma proposé (cible documentaire — **ne pas recréer** si tables existent)

- `features_permissions` : `feature_key` UNIQUE, libelle, categorie/domaine, description, niveau.
- `feature_permission_groups` : UNIQUE(`feature_permission_id`, `groupe_m365`), index `groupe_m365`.
- Option future : FK soft vers `groupes_m365.code` (aujourd'hui string libre filtrée par liste admin).

### Invariant chantier

Après refonte : `grep "GS_"` dans `app/` (hors couche admin / seed / diag) → **vide**. Le code ne connaît que des **codes permission**.

### Cohabitation config fichier

1. **Amorçage** : seeder depuis `config/feature_permissions.php` (déjà).
2. **Runtime** : tables seules.
3. Transition : fichier = catalogue de codes + defaults ; **ne plus** encoder des règles métier nouvelles dans le fichier une fois admin mature — ou garder le fichier uniquement pour **déclarer** les clés (déploiement = nouvelle permission code), mappings = base.

Nouvelle permission code = toujours un déploiement (clé dans catalogue + middleware). Changement d'**organisation** (qui a le droit) = **ligne admin**, pas déploiement.

### Cache

Conserver cache user 300 s + **flush depuis écran admin** (déjà). Ne jamais dépendre de `config:cache`. Option : flush global à chaque save droits (déjà) + évent. version stamp.

### Écran admin minimum

Déjà : tableau permissions × groupes. Renforcer : cases à cocher matrice groupe×permission, garde-fou anti-verrouillage, export diff.

### Garde-fou anti-verrouillage

Interdire de retirer le dernier groupe sur `admin.droits.ecriture` / `admin.systeme`, ou marquer `GS_DSI` / socle **non retirable** pour ces clés. Aujourd'hui le raccourci `GS_DSI` dans `PermissionService` agit déjà comme filet — **à conserver ou remplacer par règle DB explicite**, jamais les deux divergents.

---

## OBJET 9 — Volumétrie humaine

Mesure **uniquement sur OVH** via script §2 et §6 (base locale stub non représentative) :
- effectifs par groupe ;
- liste courte `GS_ADMIN_ERP` / `GS_DSI` (noms + groupes, **pas** mails complets) ;
- users sans groupe ;
- actifs (si colonnes last_login / timestamps existent).

Taux ~10 % : à objectiver avec §6.

---

## DÉPLOIEMENT / TEST

```text
git pull origin main
# pas de migrate, pas de build
php tools/diag/diag_controle_acces_1234.php
# coller sortie brute (sections 1–7)
```

## LEÇON

Le chantier n'est pas « inventer permissions » : **le socle DB + UI + audit + cache invalidable existe**. Le défaut est la **coexistence** de `m365.groups:`, raccourcis DSI/ADMIN dans le service, et centaines de `GS_*` front/back. La refonte = faire de `PermissionService` **l'unique** chemin nature 1, sans toucher natures 2/3, avec preuve d'équivalence user×action.
