# Ticket #343 - 1/5 - Entités Permission et Role (Backend) ## 1. Objectif Ce ticket livre la base RBAC backend de l'epic en 5 tickets en remplacant le stockage historique des roles Symfony dans `User::$roles` par un modele metier explicite `Role` + `Permission` + rattachements utilisateur. Le resultat attendu est un socle de persistance et de synchronisation utilisable par les tickets suivants pour exposer l'API, brancher les voters et alimenter les interfaces. Le ticket couvre aussi la migration de donnees depuis la colonne JSON existante et l'outillage necessaire pour synchroniser les permissions declarees par les modules actifs. ## 2. Périmètre ### IN - Creer l'entite `Role` avec `id`, `code`, `label`, `description`, `isSystem` et relation ManyToMany vers `Permission`. - Creer l'entite `Permission` avec `id`, `code`, `label`, `module`, `orphan` et unicite sur `code`. - Faire evoluer `User` avec une relation ManyToMany vers `Role`, une relation ManyToMany vers `Permission` pour les permissions directes et un booleen `is_admin`. - Faire evoluer `User::getRoles()` pour rester compatible Symfony en retournant toujours `ROLE_USER` et `ROLE_ADMIN` si `is_admin = true`. - Ajouter `User::getEffectivePermissions()` pour retourner l'union des codes de permissions provenant des roles et des permissions directes. - Ajouter une methode statique `permissions()` sur `/home/matthieu/dev_malio/Coltura/src/Module/Core/CoreModule.php` et definir le pattern a reproduire pour les autres modules. - Ajouter une commande console `app:sync-permissions` transactionnelle, idempotente et non destructive avec gestion `orphan`. - Ajouter une migration Doctrine modulaire Core qui cree les tables RBAC, migre les donnees depuis `user.roles`, cree les roles systeme `admin` et `user`, puis supprime la colonne JSON `roles`. - Mettre a jour les fixtures Core pour creer les roles systeme et rattacher l'utilisateur admin au role `admin`. - Ajouter une protection domaine empechant la suppression d'un role systeme via `SystemRoleDeletionException`. - Integrer les decisions de hardening demandees: fetch `EAGER`, constantes partagees pour les codes systeme, synchronisation non destructive, commentaires PHP en francais, identifiants anglais, `declare(strict_types=1)`, colonnes PostgreSQL en minuscules. ### OUT - Ticket `#344` : ressources API Platform, providers, processors et traduction HTTP de `SystemRoleDeletionException` vers `403`. - Ticket `#345` : voter / authorisation applicative basee sur les permissions. - Ticket `#346` : interfaces d'administration RBAC. - Ticket `#347` : couche de traduction / UX des erreurs `403` et integration front complete. ## 3. Fichiers à créer ### Domaine - Entités - `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Permission.php` : entite Doctrine de permission RBAC, code unique, module source et etat `orphan`. - `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Role.php` : entite Doctrine de role RBAC avec relations vers permissions et garde de role systeme. ### Domaine - Repositories - `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php` : contrat de lecture/ecriture des permissions pour la commande de sync et les fixtures. - `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/RoleRepositoryInterface.php` : contrat de lecture/ecriture des roles pour migration fonctionnelle, fixtures et usages futurs. ### Domaine - Exceptions - `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Exception/SystemRoleDeletionException.php` : exception domaine levee si une suppression vise un role systeme. ### Infrastructure - Doctrine - `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php` : implementation Doctrine de `PermissionRepositoryInterface`. - `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php` : implementation Doctrine de `RoleRepositoryInterface`. ### Infrastructure - Doctrine Migrations - `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/Migrations/Version.php` : migration modulaire RBAC Core avec schema + migration de donnees + rollback minimal. ### Infrastructure - Console - `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php` : commande `app:sync-permissions` qui scanne les modules actifs et synchronise la table `permission`. ### Infrastructure - DataFixtures - Aucun nouveau fichier necessaire si la logique reste dans le fixture existant. ### Constantes domaine - `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Security/SystemRoles.php` : constantes partagees `ADMIN_CODE = 'admin'` et `USER_CODE = 'user'`, utilisees a la fois par les fixtures et par la migration SQL. Place dans `Domain/Security/` (pas `ValueObject/` : ce n'est pas un VO, c'est un conteneur de constantes metier laissant de la place pour d'autres constantes de securite plus tard). ## 4. Fichiers à modifier - `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php` : supprimer le stockage JSON `roles`, ajouter `isAdmin`, `roles`, `directPermissions`, initialiser les collections, configurer les relations ManyToMany en `fetch=EAGER`, ajouter `getEffectivePermissions()` et adapter `getRoles()` / mutateurs. - `/home/matthieu/dev_malio/Coltura/src/Module/Core/CoreModule.php` : ajouter une methode statique `public static function permissions(): array` qui declare les permissions natives du module Core et sert de reference pour les autres modules. Contenu initial exact : ```php public static function permissions(): array { return [ ['code' => 'core.users.view', 'label' => 'Voir les utilisateurs'], ['code' => 'core.users.manage', 'label' => 'Gerer les utilisateurs (creer, editer, supprimer)'], ['code' => 'core.roles.manage', 'label' => 'Gerer les roles et permissions'], ['code' => 'core.permissions.view', 'label' => 'Voir la liste des permissions'], ]; } ``` La cle `module` n'est PAS presente dans le payload : elle est auto-injectee par la commande de sync a partir de `CoreModule::ID`. Le code de permission doit obligatoirement commencer par `self::ID . '.'` sous peine d'echec de la sync (garde anti-typo). - `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php` : aucun changement attendu dans ce ticket. Les nouvelles relations `$roles`, `$directPermissions` sont chargees par Doctrine via leurs mappings `fetch=EAGER` declares sur l'entite. Si les tests d'integration revelent un lazy-load non voulu au refresh JWT ou a la desserialisation, ajouter une methode `findForSecurity(string $username): ?User` avec `leftJoin` + `addSelect` explicites sur `roles`, `roles.permissions`, `directPermissions`, et brancher le user provider dessus. A trancher par les tests, pas en prevention. - `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/UserRepositoryInterface.php` : aucun changement dans ce ticket. Ajout eventuel de `findForSecurity()` uniquement si le cas ci-dessus se materialise. - `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` : remplacer l'usage de `setRoles(array)` par la creation des roles systeme, le rattachement des utilisateurs a ces roles et le positionnement de `is_admin`. - `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/CreateUserCommand.php` : remplacer la gestion historique de `ROLE_ADMIN` par `setIsAdmin(true)` et rattachement au role systeme `admin` si l'option `--admin` est conservee. - `/home/matthieu/dev_malio/Coltura/config/services.yaml` : ajouter 2 alias repository, aligne sur le pattern existant pour `UserRepositoryInterface` : ```yaml App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository' App\Module\Core\Domain\Repository\PermissionRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository' ``` La commande `SyncPermissionsCommand` est auto-configuree via `autoconfigure: true`, aucun binding manuel necessaire. - `/home/matthieu/dev_malio/Coltura/config/modules.php` : aucun changement de contenu requis, mais la commande `app:sync-permissions` devra s'appuyer sur ce fichier comme source de verite des modules actifs. ## 5. Schéma cible — mappings Doctrine Plutot que de prescrire du SQL verbatim (risque de divergence avec la strategie `#[ORM\GeneratedValue]` par defaut utilisee par `User`, qui genere des sequences PostgreSQL, pas des colonnes `GENERATED IDENTITY`), on decrit le schema cible par les attributs Doctrine. Le SQL effectif sera produit par `bin/console doctrine:migrations:diff` puis augmente manuellement du data-migration step (section 6). Conventions : - `declare(strict_types=1)` en tete de tous les fichiers. - Identifiants de classe et proprietes en anglais, commentaires en francais (cf. `CLAUDE.md`). - PostgreSQL : noms de colonnes en snake_case minuscules, Doctrine les deduit des proprietes camelCase (ex: `isSystem` → `is_system`). - Les tables `user` et `role` sont des mots reserves PostgreSQL ; Doctrine les quote automatiquement via `#[ORM\Table(name: '`role`')]`. ### Entite `Permission` ```php #[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)] #[ORM\Table(name: 'permission')] #[ORM\UniqueConstraint(name: 'uniq_permission_code', columns: ['code'])] #[ORM\Index(name: 'idx_permission_module', columns: ['module'])] #[ORM\Index(name: 'idx_permission_orphan', columns: ['orphan'])] class Permission { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 255)] private string $code; #[ORM\Column(length: 255)] private string $label; #[ORM\Column(length: 100)] private string $module; #[ORM\Column(options: ['default' => false])] private bool $orphan = false; } ``` Contraintes fonctionnelles : - `code` suit la convention `module.resource[.sub].action` (verifie par le sync command). - `module` contient l'identifiant declare dans `*Module::ID`, auto-injecte par le sync. - `orphan = true` signifie "permission conservee en base mais absente de la declaration courante". ### Entite `Role` ```php #[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)] #[ORM\Table(name: '`role`')] #[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])] #[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])] class Role { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 100)] private string $code; #[ORM\Column(length: 255)] private string $label; #[ORM\Column(type: Types::TEXT, nullable: true)] private ?string $description = null; #[ORM\Column(name: 'is_system', options: ['default' => false])] private bool $isSystem = false; /** @var Collection */ #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')] #[ORM\JoinTable(name: 'role_permission')] private Collection $permissions; } ``` Contraintes fonctionnelles : - `code` porte la cle metier stable du role (`admin`, `user`, ...). - `isSystem = true` interdit la suppression via `Role::ensureDeletable()` au niveau domaine. - `fetch: EAGER` sur `$permissions` : evite qu'un `User::getEffectivePermissions()` cascade du lazy-loading hors contexte EntityManager (refresh JWT, serialisation asynchrone). ### Evolution de l'entite `User` - Suppression de la propriete `private array $roles = []` (et donc de la colonne `roles JSON`). - Ajout : ```php #[ORM\Column(name: 'is_admin', options: ['default' => false])] private bool $isAdmin = false; /** @var Collection */ #[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')] #[ORM\JoinTable(name: 'user_role')] private Collection $roles; /** @var Collection */ #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')] #[ORM\JoinTable(name: 'user_permission')] private Collection $directPermissions; ``` - `$roles` et `$directPermissions` sont initialises en `ArrayCollection` dans le constructeur, comme toute collection Doctrine. - `fetch: EAGER` sur les 3 associations : critique pour eviter des `getRoles()` silencieusement tronques pendant un refresh de token JWT (cf. risque section 11). ### Evolution de la table `user` (SQL final apres diff) La migration introduira : - `ALTER TABLE "user" ADD COLUMN is_admin BOOLEAN DEFAULT FALSE NOT NULL;` - Creation de `user_role`, `user_permission` avec FKs `ON DELETE CASCADE`. - Apres data-migration (section 6), `ALTER TABLE "user" DROP COLUMN roles;`. Etat final attendu : - La colonne historique `roles JSON NOT NULL` est supprimee. - La compatibilite Symfony passe par `User::getRoles()` derivee de `$isAdmin`, plus aucune persistence framework. - Les 3 associations `User::$roles`, `User::$directPermissions`, `Role::$permissions` sont explicitement EAGER. ## 6. Plan de migration Doctrine La migration doit etre implementée dans `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/Migrations/Version.php` et executer `up()` dans cet ordre. **Workflow recommande** : 1. Ecrire d'abord les entites `Permission`, `Role` et la mutation de `User` (section 5). 2. Lancer `bin/console doctrine:migrations:diff` qui genere le squelette SQL de structure (CREATE TABLE + FKs + DROP COLUMN). 3. Editer **manuellement** le fichier genere pour inserer le data-migration step entre la creation des tables et le `DROP COLUMN roles` — sinon les donnees admin sont perdues. 4. Le fichier final vit dans le chemin ci-dessus en respectant le namespace configure dans `doctrine_migrations.yaml`. ### `up()` - ordre recommande apres edition manuelle 1. Creer la colonne `"user".is_admin` avec `DEFAULT FALSE`. 2. Creer les tables `permission`, `"role"`, `role_permission`, `user_role`, `user_permission` et leurs indexes/foreign keys. 3. Inserer par SQL brut les roles systeme `admin` et `user` en s'appuyant sur les codes centralises dans `SystemRoles`. 4. Mettre a jour `"user".is_admin` a `TRUE` pour les utilisateurs dont la colonne JSON `roles` contient `ROLE_ADMIN`. 5. Inserer dans `user_role` le role `admin` pour les utilisateurs dont la colonne JSON `roles` contient `ROLE_ADMIN`. 6. Inserer dans `user_role` le role `user` pour les utilisateurs qui ne portent pas `ROLE_ADMIN`, y compris si `roles` vaut `NULL`, `[]` ou `["ROLE_USER"]`. 7. Verifier que les insertions utilisent `ON CONFLICT DO NOTHING` ou l'equivalent applicable afin de rester robustes face a une base deja partiellement migree sur un environnement de dev. 8. Supprimer la colonne `"user".roles` uniquement apres la migration de donnees. ### SQL de migration de donnees - logique precise Detection admin : ```sql UPDATE "user" u SET is_admin = TRUE WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code WHERE role_code = 'ROLE_ADMIN' ); ``` Rattachement du role systeme `admin` : ```sql INSERT INTO user_role (user_id, role_id) SELECT u.id, r.id FROM "user" u CROSS JOIN "role" r WHERE r.code = 'admin' AND EXISTS ( SELECT 1 FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code WHERE role_code = 'ROLE_ADMIN' ) ON CONFLICT DO NOTHING; ``` Rattachement du role systeme `user` : ```sql INSERT INTO user_role (user_id, role_id) SELECT u.id, r.id FROM "user" u CROSS JOIN "role" r WHERE r.code = 'user' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code WHERE role_code = 'ROLE_ADMIN' ) ON CONFLICT DO NOTHING; ``` Cas couverts explicitement : - `roles = NULL` : traite comme tableau vide, utilisateur non admin, rattache au role `user`. - `roles = []` : utilisateur non admin, rattache au role `user`. - `roles = ["ROLE_USER"]` : utilisateur non admin, rattache au role `user`. - `roles = ["ROLE_ADMIN"]` : `is_admin = true`, rattache au role `admin`, pas de role `user`. - `roles = ["ROLE_ADMIN", "ROLE_USER"]` : meme resultat que ci-dessus, sans doublon. ### Caveat : type reel de la colonne `user.roles` Le mapping Doctrine actuel (`array` PHP → default) peut avoir genere une colonne `JSON` OU `TEXT` selon la version de Symfony/Doctrine. Le cast `::jsonb` fonctionne directement sur `JSON`, mais pas sur `TEXT`. **Avant d'executer la migration en prod**, verifier avec : ```bash docker exec -it db-coltura psql -U malio -d coltura -c '\d "user"' ``` - Si `roles | json` : le SQL ci-dessus fonctionne tel quel. - Si `roles | text` : remplacer `u.roles::jsonb` par `u.roles::text::jsonb` dans les 3 requetes. - Si la colonne est deja `jsonb` (rare) : remplacer `u.roles::jsonb` par `u.roles`. ### `down()` - rollback minimal et coherent 1. Recreer la colonne `"user".roles` en `JSON NOT NULL DEFAULT '[]'`. 2. Rehydrater `"user".roles` avec `["ROLE_ADMIN"]` si `is_admin = true`, sinon `["ROLE_USER"]`. 3. Supprimer les foreign keys et tables de jointure `user_permission`, `user_role`, `role_permission`. 4. Supprimer les tables `permission` et `"role"`. 5. Supprimer la colonne `"user".is_admin`. Le rollback ne restitue pas la granularite RBAC complete, ce qui est acceptable pour un `down()` technique, mais il doit restituer une application compatible avec le modele historique base sur `ROLE_ADMIN` / `ROLE_USER`. ## 7. Algorithme sync-permissions La commande `app:sync-permissions` doit vivre dans `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php` et encapsuler toute l'operation dans une transaction Doctrine unique. ### Source de verite - Le scan des modules actifs vient de `/home/matthieu/dev_malio/Coltura/config/modules.php`. - Chaque classe module active peut exposer `public static function permissions(): array`. - Par compatibilite montante, si une classe module n'expose pas encore `permissions()`, elle est traitee comme retournant `[]`. ### Format de declaration attendu Chaque entree retournee par `*Module::permissions()` contient STRICTEMENT deux cles : - `code` — string suivant la convention `module.resource[.sub].action`, par exemple `core.users.view` ou `commercial.quote.line.delete`. - `label` — string lisible en francais, affichee dans l'UI d'administration des roles. La cle `module` N'EST PAS dans le payload : elle est **auto-injectee** par la commande de sync a partir de `$moduleClass::ID`. Ainsi on ne peut pas declarer accidentellement une permission d'un autre module. Garde anti-typo : le sync command verifie que chaque `code` commence obligatoirement par `$moduleClass::ID . '.'`. Si non, la commande echoue avec un message explicite citant la classe module et le code incrimine, sans toucher a la base. ### Pseudocode ```text begin transaction load active module classes from /home/matthieu/dev_malio/Coltura/config/modules.php desired_permissions = empty map keyed by code for each module class: if method permissions() exists: declared_permissions = module class::permissions() else: declared_permissions = [] for each declared permission in declared_permissions: assert array_keys(permission) == ['code', 'label'] // erreur explicite sinon assert str_starts_with(permission.code, module class::ID + '.') // erreur explicite sinon normalized = { code: permission.code, label: permission.label, module: module class::ID, // auto-injection } desired_permissions[code] = normalized load existing permissions from database indexed by code for each code in desired_permissions: if code exists in database: update label update module set orphan = false else: insert permission with orphan = false for each database permission code not present in desired_permissions: set orphan = true flush commit transaction ``` ### Propriétés attendues - Idempotente : deux executions consecutives sans changement de declarations ne doivent produire aucun delta metier. - Non destructive : aucune suppression de ligne `permission`; les permissions disparues deviennent `orphan = true`. - Reversible metier : une permission orpheline redeclaree repasse a `orphan = false` et voit ses metadonnees mises a jour. - Transactionnelle : pas d'etat intermediaire visible si une validation ou un flush echoue. ## 8. Méthodes clés détaillées ### User Signature : ```text public function getRoles(): array ``` Retourne toujours `ROLE_USER`. Ajoute `ROLE_ADMIN` si `is_admin = true`. Ne lit plus aucune colonne JSON. Le resultat doit etre deduplique et stable pour Symfony Security. Signature : ```text public function getEffectivePermissions(): array ``` Retourne l'union des codes des permissions issues de `User::$roles[*]->permissions` et de `User::$directPermissions`. Le resultat est un tableau unique de chaines, sans doublon, trie de facon deterministe si possible pour des assertions de test stables. ### Role Signature : ```text public function addPermission(Permission $permission): self ``` Ajoute la permission a la collection si absente, sans doublon. Signature : ```text public function removePermission(Permission $permission): self ``` Retire la permission de la collection si presente. Signature : ```text public function ensureDeletable(): void ``` Leve `SystemRoleDeletionException` si `isSystem = true`. Cette garde doit vivre dans le domaine, meme si la traduction HTTP vers `403` est hors scope de ce ticket. ### Permission Signature : ```text public function markOrphan(): self ``` Passe `orphan` a `true` sans detruire la permission. Signature : ```text public function revive(string $label, string $module): self ``` Repasse `orphan` a `false` et remet a jour les metadonnees issues de la declaration modulaire. ## 9. Fixtures mises à jour Le fichier cible reste `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php`. ### Principe cle : decouplage via `is_admin` Le role `admin` n'a **pas besoin** de contenir "toutes les permissions" pour rendre l'admin techniquement tout-puissant : cette capacite vient du bypass `is_admin = true` dans le futur `PermissionVoter` (ticket #345). Le role `admin` est donc un **conteneur metier semantique** (il represente le bundle "administrateur") mais n'est pas fonctionnellement requis pour que l'admin puisse tout faire. Consequence directe : les fixtures deviennent auto-suffisantes. Elles ne dependent plus d'un passage prealable de `app:sync-permissions`. `make db-reset && make fixtures` reste un one-shot. ### Jeu de donnees attendu - Role systeme `admin` (`SystemRoles::ADMIN_CODE`) - `code = admin` - `label = Administrateur` - `description = Role administrateur — bypass complet via is_admin` - `isSystem = true` - `permissions = []` — volontairement vide, le bypass fait tout le travail. Une fois `app:sync-permissions` passe, un admin pourra assigner via UI les permissions au role si besoin d'un scenario "quasi-admin sans bypass". - Role systeme `user` (`SystemRoles::USER_CODE`) - `code = user` - `label = Utilisateur` - `description = Role de base sans permission specifique` - `isSystem = true` - `permissions = []` ### Assignations utilisateurs - `admin` (user) : `is_admin = true`, role `admin` - `alice` : `is_admin = false`, role `user` - `bob` : `is_admin = false`, role `user` - Aucune permission directe (`directPermissions`) n'est prechargee dans ce ticket. ### Autonomie du workflow `make db-reset && make fixtures` fonctionne sans passer par `app:sync-permissions` au prealable, car aucune fixture ne depend du contenu de la table `permission`. Optionnellement, apres chargement des fixtures, l'utilisateur peut executer `bin/console app:sync-permissions` pour peupler la table `permission` avec les declarations de `CoreModule::permissions()`, mais c'est une etape independante et optionnelle a ce stade. ## 10. Plan de tests PHPUnit ### Unitaires - domaine - `User::getRoles()` retourne `['ROLE_USER']` quand `is_admin = false`. - `User::getRoles()` retourne `['ROLE_USER', 'ROLE_ADMIN']` quand `is_admin = true`. - `User::getEffectivePermissions()` fusionne permissions de roles et permissions directes sans doublon. - `User::getEffectivePermissions()` retourne un tableau vide pour un utilisateur sans role ni permission directe. - `Role::addPermission()` n'ajoute pas de doublon. - `Role::removePermission()` retire correctement une permission existante. - `Role::ensureDeletable()` leve `SystemRoleDeletionException` pour un role systeme. - `Permission::markOrphan()` passe `orphan` a `true`. - `Permission::revive()` remet `orphan` a `false` et met a jour `label` / `module`. ### Integration - persistence et console - La commande `app:sync-permissions` cree les permissions declarees par `CoreModule::permissions()`. - Deux executions successives de `app:sync-permissions` sur le meme jeu de modules sont idempotentes. - Une permission supprimee d'une declaration modulaire n'est pas deletee mais marquee `orphan = true`. - Une permission redeclaree apres etat orphelin est revivee avec `orphan = false`. - Les repositories Doctrine chargent bien `User::$roles`, `User::$directPermissions` et `Role::$permissions` sans lazy loading hors EntityManager grace a `fetch=EAGER`. - Les fixtures chargent les roles systeme et rattachent les utilisateurs attendus. ### Integration - migration - `up()` sur une base contenant `roles = ["ROLE_ADMIN"]` cree `is_admin = true`, rattache le role `admin` et supprime la colonne JSON. - `up()` sur une base contenant `roles = ["ROLE_USER"]` rattache le role `user` et laisse `is_admin = false`. - `up()` sur une base contenant `roles = ["ROLE_ADMIN", "ROLE_USER"]` ne cree aucun doublon et conserve le comportement admin. - `up()` sur une base contenant `roles = []` ou `NULL` rattache quand meme le role `user`. - `down()` recree une colonne `roles` JSON exploitable et restaure `ROLE_ADMIN` ou `ROLE_USER` de facon coherente. ### Prerequis d'infrastructure de test Les tests d'integration migration up/down exigent une base de test dediee avec un outillage pour jouer/rejouer les migrations. Verifier l'etat de l'infra avant d'ecrire ces tests : - Si `make test` applique deja les migrations sur une base isolee : les tests peuvent etre ecrits en utilisant `KernelTestCase` + `EntityManager` + `MigrationRepository`. - Sinon, ajouter `DAMADoctrineTestBundle` (transactionne chaque test) ou une recipe dediee `make test-migration` qui monte une base jetable puis lance les migrations. - **Si l'outillage manque** : ne pas bloquer le ticket. Ecrire a la place un test SQL de bas niveau sur une base transactionnellement reinitialisee (via `BEGIN` / `ROLLBACK` a chaque cas) et poser une TODO explicite dans le ticket suivant pour normaliser l'infra de test migration. ## 11. Risques et points d'attention - Risque de chargement paresseux pendant refresh JWT, serialisation ou security context hors EntityManager. - Mitigation : imposer `fetch=EAGER` sur `User::$roles`, `User::$directPermissions` et `Role::$permissions`, puis le verifier par tests d'integration. - Risque de perte de donnees pendant la suppression de la colonne `user.roles`. - Mitigation : creer les roles systeme et inserer les jointures `user_role` avant tout `DROP COLUMN`, avec tests de migration sur etats mixtes. - Risque de divergence entre migration SQL brute et fixtures sur les codes des roles systeme. - Mitigation : centraliser `admin` et `user` dans `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Security/SystemRoles.php` et documenter que la migration doit reprendre ces valeurs telles quelles. - Risque d'accumulation de permissions orphelines sur des environnements de dev ou apres refactors de codes. - Mitigation : conserver `orphan = true` pour la non-destruction, mais ajouter un suivi explicite dans les tests et dans la documentation d'exploitation; une strategie de purge pourra etre traitee plus tard si necessaire. - Risque de sync incoherente entre dev et prod si un module actif ne declare pas encore `permissions()`. - Mitigation : traiter l'absence de methode comme `[]` pour la compatibilite immediate et documenter que chaque nouveau module devra ajouter `permissions()` dans les tickets suivants. - Risque de cout SQL/ORM du `fetch=EAGER` quand un utilisateur porte beaucoup de roles et permissions. - Mitigation : limiter pour l'instant le perimetre aux trois associations critiques et surveiller les requetes; un ajustement vers des requetes dediees pourra etre etudie si la volumetrie augmente. - Risque de semantique confuse entre `is_admin` et role systeme `admin`. - Mitigation : regle gravee a partir de ce ticket. `is_admin` est le SEUL levier technique de bypass — c'est lui qui fait qu'un admin peut tout faire, via le futur `PermissionVoter` (ticket #345). Le role `admin` est un **conteneur metier semantique** : il identifie visuellement les admins dans l'UI et laisse la porte ouverte a un scenario "quasi-admin sans bypass" (admin qui aurait beaucoup de permissions explicites mais pas le bypass). Les fixtures/migrations posent les deux (`is_admin = true` ET rattachement au role `admin`) pour le compte admin, mais la logique d'autorisation ne regarde QUE `is_admin` + les permissions effectives. Ne jamais coder `if ($user->hasRole('admin'))` : toujours `if ($user->isAdmin())` ou `is_granted('permission.code')`. ## 12. Ordre d'exécution recommandé 1. Creer `Permission`, `Role`, `SystemRoleDeletionException` et `SystemRoles`. 2. Creer `PermissionRepositoryInterface`, `RoleRepositoryInterface` et leurs implementations Doctrine. 3. Faire evoluer `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php` avec `is_admin`, `roles`, `directPermissions`, `getRoles()` et `getEffectivePermissions()`. 4. Ajouter `CoreModule::permissions()` et documenter le pattern de declaration statique pour les autres modules. 5. Ajouter la commande `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php`. 6. Ecrire la migration `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/Migrations/Version.php` avec schema + migration de donnees + down(). 7. Mettre a jour `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` et `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/CreateUserCommand.php`. 8. Ajouter les alias repository dans `/home/matthieu/dev_malio/Coltura/config/services.yaml`. 9. Ecrire les tests unitaires et d'integration couvrant domaine, sync, fixtures et migration. ## 13. Critères d'acceptation (DoD) - Les entites `Role` et `Permission` existent dans le module Core avec mappings Doctrine valides et identifiants anglais. - `User` ne persiste plus de colonne JSON `roles` apres migration, mais expose toujours un `getRoles()` compatible Symfony. - `User::getEffectivePermissions()` retourne l'union sans doublon des permissions de roles et des permissions directes. - `CoreModule` expose une methode statique `permissions()` servant de reference au mecanisme de sync. - La commande `app:sync-permissions` est transactionnelle, idempotente, non destructive et gere correctement `orphan = true` / revival. - Les roles systeme `admin` et `user` sont crees par la migration et par les fixtures avec `isSystem = true`. - La migration convertit de facon sure les etats historiques `ROLE_ADMIN`, `ROLE_USER`, tableau vide, `NULL` et combinaisons mixtes sans perte de comptes. - La suppression d'un role systeme leve `SystemRoleDeletionException` au niveau domaine. - Les associations `User::$roles`, `User::$directPermissions` et `Role::$permissions` sont explicitement configurees en `fetch=EAGER` et ce point est verifie par tests. - Les fixtures attribuent `is_admin = true` + role `admin` a l'utilisateur `admin`, et le role `user` aux utilisateurs standards. - Le spec est compatible avec l'architecture modulaire actuelle basee sur `/home/matthieu/dev_malio/Coltura/config/modules.php` et n'introduit aucune resource API Platform ni voter dans ce ticket.