Files
Coltura/docs/rbac/ticket-343-spec.md
THOLOT DECHENE Matthieu e8c2789435
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
RBAC - Système complet de permissions (Backend + Frontend) (#7)
## 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>
2026-04-17 12:34:38 +00:00

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 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<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 : 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 :
    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 :
    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: isSystemis_system).
  • Les tables user et role sont 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 :

  • 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

#[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 :

  • 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 :
    #[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;
    
  • $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<timestamp>.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 :

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 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 :

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

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 :

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 = 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<timestamp>.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.