## Résumé
Implémentation complète du système RBAC (Role-Based Access Control) pour Coltura.
### Backend
- Entités Permission et Role avec API Platform CRUD
- PermissionVoter : vérification des permissions effectives (rôles + directes), admin bypass
- Endpoints `PATCH /users/{id}/rbac` pour assigner rôles, permissions directes et isAdmin
- AdminHeadcountGuard : protection contre la suppression du dernier admin
- Commande `app:sync-permissions` pour synchroniser les permissions déclarées par les modules
- Filtrage sidebar par permission RBAC (`permission` key optionnelle dans sidebar.php)
- 115 tests PHPUnit (fonctionnels + unitaires)
### Frontend
- Composable `usePermissions()` avec `can()`, `canAny()`, `canAll()` et admin bypass
- Page `/admin/roles` : DataTable, création/édition via drawer, suppression avec confirmation
- Page `/admin/users` : DataTable, drawer RBAC avec rôles, permissions directes, résumé effectif
- PermissionGroup : checkboxes groupées par module avec "tout sélectionner"
- EffectivePermissions : résumé lecture seule avec badges source ("via Rôle X" / "Direct")
- Warning auto-édition, toggle isAdmin
- Tests Vitest pour usePermissions
### Permissions déclarées
- `core.users.view` — Voir les utilisateurs
- `core.users.manage` — Gérer les utilisateurs
- `core.roles.view` — Voir les rôles RBAC
- `core.roles.manage` — Gérer les rôles et permissions
- `GET /api/permissions` accessible à tout utilisateur authentifié (catalogue read-only)
## Tickets Lesstime
- ERP-23 (#343) — Entités Permission et Role
- ERP-24 (#344) — API CRUD Roles & Permissions
- ERP-25 (#345) — Voter Symfony + usePermissions
- ERP-26 (#346) — Interface Admin : Gestion des Rôles
- ERP-27 (#347) — Interface Admin : Permissions Utilisateur
## Test plan
- [ ] `make db-reset` puis vérifier les fixtures (admin/alice/bob, rôles système)
- [ ] Login admin : sidebar affiche Gestion des rôles + Utilisateurs
- [ ] Login alice : sidebar masque ces onglets (pas de permission)
- [ ] Page /admin/roles : CRUD rôles, permissions groupées, protection rôles système
- [ ] Page /admin/users : assignation rôles + permissions directes, résumé effectif
- [ ] Warning auto-édition quand admin modifie ses propres droits
- [ ] `make test` : 115 tests PHPUnit passent
- [ ] `cd frontend && npm run test` : tests Vitest passent
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Matthieu <mtholot19@gmail.com>
Co-authored-by: tristan <tristan@yuno.malio.fr>
Reviewed-on: #7
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
32 KiB
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
Roleavecid,code,label,description,isSystemet relation ManyToMany versPermission. - Creer l'entite
Permissionavecid,code,label,module,orphanet unicite surcode. - Faire evoluer
Useravec une relation ManyToMany versRole, une relation ManyToMany versPermissionpour les permissions directes et un booleenis_admin. - Faire evoluer
User::getRoles()pour rester compatible Symfony en retournant toujoursROLE_USERetROLE_ADMINsiis_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.phpet definir le pattern a reproduire pour les autres modules. - Ajouter une commande console
app:sync-permissionstransactionnelle, idempotente et non destructive avec gestionorphan. - Ajouter une migration Doctrine modulaire Core qui cree les tables RBAC, migre les donnees depuis
user.roles, cree les roles systemeadminetuser, puis supprime la colonne JSONroles. - 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 deSystemRoleDeletionExceptionvers403. - Ticket
#345: voter / authorisation applicative basee sur les permissions. - Ticket
#346: interfaces d'administration RBAC. - Ticket
#347: couche de traduction / UX des erreurs403et 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 etatorphan./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 dePermissionRepositoryInterface./home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php: implementation Doctrine deRoleRepositoryInterface.
Infrastructure - Doctrine Migrations
/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.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: commandeapp:sync-permissionsqui scanne les modules actifs et synchronise la tablepermission.
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 partageesADMIN_CODE = 'admin'etUSER_CODE = 'user', utilisees a la fois par les fixtures et par la migration SQL. Place dansDomain/Security/(pasValueObject/: 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 JSONroles, ajouterisAdmin,roles,directPermissions, initialiser les collections, configurer les relations ManyToMany enfetch=EAGER, ajoutergetEffectivePermissions()et adaptergetRoles()/ mutateurs./home/matthieu/dev_malio/Coltura/src/Module/Core/CoreModule.php: ajouter une methode statiquepublic static function permissions(): arrayqui declare les permissions natives du module Core et sert de reference pour les autres modules. Contenu initial exact :La clepublic 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'], ]; }modulen'est PAS presente dans le payload : elle est auto-injectee par la commande de sync a partir deCoreModule::ID. Le code de permission doit obligatoirement commencer parself::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,$directPermissionssont chargees par Doctrine via leurs mappingsfetch=EAGERdeclares sur l'entite. Si les tests d'integration revelent un lazy-load non voulu au refresh JWT ou a la desserialisation, ajouter une methodefindForSecurity(string $username): ?UseravecleftJoin+addSelectexplicites surroles,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 defindForSecurity()uniquement si le cas ci-dessus se materialise./home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php: remplacer l'usage desetRoles(array)par la creation des roles systeme, le rattachement des utilisateurs a ces roles et le positionnement deis_admin./home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/CreateUserCommand.php: remplacer la gestion historique deROLE_ADMINparsetIsAdmin(true)et rattachement au role systemeadminsi l'option--adminest conservee./home/matthieu/dev_malio/Coltura/config/services.yaml: ajouter 2 alias repository, aligne sur le pattern existant pourUserRepositoryInterface:La commandeApp\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository' App\Module\Core\Domain\Repository\PermissionRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository'SyncPermissionsCommandest auto-configuree viaautoconfigure: true, aucun binding manuel necessaire./home/matthieu/dev_malio/Coltura/config/modules.php: aucun changement de contenu requis, mais la commandeapp:sync-permissionsdevra 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
useretrolesont des mots reserves PostgreSQL ; Doctrine les quote automatiquement via#[ORM\Table(name: 'role')].
Entite Permission
#[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 :
codesuit la conventionmodule.resource[.sub].action(verifie par le sync command).modulecontient l'identifiant declare dans*Module::ID, auto-injecte par le sync.orphan = truesignifie "permission conservee en base mais absente de la declaration courante".
Entite Role
#[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<int, Permission> */
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'role_permission')]
private Collection $permissions;
}
Contraintes fonctionnelles :
codeporte la cle metier stable du role (admin,user, ...).isSystem = trueinterdit la suppression viaRole::ensureDeletable()au niveau domaine.fetch: EAGERsur$permissions: evite qu'unUser::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 colonneroles JSON). - Ajout :
#[ORM\Column(name: 'is_admin', options: ['default' => false])] private bool $isAdmin = false; /** @var Collection<int, Role> */ #[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')] #[ORM\JoinTable(name: 'user_role')] private Collection $roles; /** @var Collection<int, Permission> */ #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')] #[ORM\JoinTable(name: 'user_permission')] private Collection $directPermissions; $roleset$directPermissionssont initialises enArrayCollectiondans le constructeur, comme toute collection Doctrine.fetch: EAGERsur les 3 associations : critique pour eviter desgetRoles()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_permissionavec FKsON DELETE CASCADE. - Apres data-migration (section 6),
ALTER TABLE "user" DROP COLUMN roles;.
Etat final attendu :
- La colonne historique
roles JSON NOT NULLest supprimee. - La compatibilite Symfony passe par
User::getRoles()derivee de$isAdmin, plus aucune persistence framework. - Les 3 associations
User::$roles,User::$directPermissions,Role::$permissionssont 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<timestamp>.php et executer up() dans cet ordre.
Workflow recommande :
- Ecrire d'abord les entites
Permission,Roleet la mutation deUser(section 5). - Lancer
bin/console doctrine:migrations:diffqui genere le squelette SQL de structure (CREATE TABLE + FKs + DROP COLUMN). - 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. - Le fichier final vit dans le chemin ci-dessus en respectant le namespace configure dans
doctrine_migrations.yaml.
up() - ordre recommande apres edition manuelle
- Creer la colonne
"user".is_adminavecDEFAULT FALSE. - Creer les tables
permission,"role",role_permission,user_role,user_permissionet leurs indexes/foreign keys. - Inserer par SQL brut les roles systeme
adminetuseren s'appuyant sur les codes centralises dansSystemRoles. - Mettre a jour
"user".is_adminaTRUEpour les utilisateurs dont la colonne JSONrolescontientROLE_ADMIN. - Inserer dans
user_rolele roleadminpour les utilisateurs dont la colonne JSONrolescontientROLE_ADMIN. - Inserer dans
user_rolele roleuserpour les utilisateurs qui ne portent pasROLE_ADMIN, y compris sirolesvautNULL,[]ou["ROLE_USER"]. - Verifier que les insertions utilisent
ON CONFLICT DO NOTHINGou l'equivalent applicable afin de rester robustes face a une base deja partiellement migree sur un environnement de dev. - Supprimer la colonne
"user".rolesuniquement apres la migration de donnees.
SQL de migration de donnees - logique precise
Detection admin :
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 :
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 :
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 roleuser.roles = []: utilisateur non admin, rattache au roleuser.roles = ["ROLE_USER"]: utilisateur non admin, rattache au roleuser.roles = ["ROLE_ADMIN"]:is_admin = true, rattache au roleadmin, pas de roleuser.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 :
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: remplaceru.roles::jsonbparu.roles::text::jsonbdans les 3 requetes. - Si la colonne est deja
jsonb(rare) : remplaceru.roles::jsonbparu.roles.
down() - rollback minimal et coherent
- Recreer la colonne
"user".rolesenJSON NOT NULL DEFAULT '[]'. - Rehydrater
"user".rolesavec["ROLE_ADMIN"]siis_admin = true, sinon["ROLE_USER"]. - Supprimer les foreign keys et tables de jointure
user_permission,user_role,role_permission. - Supprimer les tables
permissionet"role". - 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 conventionmodule.resource[.sub].action, par exemplecore.users.viewoucommercial.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
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 deviennentorphan = true. - Reversible metier : une permission orpheline redeclaree repasse a
orphan = falseet 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 :
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 :
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 :
public function addPermission(Permission $permission): self
Ajoute la permission a la collection si absente, sans doublon.
Signature :
public function removePermission(Permission $permission): self
Retire la permission de la collection si presente.
Signature :
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 :
public function markOrphan(): self
Passe orphan a true sans detruire la permission.
Signature :
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 = adminlabel = Administrateurdescription = Role administrateur — bypass complet via is_adminisSystem = truepermissions = []— volontairement vide, le bypass fait tout le travail. Une foisapp:sync-permissionspasse, 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 = userlabel = Utilisateurdescription = Role de base sans permission specifiqueisSystem = truepermissions = []
Assignations utilisateurs
admin(user) :is_admin = true, roleadminalice:is_admin = false, roleuserbob:is_admin = false, roleuser- 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']quandis_admin = false.User::getRoles()retourne['ROLE_USER', 'ROLE_ADMIN']quandis_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()leveSystemRoleDeletionExceptionpour un role systeme.Permission::markOrphan()passeorphanatrue.Permission::revive()remetorphanafalseet met a jourlabel/module.
Integration - persistence et console
- La commande
app:sync-permissionscree les permissions declarees parCoreModule::permissions(). - Deux executions successives de
app:sync-permissionssur 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::$directPermissionsetRole::$permissionssans lazy loading hors EntityManager grace afetch=EAGER. - Les fixtures chargent les roles systeme et rattachent les utilisateurs attendus.
Integration - migration
up()sur une base contenantroles = ["ROLE_ADMIN"]creeis_admin = true, rattache le roleadminet supprime la colonne JSON.up()sur une base contenantroles = ["ROLE_USER"]rattache le roleuseret laisseis_admin = false.up()sur une base contenantroles = ["ROLE_ADMIN", "ROLE_USER"]ne cree aucun doublon et conserve le comportement admin.up()sur une base contenantroles = []ouNULLrattache quand meme le roleuser.down()recree une colonnerolesJSON exploitable et restaureROLE_ADMINouROLE_USERde 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 testapplique deja les migrations sur une base isolee : les tests peuvent etre ecrits en utilisantKernelTestCase+EntityManager+MigrationRepository. - Sinon, ajouter
DAMADoctrineTestBundle(transactionne chaque test) ou une recipe dedieemake test-migrationqui 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/ROLLBACKa 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=EAGERsurUser::$roles,User::$directPermissionsetRole::$permissions, puis le verifier par tests d'integration.
- Mitigation : imposer
- Risque de perte de donnees pendant la suppression de la colonne
user.roles.- Mitigation : creer les roles systeme et inserer les jointures
user_roleavant toutDROP COLUMN, avec tests de migration sur etats mixtes.
- Mitigation : creer les roles systeme et inserer les jointures
- Risque de divergence entre migration SQL brute et fixtures sur les codes des roles systeme.
- Mitigation : centraliser
adminetuserdans/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Security/SystemRoles.phpet documenter que la migration doit reprendre ces valeurs telles quelles.
- Mitigation : centraliser
- Risque d'accumulation de permissions orphelines sur des environnements de dev ou apres refactors de codes.
- Mitigation : conserver
orphan = truepour 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.
- Mitigation : conserver
- 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 ajouterpermissions()dans les tickets suivants.
- Mitigation : traiter l'absence de methode comme
- Risque de cout SQL/ORM du
fetch=EAGERquand 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_adminet role systemeadmin.- Mitigation : regle gravee a partir de ce ticket.
is_adminest le SEUL levier technique de bypass — c'est lui qui fait qu'un admin peut tout faire, via le futurPermissionVoter(ticket #345). Le roleadminest 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 = trueET rattachement au roleadmin) pour le compte admin, mais la logique d'autorisation ne regarde QUEis_admin+ les permissions effectives. Ne jamais coderif ($user->hasRole('admin')): toujoursif ($user->isAdmin())ouis_granted('permission.code').
- Mitigation : regle gravee a partir de ce ticket.
12. Ordre d'exécution recommandé
- Creer
Permission,Role,SystemRoleDeletionExceptionetSystemRoles. - Creer
PermissionRepositoryInterface,RoleRepositoryInterfaceet leurs implementations Doctrine. - Faire evoluer
/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.phpavecis_admin,roles,directPermissions,getRoles()etgetEffectivePermissions(). - Ajouter
CoreModule::permissions()et documenter le pattern de declaration statique pour les autres modules. - Ajouter la commande
/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php. - Ecrire la migration
/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.phpavec schema + migration de donnees + down(). - Mettre a jour
/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.phpet/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/CreateUserCommand.php. - Ajouter les alias repository dans
/home/matthieu/dev_malio/Coltura/config/services.yaml. - Ecrire les tests unitaires et d'integration couvrant domaine, sync, fixtures et migration.
13. Critères d'acceptation (DoD)
- Les entites
RoleetPermissionexistent dans le module Core avec mappings Doctrine valides et identifiants anglais. Userne persiste plus de colonne JSONrolesapres migration, mais expose toujours ungetRoles()compatible Symfony.User::getEffectivePermissions()retourne l'union sans doublon des permissions de roles et des permissions directes.CoreModuleexpose une methode statiquepermissions()servant de reference au mecanisme de sync.- La commande
app:sync-permissionsest transactionnelle, idempotente, non destructive et gere correctementorphan = true/ revival. - Les roles systeme
adminetusersont crees par la migration et par les fixtures avecisSystem = true. - La migration convertit de facon sure les etats historiques
ROLE_ADMIN,ROLE_USER, tableau vide,NULLet combinaisons mixtes sans perte de comptes. - La suppression d'un role systeme leve
SystemRoleDeletionExceptionau niveau domaine. - Les associations
User::$roles,User::$directPermissionsetRole::$permissionssont explicitement configurees enfetch=EAGERet ce point est verifie par tests. - Les fixtures attribuent
is_admin = true+ roleadmina l'utilisateuradmin, et le roleuseraux utilisateurs standards. - Le spec est compatible avec l'architecture modulaire actuelle basee sur
/home/matthieu/dev_malio/Coltura/config/modules.phpet n'introduit aucune resource API Platform ni voter dans ce ticket.