From e3025bf2c945f7486bd8bd440f3dcff9881ce462 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 14 Apr 2026 16:26:49 +0200 Subject: [PATCH 01/13] docs(rbac) : plan et spec ticket #343 + conventions permissions - Spec detaillee des fondations RBAC backend (entites Role/Permission, sync command, migration, fixtures, tests) dans docs/rbac/ticket-343-spec.md - Ajout CLAUDE.md des regles projet : commentaires francais (PHP + TS/Vue) et convention de nommage des permissions module.resource[.sub].action Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 3 + docs/rbac/ticket-343-spec.md | 556 +++++++++++++++++++++++++++++++++++ 2 files changed, 559 insertions(+) create mode 100644 docs/rbac/ticket-343-spec.md diff --git a/CLAUDE.md b/CLAUDE.md index 76c50b1..030a208 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -222,11 +222,13 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash` ### Backend - Toujours `declare(strict_types=1)` en haut des fichiers PHP +- **Commentaires en francais** : tout commentaire PHP (docblock, inline, bloc) doit etre redige en francais. Le code (noms de classes, methodes, variables) reste en anglais. Objectif : faciliter la relecture par l'equipe FR sans polluer l'API publique du code. - API Platform : utiliser ApiResource, Providers, Processors — pas de controllers - Routes API prefixees `/api` (via `config/routes/api_platform.yaml`) - Le login (`/login_check`) est hors prefix `/api`, nginx reecrit `REQUEST_URI` vers `/login_check` - PHP CS Fixer : regles Symfony + PSR-12 + strict types - Roles : `ROLE_ADMIN`, `ROLE_USER` — hierarchie dans `security.yaml` +- **Permissions RBAC** : format obligatoire `module.resource[.subresource].action` en snake_case, ex : `core.users.view`, `commercial.clients.contacts.edit`. Declarees via la methode statique `permissions()` des `*Module.php`, synchronisees par la commande `app:sync-permissions`. Verification via `is_granted('module.resource.action')` cote API Platform et `usePermissions()` cote front. - PostgreSQL : noms de colonnes toujours en **minuscules** dans le SQL brut - Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour eviter le conflit avec API Platform `{id}` - Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux proprietes de l'entite cible @@ -235,6 +237,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash` ### Frontend - TypeScript strict +- **Commentaires en francais** : tout commentaire TS/Vue (JSDoc, inline, bloc) doit etre redige en francais. Le code reste en anglais. Meme regle que cote backend. - Composable `useApi()` pour tous les appels API (gere cookies, erreurs, toasts, i18n) - Stores Pinia : `useAuthStore` (auth), `useUiStore` (ui) - Middleware global `auth.global.ts` protege les routes + charge la sidebar apres login diff --git a/docs/rbac/ticket-343-spec.md b/docs/rbac/ticket-343-spec.md new file mode 100644 index 0000000..9ba04cb --- /dev/null +++ b/docs/rbac/ticket-343-spec.md @@ -0,0 +1,556 @@ +# 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. From f0ea9201f54a02c4a822c6e5abe0716029dd616e Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 14 Apr 2026 16:30:15 +0200 Subject: [PATCH 02/13] feat(core) : RBAC Task 1 - entites Permission et Role + domaine securite - Entite Permission avec methodes markOrphan/revive/updateMetadata - Entite Role avec addPermission/removePermission/ensureDeletable - Constantes SystemRoles (codes admin/user partages) - Exception SystemRoleDeletionException pour la garde de suppression - Tests unitaires couvrant le comportement domaine (pas de BDD) Ticket #343 - 1/7 : fondations RBAC (domaine pur, sans persistence). Les entites ne portent pas encore repositoryClass (ajoute en Task 2). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Module/Core/Domain/Entity/Permission.php | 105 +++++++++++++ src/Module/Core/Domain/Entity/Role.php | 144 ++++++++++++++++++ .../Exception/SystemRoleDeletionException.php | 27 ++++ .../Core/Domain/Security/SystemRoles.php | 23 +++ .../Core/Domain/Entity/PermissionTest.php | 57 +++++++ tests/Module/Core/Domain/Entity/RoleTest.php | 79 ++++++++++ .../Core/Domain/Security/SystemRolesTest.php | 24 +++ 7 files changed, 459 insertions(+) create mode 100644 src/Module/Core/Domain/Entity/Permission.php create mode 100644 src/Module/Core/Domain/Entity/Role.php create mode 100644 src/Module/Core/Domain/Exception/SystemRoleDeletionException.php create mode 100644 src/Module/Core/Domain/Security/SystemRoles.php create mode 100644 tests/Module/Core/Domain/Entity/PermissionTest.php create mode 100644 tests/Module/Core/Domain/Entity/RoleTest.php create mode 100644 tests/Module/Core/Domain/Security/SystemRolesTest.php diff --git a/src/Module/Core/Domain/Entity/Permission.php b/src/Module/Core/Domain/Entity/Permission.php new file mode 100644 index 0000000..f23759a --- /dev/null +++ b/src/Module/Core/Domain/Entity/Permission.php @@ -0,0 +1,105 @@ + false])] + private bool $orphan = false; + + public function __construct(string $code, string $label, string $module) + { + $this->code = $code; + $this->label = $label; + $this->module = $module; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCode(): string + { + return $this->code; + } + + public function getLabel(): string + { + return $this->label; + } + + public function getModule(): string + { + return $this->module; + } + + public function isOrphan(): bool + { + return $this->orphan; + } + + /** + * Marque la permission comme orpheline : son code n'est plus declare par + * aucun module. Elle reste en base pour preserver les assignations et + * permettre une reactivation ulterieure, mais doit etre ignoree par les + * verifications d'autorisation. + */ + public function markOrphan(): self + { + $this->orphan = true; + + return $this; + } + + /** + * Reactive une permission precedemment orpheline : son code reapparait + * dans le code source d'un module. On en profite pour rafraichir les + * metadonnees (libelle et module d'appartenance). + */ + public function revive(string $label, string $module): self + { + $this->orphan = false; + $this->label = $label; + $this->module = $module; + + return $this; + } + + /** + * Met a jour les metadonnees d'une permission active sans toucher a son + * statut d'orphelin. Utilise par la commande de synchronisation lorsque + * seul le libelle ou le module proprietaire a change cote code. + */ + public function updateMetadata(string $label, string $module): self + { + $this->label = $label; + $this->module = $module; + + return $this; + } +} diff --git a/src/Module/Core/Domain/Entity/Role.php b/src/Module/Core/Domain/Entity/Role.php new file mode 100644 index 0000000..8db87df --- /dev/null +++ b/src/Module/Core/Domain/Entity/Role.php @@ -0,0 +1,144 @@ + false])] + private bool $isSystem = false; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')] + #[ORM\JoinTable(name: 'role_permission')] + private Collection $permissions; + + public function __construct(string $code, string $label, bool $isSystem = false, ?string $description = null) + { + $this->code = $code; + $this->label = $label; + $this->isSystem = $isSystem; + $this->description = $description; + $this->permissions = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCode(): string + { + return $this->code; + } + + public function getLabel(): string + { + return $this->label; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function isSystem(): bool + { + return $this->isSystem; + } + + /** @return Collection */ + public function getPermissions(): Collection + { + return $this->permissions; + } + + /** + * Met a jour le libelle affichable du role. Le code reste immuable pour + * garantir la stabilite des references cote fixtures et migrations. + */ + public function setLabel(string $label): self + { + $this->label = $label; + + return $this; + } + + /** + * Met a jour la description libre du role (champ documentaire). + */ + public function setDescription(?string $description): self + { + $this->description = $description; + + return $this; + } + + /** + * Ajoute une permission au role. Idempotent : ajouter deux fois la meme + * permission n'entraine pas de doublon dans la collection. + */ + public function addPermission(Permission $permission): self + { + if (!$this->permissions->contains($permission)) { + $this->permissions->add($permission); + } + + return $this; + } + + /** + * Retire une permission du role. Idempotent : retirer une permission + * absente est un no-op silencieux. + */ + public function removePermission(Permission $permission): self + { + $this->permissions->removeElement($permission); + + return $this; + } + + /** + * Garde domaine : refuse la suppression d'un role marque comme systeme. + * La traduction HTTP (403) est faite au niveau application / API Platform. + */ + public function ensureDeletable(): void + { + if ($this->isSystem) { + throw SystemRoleDeletionException::forRole($this); + } + } +} diff --git a/src/Module/Core/Domain/Exception/SystemRoleDeletionException.php b/src/Module/Core/Domain/Exception/SystemRoleDeletionException.php new file mode 100644 index 0000000..aa34d01 --- /dev/null +++ b/src/Module/Core/Domain/Exception/SystemRoleDeletionException.php @@ -0,0 +1,27 @@ +getCode())); + } +} diff --git a/src/Module/Core/Domain/Security/SystemRoles.php b/src/Module/Core/Domain/Security/SystemRoles.php new file mode 100644 index 0000000..ba1d68c --- /dev/null +++ b/src/Module/Core/Domain/Security/SystemRoles.php @@ -0,0 +1,23 @@ +getId()); + self::assertSame('core.users.view', $permission->getCode()); + self::assertSame('Voir les utilisateurs', $permission->getLabel()); + self::assertSame('core', $permission->getModule()); + self::assertFalse($permission->isOrphan()); + } + + public function testMarkOrphanSetsFlag(): void + { + $permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core'); + + $permission->markOrphan(); + + self::assertTrue($permission->isOrphan()); + } + + public function testReviveResetsOrphanAndUpdatesMetadata(): void + { + $permission = new Permission('core.users.view', 'Old label', 'core'); + $permission->markOrphan(); + + $permission->revive('New label', 'commercial'); + + self::assertFalse($permission->isOrphan()); + self::assertSame('New label', $permission->getLabel()); + self::assertSame('commercial', $permission->getModule()); + } + + public function testUpdateMetadataDoesNotTouchOrphan(): void + { + $permission = new Permission('core.users.view', 'Old', 'core'); + $permission->markOrphan(); + + $permission->updateMetadata('Lbl', 'core'); + + self::assertTrue($permission->isOrphan()); + self::assertSame('Lbl', $permission->getLabel()); + } +} diff --git a/tests/Module/Core/Domain/Entity/RoleTest.php b/tests/Module/Core/Domain/Entity/RoleTest.php new file mode 100644 index 0000000..3910550 --- /dev/null +++ b/tests/Module/Core/Domain/Entity/RoleTest.php @@ -0,0 +1,79 @@ +getId()); + self::assertSame('custom', $role->getCode()); + self::assertSame('Custom', $role->getLabel()); + self::assertNull($role->getDescription()); + self::assertFalse($role->isSystem()); + self::assertTrue($role->getPermissions()->isEmpty()); + } + + public function testAddPermissionAddsOnce(): void + { + $role = new Role('custom', 'Custom'); + $permission = new Permission('core.users.view', 'Voir', 'core'); + + $role->addPermission($permission); + $role->addPermission($permission); + + self::assertSame(1, $role->getPermissions()->count()); + } + + public function testRemovePermissionRemovesWhenPresent(): void + { + $role = new Role('custom', 'Custom'); + $permission = new Permission('core.users.view', 'Voir', 'core'); + + $role->addPermission($permission); + $role->removePermission($permission); + + self::assertSame(0, $role->getPermissions()->count()); + } + + public function testRemovePermissionIsNoOpWhenAbsent(): void + { + $role = new Role('custom', 'Custom'); + $permission = new Permission('core.users.view', 'Voir', 'core'); + + $role->removePermission($permission); + + self::assertSame(0, $role->getPermissions()->count()); + } + + public function testEnsureDeletableAllowsNonSystemRole(): void + { + $role = new Role('custom', 'Custom', false); + + $role->ensureDeletable(); + + $this->expectNotToPerformAssertions(); + } + + public function testEnsureDeletableThrowsForSystemRole(): void + { + $role = new Role('admin', 'Admin', true); + + $this->expectException(SystemRoleDeletionException::class); + $this->expectExceptionMessage('admin'); + + $role->ensureDeletable(); + } +} diff --git a/tests/Module/Core/Domain/Security/SystemRolesTest.php b/tests/Module/Core/Domain/Security/SystemRolesTest.php new file mode 100644 index 0000000..0a1934e --- /dev/null +++ b/tests/Module/Core/Domain/Security/SystemRolesTest.php @@ -0,0 +1,24 @@ + Date: Tue, 14 Apr 2026 16:37:53 +0200 Subject: [PATCH 03/13] refactor(core) : RBAC Task 1 - polish apres revue qualite - Permission : guards constructeur (code/label/module non vides, code avec point) - Permission::revive() reutilise updateMetadata() pour eviter la duplication - Suppression de SystemRolesTest (tautologique, ne capture aucun comportement) - Role::permissions : commentaire explicite sur la raison du fetch EAGER - Alignement des types de retour sur static (style User.php) - Nouveau test Role::addPermission avec permissions distinctes Ticket #343 - Task 1 polish (revue qualite). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Module/Core/Domain/Entity/Permission.php | 36 +++++++++++++++---- src/Module/Core/Domain/Entity/Role.php | 17 ++++++--- .../Core/Domain/Entity/PermissionTest.php | 30 ++++++++++++++++ tests/Module/Core/Domain/Entity/RoleTest.php | 12 +++++++ .../Core/Domain/Security/SystemRolesTest.php | 24 ------------- 5 files changed, 84 insertions(+), 35 deletions(-) delete mode 100644 tests/Module/Core/Domain/Security/SystemRolesTest.php diff --git a/src/Module/Core/Domain/Entity/Permission.php b/src/Module/Core/Domain/Entity/Permission.php index f23759a..19e5ff5 100644 --- a/src/Module/Core/Domain/Entity/Permission.php +++ b/src/Module/Core/Domain/Entity/Permission.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Module\Core\Domain\Entity; use Doctrine\ORM\Mapping as ORM; +use InvalidArgumentException; // TODO: brancher repositoryClass au ticket 343 partie 2 (Task 2). #[ORM\Entity] @@ -31,8 +32,29 @@ class Permission #[ORM\Column(options: ['default' => false])] private bool $orphan = false; + /** + * Invariants : une permission doit avoir un code non vide respectant la + * convention "module.resource[.sub].action" (donc contenir au moins un + * point), un libelle non vide et un module proprietaire non vide. Ces + * garde-fous evitent la persistence de lignes incoherentes si un appelant + * (fixture, commande de synchro, import) oublie un champ ou passe une + * chaine vide. + */ public function __construct(string $code, string $label, string $module) { + if ('' === $code) { + throw new InvalidArgumentException('Le code de permission ne peut pas etre vide.'); + } + if (!str_contains($code, '.')) { + throw new InvalidArgumentException(sprintf('Le code de permission "%s" ne respecte pas la convention "module.resource[.sub].action".', $code)); + } + if ('' === $label) { + throw new InvalidArgumentException('Le libelle de permission ne peut pas etre vide.'); + } + if ('' === $module) { + throw new InvalidArgumentException('Le module proprietaire de la permission ne peut pas etre vide.'); + } + $this->code = $code; $this->label = $label; $this->module = $module; @@ -69,7 +91,7 @@ class Permission * permettre une reactivation ulterieure, mais doit etre ignoree par les * verifications d'autorisation. */ - public function markOrphan(): self + public function markOrphan(): static { $this->orphan = true; @@ -78,14 +100,14 @@ class Permission /** * Reactive une permission precedemment orpheline : son code reapparait - * dans le code source d'un module. On en profite pour rafraichir les - * metadonnees (libelle et module d'appartenance). + * dans le code source d'un module. Equivaut a updateMetadata() suivi d'un + * clearing du flag orphan ; on delegue a updateMetadata() pour ne pas + * dupliquer la logique d'affectation des metadonnees. */ - public function revive(string $label, string $module): self + public function revive(string $label, string $module): static { + $this->updateMetadata($label, $module); $this->orphan = false; - $this->label = $label; - $this->module = $module; return $this; } @@ -95,7 +117,7 @@ class Permission * statut d'orphelin. Utilise par la commande de synchronisation lorsque * seul le libelle ou le module proprietaire a change cote code. */ - public function updateMetadata(string $label, string $module): self + public function updateMetadata(string $label, string $module): static { $this->label = $label; $this->module = $module; diff --git a/src/Module/Core/Domain/Entity/Role.php b/src/Module/Core/Domain/Entity/Role.php index 8db87df..3782a58 100644 --- a/src/Module/Core/Domain/Entity/Role.php +++ b/src/Module/Core/Domain/Entity/Role.php @@ -42,6 +42,15 @@ class Role private bool $isSystem = false; /** @var Collection */ + // Choix deliberé de fetch: 'EAGER' (durcissement, pas oubli de perf) : + // - Evite un lazy-load silencieux pendant un refresh de token JWT ou une + // serialisation hors contexte EntityManager (voir ticket #343, section + // 11 risque #1) ou la collection serait inaccessible et provoquerait + // une erreur opaque. + // - Compromis accepte : surcout SQL volontaire, acceptable a l'echelle + // d'un CRM/ERP PME ou un role porte quelques dizaines de permissions. + // - Si la volumetrie augmente significativement : revoir vers une + // projection cachee (ticket a ouvrir a ce moment-la). #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')] #[ORM\JoinTable(name: 'role_permission')] private Collection $permissions; @@ -90,7 +99,7 @@ class Role * Met a jour le libelle affichable du role. Le code reste immuable pour * garantir la stabilite des references cote fixtures et migrations. */ - public function setLabel(string $label): self + public function setLabel(string $label): static { $this->label = $label; @@ -100,7 +109,7 @@ class Role /** * Met a jour la description libre du role (champ documentaire). */ - public function setDescription(?string $description): self + public function setDescription(?string $description): static { $this->description = $description; @@ -111,7 +120,7 @@ class Role * Ajoute une permission au role. Idempotent : ajouter deux fois la meme * permission n'entraine pas de doublon dans la collection. */ - public function addPermission(Permission $permission): self + public function addPermission(Permission $permission): static { if (!$this->permissions->contains($permission)) { $this->permissions->add($permission); @@ -124,7 +133,7 @@ class Role * Retire une permission du role. Idempotent : retirer une permission * absente est un no-op silencieux. */ - public function removePermission(Permission $permission): self + public function removePermission(Permission $permission): static { $this->permissions->removeElement($permission); diff --git a/tests/Module/Core/Domain/Entity/PermissionTest.php b/tests/Module/Core/Domain/Entity/PermissionTest.php index 0b039c8..af4695c 100644 --- a/tests/Module/Core/Domain/Entity/PermissionTest.php +++ b/tests/Module/Core/Domain/Entity/PermissionTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Tests\Module\Core\Domain\Entity; use App\Module\Core\Domain\Entity\Permission; +use InvalidArgumentException; use PHPUnit\Framework\TestCase; /** @@ -54,4 +55,33 @@ final class PermissionTest extends TestCase self::assertTrue($permission->isOrphan()); self::assertSame('Lbl', $permission->getLabel()); } + + public function testConstructorRejectsEmptyCode(): void + { + $this->expectException(InvalidArgumentException::class); + + new Permission('', 'Libelle', 'core'); + } + + public function testConstructorRejectsCodeWithoutDot(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('invalid_format'); + + new Permission('invalid_format', 'Libelle', 'core'); + } + + public function testConstructorRejectsEmptyLabel(): void + { + $this->expectException(InvalidArgumentException::class); + + new Permission('core.users.view', '', 'core'); + } + + public function testConstructorRejectsEmptyModule(): void + { + $this->expectException(InvalidArgumentException::class); + + new Permission('core.users.view', 'Libelle', ''); + } } diff --git a/tests/Module/Core/Domain/Entity/RoleTest.php b/tests/Module/Core/Domain/Entity/RoleTest.php index 3910550..23e8dd2 100644 --- a/tests/Module/Core/Domain/Entity/RoleTest.php +++ b/tests/Module/Core/Domain/Entity/RoleTest.php @@ -37,6 +37,18 @@ final class RoleTest extends TestCase self::assertSame(1, $role->getPermissions()->count()); } + public function testAddPermissionAddsMultipleDistinct(): void + { + $role = new Role('custom', 'Custom'); + $permissionView = new Permission('core.users.view', 'Voir', 'core'); + $permissionEdit = new Permission('core.users.edit', 'Editer', 'core'); + + $role->addPermission($permissionView); + $role->addPermission($permissionEdit); + + self::assertSame(2, $role->getPermissions()->count()); + } + public function testRemovePermissionRemovesWhenPresent(): void { $role = new Role('custom', 'Custom'); diff --git a/tests/Module/Core/Domain/Security/SystemRolesTest.php b/tests/Module/Core/Domain/Security/SystemRolesTest.php deleted file mode 100644 index 0a1934e..0000000 --- a/tests/Module/Core/Domain/Security/SystemRolesTest.php +++ /dev/null @@ -1,24 +0,0 @@ - Date: Tue, 14 Apr 2026 16:40:44 +0200 Subject: [PATCH 04/13] feat(core) : RBAC Task 2 - repositories Permission et Role - PermissionRepositoryInterface avec findByCode et findAllCodes (pour le sync command et le futur PermissionVoter) - RoleRepositoryInterface avec findByCode - Implementations Doctrine alignees sur DoctrineUserRepository - Alias DI dans config/services.yaml - Rebranchement de repositoryClass sur les entites Permission et Role Ticket #343 - 2/7 : couche persistence RBAC. Co-Authored-By: Claude Opus 4.6 (1M context) --- config/services.yaml | 6 ++ src/Module/Core/Domain/Entity/Permission.php | 4 +- src/Module/Core/Domain/Entity/Role.php | 4 +- .../PermissionRepositoryInterface.php | 33 ++++++++++ .../Repository/RoleRepositoryInterface.php | 27 ++++++++ .../ApiPlatform/State/Provider/MeProvider.php | 3 +- .../Doctrine/DoctrinePermissionRepository.php | 62 +++++++++++++++++++ .../Doctrine/DoctrineRoleRepository.php | 45 ++++++++++++++ 8 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php create mode 100644 src/Module/Core/Domain/Repository/RoleRepositoryInterface.php create mode 100644 src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php create mode 100644 src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php diff --git a/config/services.yaml b/config/services.yaml index 9690014..9457e91 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -16,5 +16,11 @@ services: App\: resource: '../src/' + App\Module\Core\Domain\Repository\PermissionRepositoryInterface: + alias: App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository + + App\Module\Core\Domain\Repository\RoleRepositoryInterface: + alias: App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository + App\Module\Core\Domain\Repository\UserRepositoryInterface: alias: App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository diff --git a/src/Module/Core/Domain/Entity/Permission.php b/src/Module/Core/Domain/Entity/Permission.php index 19e5ff5..e5e92a0 100644 --- a/src/Module/Core/Domain/Entity/Permission.php +++ b/src/Module/Core/Domain/Entity/Permission.php @@ -4,11 +4,11 @@ declare(strict_types=1); namespace App\Module\Core\Domain\Entity; +use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository; use Doctrine\ORM\Mapping as ORM; use InvalidArgumentException; -// TODO: brancher repositoryClass au ticket 343 partie 2 (Task 2). -#[ORM\Entity] +#[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'])] diff --git a/src/Module/Core/Domain/Entity/Role.php b/src/Module/Core/Domain/Entity/Role.php index 3782a58..7583454 100644 --- a/src/Module/Core/Domain/Entity/Role.php +++ b/src/Module/Core/Domain/Entity/Role.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Module\Core\Domain\Entity; use App\Module\Core\Domain\Exception\SystemRoleDeletionException; +use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; @@ -17,8 +18,7 @@ use Doctrine\ORM\Mapping as ORM; * "personnalise" (cree par un administrateur). Seuls les roles personnalises * peuvent etre supprimes. */ -// TODO: brancher repositoryClass au ticket 343 partie 2 (Task 2). -#[ORM\Entity] +#[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'])] diff --git a/src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php b/src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php new file mode 100644 index 0000000..7464c93 --- /dev/null +++ b/src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php @@ -0,0 +1,33 @@ + + */ + public function findAll(): array; + + /** + * @return array liste des codes connus, pour la commande de sync et le futur voter + */ + public function findAllCodes(): array; + + public function save(Permission $permission): void; +} diff --git a/src/Module/Core/Domain/Repository/RoleRepositoryInterface.php b/src/Module/Core/Domain/Repository/RoleRepositoryInterface.php new file mode 100644 index 0000000..c952d7d --- /dev/null +++ b/src/Module/Core/Domain/Repository/RoleRepositoryInterface.php @@ -0,0 +1,27 @@ + + */ + public function findAll(): array; + + public function save(Role $role): void; +} diff --git a/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/MeProvider.php b/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/MeProvider.php index 5359d7b..ea088c8 100644 --- a/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/MeProvider.php +++ b/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/MeProvider.php @@ -6,6 +6,7 @@ namespace App\Module\Core\Infrastructure\ApiPlatform\State\Provider; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; +use Symfony\Bundle\SecurityBundle\Security; /** * @implements ProviderInterface @@ -13,7 +14,7 @@ use ApiPlatform\State\ProviderInterface; class MeProvider implements ProviderInterface { public function __construct( - private readonly \Symfony\Bundle\SecurityBundle\Security $security, + private readonly Security $security, ) {} public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object diff --git a/src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php b/src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php new file mode 100644 index 0000000..b62f2cb --- /dev/null +++ b/src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php @@ -0,0 +1,62 @@ + + */ +class DoctrinePermissionRepository extends ServiceEntityRepository implements PermissionRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Permission::class); + } + + public function findById(int $id): ?Permission + { + return $this->find($id); + } + + public function findByCode(string $code): ?Permission + { + return $this->findOneBy(['code' => $code]); + } + + /** + * @return array + */ + public function findAll(): array + { + return parent::findAll(); + } + + /** + * @return array + */ + public function findAllCodes(): array + { + // Requete legere : on ne selectionne que la colonne code (pas d'hydratation + // d'entites Permission) car findAllCodes() est appelee par la commande de + // sync et le futur voter qui n'ont besoin que des chaines. + $rows = $this->createQueryBuilder('p') + ->select('p.code') + ->getQuery() + ->getArrayResult() + ; + + return array_column($rows, 'code'); + } + + public function save(Permission $permission): void + { + $this->getEntityManager()->persist($permission); + $this->getEntityManager()->flush(); + } +} diff --git a/src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php b/src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php new file mode 100644 index 0000000..7667440 --- /dev/null +++ b/src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php @@ -0,0 +1,45 @@ + + */ +class DoctrineRoleRepository extends ServiceEntityRepository implements RoleRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Role::class); + } + + public function findById(int $id): ?Role + { + return $this->find($id); + } + + public function findByCode(string $code): ?Role + { + return $this->findOneBy(['code' => $code]); + } + + /** + * @return array + */ + public function findAll(): array + { + return parent::findAll(); + } + + public function save(Role $role): void + { + $this->getEntityManager()->persist($role); + $this->getEntityManager()->flush(); + } +} From 7aa32b1972d71ea979b3be1ba7f1e61d4dd9a419 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 14 Apr 2026 16:48:49 +0200 Subject: [PATCH 05/13] feat(core) : RBAC Task 3 - mutation User (isAdmin + roles RBAC + permissions directes) - Suppression de la colonne JSON roles (persiste jusqu'a la migration Task 5) - Ajout is_admin bool (seul levier de bypass RBAC via getRoles()) - Ajout ManyToMany User-Role (EAGER, table user_role) - Ajout ManyToMany User-Permission directes (EAGER, table user_permission) - getEffectivePermissions() : union dedupliquee triee, utilisee par le futur PermissionVoter (#345) - getRbacRoles() pour ne pas shadow getRoles() de UserInterface Symfony - Tests unitaires couvrant derivation getRoles, union, deduplication, tri Ticket #343 - 3/7 : migration du User vers le modele RBAC relationnel. Fetch EAGER documente : evite le lazy-load au refresh JWT. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Module/Core/Domain/Entity/User.php | 157 ++++++++++++++++-- .../Console/CreateUserCommand.php | 3 +- .../DataFixtures/AppFixtures.php | 5 +- tests/Module/Core/Domain/Entity/UserTest.php | 132 +++++++++++++++ 4 files changed, 282 insertions(+), 15 deletions(-) create mode 100644 tests/Module/Core/Domain/Entity/UserTest.php diff --git a/src/Module/Core/Domain/Entity/User.php b/src/Module/Core/Domain/Entity/User.php index cef1bd0..d06efdd 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -14,6 +14,8 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserPasswordHashe use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider; use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository; use DateTimeImmutable; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; @@ -52,10 +54,37 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[Groups(['me:read', 'user:list', 'user:write'])] private ?string $username = null; - /** @var list */ - #[ORM\Column] + #[ORM\Column(name: 'is_admin', options: ['default' => false])] #[Groups(['me:read', 'user:list', 'user:write'])] - private array $roles = []; + private bool $isAdmin = false; + + /** + * Les roles RBAC metier rattaches a l'utilisateur. + * + * Le fetch EAGER est delibere : evite un lazy-load silencieux pendant + * un refresh de token JWT ou une serialisation hors contexte EntityManager + * (cf. docs/rbac/ticket-343-spec.md section 11 risque 1). Le surcout SQL est + * accepte a l'echelle d'un CRM/ERP PME ; a revoir si la volumetrie augmente. + * + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')] + #[ORM\JoinTable(name: 'user_role')] + #[Groups(['me:read', 'user:list', 'user:write'])] + private Collection $roles; + + /** + * Les permissions directes accordees hors des roles. + * + * Meme justification EAGER que pour $roles : garantie que + * getEffectivePermissions() fonctionne dans tous les contextes de chargement. + * + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')] + #[ORM\JoinTable(name: 'user_permission')] + #[Groups(['me:read', 'user:list', 'user:write'])] + private Collection $directPermissions; #[ORM\Column] private ?string $password = null; @@ -68,7 +97,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface public function __construct() { - $this->createdAt = new DateTimeImmutable(); + $this->createdAt = new DateTimeImmutable(); + $this->roles = new ArrayCollection(); + $this->directPermissions = new ArrayCollection(); } public function getId(): ?int @@ -93,23 +124,127 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return (string) $this->username; } - /** @return list */ + /** + * Retourne les roles Symfony Security, derives de $isAdmin. + * + * ROLE_USER est toujours present pour que Symfony accepte l'authentification. + * ROLE_ADMIN est ajoute si l'utilisateur porte le flag is_admin — c'est le + * SEUL levier technique de bypass RBAC (cf. section 11 du spec). + * + * Important : ne JAMAIS iterer $this->roles (la Collection de Role) ici. + * Cette methode peut etre appelee pendant un refresh JWT, moment ou la + * Collection peut ne pas etre hydratee. On se contente d'un calcul base + * sur un scalaire. + * + * @return list + */ public function getRoles(): array { - $roles = $this->roles; - $roles[] = 'ROLE_USER'; + $roles = ['ROLE_USER']; - return array_values(array_unique($roles)); + if ($this->isAdmin) { + $roles[] = 'ROLE_ADMIN'; + } + + return $roles; } - /** @param list $roles */ - public function setRoles(array $roles): static + public function isAdmin(): bool { - $this->roles = $roles; + return $this->isAdmin; + } + + public function setIsAdmin(bool $isAdmin): static + { + $this->isAdmin = $isAdmin; return $this; } + /** + * Retourne la collection de roles RBAC rattaches a l'utilisateur. + * + * NE PAS confondre avec getRoles() qui renvoie les roles Symfony scalaires. + * + * @return Collection + */ + public function getRbacRoles(): Collection + { + return $this->roles; + } + + public function addRbacRole(Role $role): static + { + if (!$this->roles->contains($role)) { + $this->roles->add($role); + } + + return $this; + } + + public function removeRbacRole(Role $role): static + { + $this->roles->removeElement($role); + + return $this; + } + + /** + * @return Collection + */ + public function getDirectPermissions(): Collection + { + return $this->directPermissions; + } + + public function addDirectPermission(Permission $permission): static + { + if (!$this->directPermissions->contains($permission)) { + $this->directPermissions->add($permission); + } + + return $this; + } + + public function removeDirectPermission(Permission $permission): static + { + $this->directPermissions->removeElement($permission); + + return $this; + } + + /** + * Retourne l'union dedupliquee des codes de permissions effectives. + * + * Agrege les permissions venant des roles RBAC et les permissions directes. + * Utilisee par le PermissionVoter (ticket #345) et exposee via /api/me + * apres l'evolution du MeProvider (aussi ticket #345). + * + * Ne PAS appeler dans getRoles() : voir commentaire sur cette derniere + * methode pour le piege de chargement au refresh JWT. + * + * @return list + */ + public function getEffectivePermissions(): array + { + $codes = []; + + foreach ($this->roles as $role) { + foreach ($role->getPermissions() as $permission) { + $codes[$permission->getCode()] = true; + } + } + + foreach ($this->directPermissions as $permission) { + $codes[$permission->getCode()] = true; + } + + $keys = array_keys($codes); + sort($keys); + + return $keys; + } + public function getPassword(): ?string { return $this->password; diff --git a/src/Module/Core/Infrastructure/Console/CreateUserCommand.php b/src/Module/Core/Infrastructure/Console/CreateUserCommand.php index ba647e2..7133adc 100644 --- a/src/Module/Core/Infrastructure/Console/CreateUserCommand.php +++ b/src/Module/Core/Infrastructure/Console/CreateUserCommand.php @@ -49,7 +49,8 @@ class CreateUserCommand extends Command $user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword)); if ($input->getOption('admin')) { - $user->setRoles(['ROLE_ADMIN']); + // TODO Task 6 : attacher l'entite Role "admin" en plus du flag is_admin. + $user->setIsAdmin(true); } $this->userRepository->save($user); diff --git a/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php b/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php index f58a6bf..0ba45fa 100644 --- a/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php +++ b/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php @@ -17,21 +17,20 @@ class AppFixtures extends Fixture public function load(ObjectManager $manager): void { + // TODO Task 6 : cette fixture sera refactoree pour attacher les entites Role RBAC. $admin = new User(); $admin->setUsername('admin'); - $admin->setRoles(['ROLE_ADMIN']); + $admin->setIsAdmin(true); $admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin')); $manager->persist($admin); $alice = new User(); $alice->setUsername('alice'); - $alice->setRoles(['ROLE_USER']); $alice->setPassword($this->passwordHasher->hashPassword($alice, 'alice')); $manager->persist($alice); $bob = new User(); $bob->setUsername('bob'); - $bob->setRoles(['ROLE_USER']); $bob->setPassword($this->passwordHasher->hashPassword($bob, 'bob')); $manager->persist($bob); diff --git a/tests/Module/Core/Domain/Entity/UserTest.php b/tests/Module/Core/Domain/Entity/UserTest.php new file mode 100644 index 0000000..5c3b362 --- /dev/null +++ b/tests/Module/Core/Domain/Entity/UserTest.php @@ -0,0 +1,132 @@ +getRoles()); + } + + public function testGetRolesIncludesRoleAdminWhenIsAdminTrue(): void + { + $user = new User(); + $user->setIsAdmin(true); + + self::assertSame(['ROLE_USER', 'ROLE_ADMIN'], $user->getRoles()); + } + + public function testIsAdminDefaultsToFalse(): void + { + $user = new User(); + + self::assertFalse($user->isAdmin()); + } + + public function testGetEffectivePermissionsIsEmptyByDefault(): void + { + $user = new User(); + + self::assertSame([], $user->getEffectivePermissions()); + } + + public function testGetEffectivePermissionsUnionsRolesAndDirects(): void + { + $perm1 = new Permission('core.users.view', 'View users', 'core'); + $perm2 = new Permission('core.users.edit', 'Edit users', 'core'); + $perm3 = new Permission('core.users.delete', 'Delete users', 'core'); + + $role = new Role('manager', 'Manager'); + $role->addPermission($perm1); + $role->addPermission($perm2); + + $user = new User(); + $user->addRbacRole($role); + $user->addDirectPermission($perm3); + + self::assertSame( + ['core.users.delete', 'core.users.edit', 'core.users.view'], + $user->getEffectivePermissions(), + ); + } + + public function testGetEffectivePermissionsDeduplicatesAcrossRolesAndDirects(): void + { + $perm = new Permission('core.users.view', 'View users', 'core'); + + $role = new Role('viewer', 'Viewer'); + $role->addPermission($perm); + + $user = new User(); + $user->addRbacRole($role); + $user->addDirectPermission($perm); + + $result = $user->getEffectivePermissions(); + + self::assertCount(1, $result); + self::assertSame(['core.users.view'], $result); + } + + public function testAddRbacRoleIsIdempotent(): void + { + $role = new Role('manager', 'Manager'); + $user = new User(); + + $user->addRbacRole($role); + $user->addRbacRole($role); + + self::assertSame(1, $user->getRbacRoles()->count()); + } + + public function testAddDirectPermissionIsIdempotent(): void + { + $perm = new Permission('core.users.view', 'View users', 'core'); + $user = new User(); + + $user->addDirectPermission($perm); + $user->addDirectPermission($perm); + + self::assertSame(1, $user->getDirectPermissions()->count()); + } + + public function testRemoveRbacRole(): void + { + $role = new Role('manager', 'Manager'); + $user = new User(); + + $user->addRbacRole($role); + $user->removeRbacRole($role); + + self::assertSame(0, $user->getRbacRoles()->count()); + } + + public function testGetEffectivePermissionsOutputIsSorted(): void + { + $permZ = new Permission('core.z.action', 'Z', 'core'); + $permA = new Permission('core.a.action', 'A', 'core'); + $permM = new Permission('core.m.action', 'M', 'core'); + + $user = new User(); + $user->addDirectPermission($permZ); + $user->addDirectPermission($permA); + $user->addDirectPermission($permM); + + self::assertSame( + ['core.a.action', 'core.m.action', 'core.z.action'], + $user->getEffectivePermissions(), + ); + } +} From 3b1f18b0e08704c528f115483803988a4d9f00d1 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 14 Apr 2026 16:56:50 +0200 Subject: [PATCH 06/13] feat(core) : RBAC Task 4 - CoreModule::permissions() + SyncPermissionsCommand - CoreModule declare 4 permissions initiales (users.view/manage, roles.manage, permissions.view) - Nouvelle commande app:sync-permissions : * scan des *Module::permissions() via config/modules.php * validation stricte : cles [code, label], prefixe module, non-vides * upsert transactionnel non-destructif * revival des permissions orphelines qui reapparaissent * marquage orphan pour les permissions disparues du code * un seul flush() final (evite le flush-par-save de la repo save()) Ticket #343 - 4/7 : scanner et synchroniseur de permissions RBAC. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Module/Core/CoreModule.php | 27 +++ .../Console/SyncPermissionsCommand.php | 210 ++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php diff --git a/src/Module/Core/CoreModule.php b/src/Module/Core/CoreModule.php index 80fa614..d6d22a9 100644 --- a/src/Module/Core/CoreModule.php +++ b/src/Module/Core/CoreModule.php @@ -9,4 +9,31 @@ final class CoreModule public const string ID = 'core'; public const string LABEL = 'Core'; public const bool REQUIRED = true; + + /** + * Liste declarative des permissions RBAC exposees par le module Core. + * + * Consommee par la commande `app:sync-permissions` (SyncPermissionsCommand) + * qui se charge d'upserter ces entrees dans la table `permission`, de + * reactiver les codes precedemment marques orphelins et de marquer comme + * orphelins ceux qui ont disparu du code source. + * + * La cle `module` est auto-injectee par le sync command a partir de + * `self::ID`, il est donc inutile de la repeter dans chaque entree. + * + * Convention de nommage des codes : `module.resource[.sub].action` en + * snake_case, le prefixe module devant correspondre exactement a + * `self::ID` (verifie par la commande de synchronisation). + * + * @return array + */ + 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'], + ]; + } } diff --git a/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php b/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php new file mode 100644 index 0000000..a326b9b --- /dev/null +++ b/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php @@ -0,0 +1,210 @@ +collectDesiredPermissions(); + } catch (InvalidArgumentException $e) { + $io->error($e->getMessage()); + + return Command::FAILURE; + } + + // Etape 2 : upsert transactionnel non destructif. + $this->em->beginTransaction(); + + try { + // Indexation des permissions existantes par code pour un acces O(1). + $existingByCode = []; + foreach ($this->permissionRepository->findAll() as $permission) { + $existingByCode[$permission->getCode()] = $permission; + } + + $added = 0; + $updated = 0; + $orphans = 0; + + // Upsert : chaque entree desiree est creee, revivee ou mise a jour. + foreach ($desiredPermissions as $code => $entry) { + $label = $entry['label']; + $module = $entry['module']; + + if (isset($existingByCode[$code])) { + $existing = $existingByCode[$code]; + + if ($existing->isOrphan()) { + // Revival : le code reapparait dans le source, on + // rafraichit ses metadonnees et on retire le flag. + $existing->revive($label, $module); + ++$updated; + } elseif ($existing->getLabel() !== $label || $existing->getModule() !== $module) { + // Mise a jour des metadonnees sans toucher au flag orphan. + $existing->updateMetadata($label, $module); + ++$updated; + } + // Sinon : strictement identique, no-op. + } else { + // Creation : on persiste directement via l'EM pour ne + // pas declencher un flush par appel (cf. save() repo). + $permission = new Permission($code, $label, $module); + $this->em->persist($permission); + ++$added; + } + } + + // Etape 3 : marquage orphelin des permissions absentes du source. + foreach ($existingByCode as $code => $existing) { + if (isset($desiredPermissions[$code])) { + continue; + } + + if (!$existing->isOrphan()) { + $existing->markOrphan(); + ++$orphans; + } + } + + // Un unique flush regroupe toutes les mutations de la transaction. + $this->em->flush(); + $this->em->commit(); + } catch (Throwable $e) { + $this->em->rollback(); + $io->error(sprintf('Echec de la synchronisation des permissions : %s', $e->getMessage())); + + return Command::FAILURE; + } + + $totalInDb = count($this->permissionRepository->findAll()); + + $io->success('Synchronisation des permissions RBAC terminee.'); + $io->table( + ['Indicateur', 'Valeur'], + [ + ['Permissions ajoutees', (string) $added], + ['Permissions mises a jour ou revivees', (string) $updated], + ['Permissions marquees orphelines', (string) $orphans], + ['Total en base apres sync', (string) $totalInDb], + ], + ); + + return Command::SUCCESS; + } + + /** + * Parcourt la liste des modules actifs declares dans `config/modules.php`, + * extrait leurs permissions statiques, valide strictement chaque entree + * puis renvoie une map indexee par code. + * + * Regles de validation appliquees : + * - chaque entree doit posseder exactement les cles `code` et `label` + * - le `code` doit etre prefixe par `::ID . '.'` + * - `code` et `label` ne peuvent pas etre des chaines vides + * + * Les modules ne definissant pas de methode statique `permissions()` sont + * ignores silencieusement (compat ascendante pour les modules legacy). + * + * @return array + */ + private function collectDesiredPermissions(): array + { + /** @var array $moduleClasses */ + $moduleClasses = require $this->projectDir.'/config/modules.php'; + + $desired = []; + + foreach ($moduleClasses as $moduleClass) { + if (!method_exists($moduleClass, 'permissions')) { + continue; + } + + /** @var array> $entries */ + $entries = $moduleClass::permissions(); + $moduleId = $moduleClass::ID; + + foreach ($entries as $entry) { + $keys = array_keys($entry); + sort($keys); + if (['code', 'label'] !== $keys) { + throw new InvalidArgumentException(sprintf( + 'Permission malformee declaree par %s : chaque entree doit contenir exactement les cles [code, label], recu [%s].', + $moduleClass, + implode(', ', array_keys($entry)), + )); + } + + $code = $entry['code']; + $label = $entry['label']; + + if ('' === $code) { + throw new InvalidArgumentException(sprintf( + 'Permission invalide declaree par %s : le code ne peut pas etre vide.', + $moduleClass, + )); + } + if ('' === $label) { + throw new InvalidArgumentException(sprintf( + 'Permission invalide declaree par %s (code "%s") : le libelle ne peut pas etre vide.', + $moduleClass, + $code, + )); + } + + $expectedPrefix = $moduleId.'.'; + if (!str_starts_with($code, $expectedPrefix)) { + throw new InvalidArgumentException(sprintf( + 'Permission invalide declaree par %s : le code "%s" doit etre prefixe par "%s" (ID du module).', + $moduleClass, + $code, + $expectedPrefix, + )); + } + + $desired[$code] = [ + 'code' => $code, + 'label' => $label, + 'module' => $moduleId, + ]; + } + } + + return $desired; + } +} From d68aa0456ab2bb9086415c9788b969e276216d0a Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 14 Apr 2026 17:02:26 +0200 Subject: [PATCH 07/13] feat(core) : RBAC Task 5 - migration Doctrine RBAC + data-migration JSON roles - Nouvelles tables permission, role, role_permission, user_role, user_permission - Ajout user.is_admin (BOOLEAN, default false) - Seed des roles systeme admin et user via SQL brut (autonome, pas besoin de fixtures pour cette etape) - Migration des donnees : is_admin reflete ROLE_ADMIN du JSON roles, puis rattachement user_role selon admin/user - Drop user.roles en dernier (apres la migration de donnees) - down() recree la colonne roles et la rehydrate depuis is_admin Ticket #343 - 5/7 : persistance + migration donnees safe. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Migrations/Version20260414150034.php | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 src/Module/Core/Infrastructure/Doctrine/Migrations/Version20260414150034.php diff --git a/src/Module/Core/Infrastructure/Doctrine/Migrations/Version20260414150034.php b/src/Module/Core/Infrastructure/Doctrine/Migrations/Version20260414150034.php new file mode 100644 index 0000000..9129390 --- /dev/null +++ b/src/Module/Core/Infrastructure/Doctrine/Migrations/Version20260414150034.php @@ -0,0 +1,223 @@ + ADD is_admin + * -> seed des roles systeme -> migration des donnees -> DROP roles. + * + * Dependance avec Task 6 (fixtures) : + * Les fixtures applicatives reposent sur l'existence des roles systeme + * 'admin' et 'user' seedes ici par SQL brut. Cette migration est donc + * auto-suffisante et n'a pas besoin que les fixtures soient executees. + */ +final class Version20260414150034 extends AbstractMigration +{ + public function getDescription(): string + { + return 'RBAC : tables permission/role + jointures + is_admin + migration des donnees depuis user.roles'; + } + + public function up(Schema $schema): void + { + // 1) Creation des tables RBAC (permission, role, jointures). + $this->addSql(<<<'SQL' + CREATE TABLE permission ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + code VARCHAR(255) NOT NULL, + label VARCHAR(255) NOT NULL, + module VARCHAR(100) NOT NULL, + orphan BOOLEAN DEFAULT false NOT NULL, + PRIMARY KEY (id) + ) + SQL); + $this->addSql('CREATE INDEX idx_permission_module ON permission (module)'); + $this->addSql('CREATE INDEX idx_permission_orphan ON permission (orphan)'); + $this->addSql('CREATE UNIQUE INDEX uniq_permission_code ON permission (code)'); + $this->addSql(<<<'SQL' + CREATE TABLE "role" ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + code VARCHAR(100) NOT NULL, + label VARCHAR(255) NOT NULL, + description TEXT DEFAULT NULL, + is_system BOOLEAN DEFAULT false NOT NULL, + PRIMARY KEY (id) + ) + SQL); + $this->addSql('CREATE INDEX idx_role_is_system ON "role" (is_system)'); + $this->addSql('CREATE UNIQUE INDEX uniq_role_code ON "role" (code)'); + $this->addSql(<<<'SQL' + CREATE TABLE role_permission ( + role_id INT NOT NULL, + permission_id INT NOT NULL, + PRIMARY KEY (role_id, permission_id) + ) + SQL); + $this->addSql('CREATE INDEX IDX_6F7DF886D60322AC ON role_permission (role_id)'); + $this->addSql('CREATE INDEX IDX_6F7DF886FED90CCA ON role_permission (permission_id)'); + $this->addSql(<<<'SQL' + CREATE TABLE user_role ( + user_id INT NOT NULL, + role_id INT NOT NULL, + PRIMARY KEY (user_id, role_id) + ) + SQL); + $this->addSql('CREATE INDEX IDX_2DE8C6A3A76ED395 ON user_role (user_id)'); + $this->addSql('CREATE INDEX IDX_2DE8C6A3D60322AC ON user_role (role_id)'); + $this->addSql(<<<'SQL' + CREATE TABLE user_permission ( + user_id INT NOT NULL, + permission_id INT NOT NULL, + PRIMARY KEY (user_id, permission_id) + ) + SQL); + $this->addSql('CREATE INDEX IDX_472E5446A76ED395 ON user_permission (user_id)'); + $this->addSql('CREATE INDEX IDX_472E5446FED90CCA ON user_permission (permission_id)'); + $this->addSql(<<<'SQL' + ALTER TABLE + role_permission + ADD + CONSTRAINT FK_6F7DF886D60322AC FOREIGN KEY (role_id) REFERENCES "role" (id) ON DELETE CASCADE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE + role_permission + ADD + CONSTRAINT FK_6F7DF886FED90CCA FOREIGN KEY (permission_id) REFERENCES permission (id) ON DELETE CASCADE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE + user_role + ADD + CONSTRAINT FK_2DE8C6A3A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE + user_role + ADD + CONSTRAINT FK_2DE8C6A3D60322AC FOREIGN KEY (role_id) REFERENCES "role" (id) ON DELETE CASCADE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE + user_permission + ADD + CONSTRAINT FK_472E5446A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE + user_permission + ADD + CONSTRAINT FK_472E5446FED90CCA FOREIGN KEY (permission_id) REFERENCES permission (id) ON DELETE CASCADE + SQL); + + // 2) Ajout de la colonne is_admin sur "user" (avant la data-migration + // qui en a besoin pour marquer les super-admins). + $this->addSql('ALTER TABLE "user" ADD is_admin BOOLEAN DEFAULT false NOT NULL'); + + // 3) Seed des roles systeme avant toute migration de donnees utilisateurs. + // Les codes sont centralises dans App\Module\Core\Domain\Security\SystemRoles + // mais dupliques ici volontairement pour que la migration reste auto-suffisante. + $this->addSql("INSERT INTO \"role\" (code, label, description, is_system) VALUES ('admin', 'Administrateur', 'Role administrateur - bypass complet via is_admin', TRUE)"); + $this->addSql("INSERT INTO \"role\" (code, label, description, is_system) VALUES ('user', 'Utilisateur', 'Role de base sans permission specifique', TRUE)"); + + // 4) Bascule is_admin a TRUE pour tout user dont le JSON roles contient ROLE_ADMIN. + $this->addSql(<<<'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' + ) + SQL); + + // 5) Rattachement des admins au role systeme 'admin'. + $this->addSql(<<<'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 + SQL); + + // 6) Rattachement des autres users (y compris roles vide ou NULL) au role 'user'. + $this->addSql(<<<'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 + SQL); + + // 7) Drop de la colonne "user".roles (DOIT etre la derniere instruction, + // apres la migration des donnees qui la lit). + $this->addSql('ALTER TABLE "user" DROP roles'); + } + + public function down(Schema $schema): void + { + // 1) Recreation de la colonne roles (avec un defaut pour permettre le + // backfill). Le NOT NULL est conserve comme dans le schema d'origine. + $this->addSql('ALTER TABLE "user" ADD roles JSON NOT NULL DEFAULT \'[]\''); + + // 2) Rehydratation du JSON roles depuis is_admin avant de perdre la colonne. + $this->addSql(<<<'SQL' + UPDATE "user" + SET roles = CASE + WHEN is_admin THEN '["ROLE_ADMIN"]'::json + ELSE '["ROLE_USER"]'::json + END + SQL); + + // 3) Drop des FK puis des tables de jointure (enfants d'abord). + $this->addSql('ALTER TABLE role_permission DROP CONSTRAINT FK_6F7DF886D60322AC'); + $this->addSql('ALTER TABLE role_permission DROP CONSTRAINT FK_6F7DF886FED90CCA'); + $this->addSql('ALTER TABLE user_role DROP CONSTRAINT FK_2DE8C6A3A76ED395'); + $this->addSql('ALTER TABLE user_role DROP CONSTRAINT FK_2DE8C6A3D60322AC'); + $this->addSql('ALTER TABLE user_permission DROP CONSTRAINT FK_472E5446A76ED395'); + $this->addSql('ALTER TABLE user_permission DROP CONSTRAINT FK_472E5446FED90CCA'); + $this->addSql('DROP TABLE user_permission'); + $this->addSql('DROP TABLE user_role'); + $this->addSql('DROP TABLE role_permission'); + + // 4) Drop des tables parentes permission et role. + $this->addSql('DROP TABLE permission'); + $this->addSql('DROP TABLE "role"'); + + // 5) Drop de is_admin, la colonne roles ayant ete rehydratee en amont. + $this->addSql('ALTER TABLE "user" DROP is_admin'); + } +} From aafe08b6adb3fa2a6789c65ba2d220374a279353 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 14 Apr 2026 17:12:09 +0200 Subject: [PATCH 08/13] feat(core) : RBAC Task 6 - fixtures et CreateUserCommand branches sur les roles systeme - AppFixtures : rattachement des users aux entites Role via RoleRepositoryInterface. Re-seed idempotent des roles systeme dans ensureSystemRole() pour compenser le purger Doctrine qui vide la table role avant load(), afin que "make db-reset && make fixtures" reste un workflow one-shot. - CreateUserCommand : flag --admin attache au role systeme admin + is_admin, sinon au role user. Gestion d'erreur explicite si les roles systeme sont absents (FAILURE + message pointant vers la migration). - CreateUserCommand devient final, descriptions traduites en francais. Ticket #343 - 6/7 : fixtures et command alignes sur le RBAC relationnel. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Console/CreateUserCommand.php | 40 ++++++++++++++----- .../DataFixtures/AppFixtures.php | 38 +++++++++++++++++- 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/src/Module/Core/Infrastructure/Console/CreateUserCommand.php b/src/Module/Core/Infrastructure/Console/CreateUserCommand.php index 7133adc..f13afeb 100644 --- a/src/Module/Core/Infrastructure/Console/CreateUserCommand.php +++ b/src/Module/Core/Infrastructure/Console/CreateUserCommand.php @@ -5,7 +5,9 @@ declare(strict_types=1); namespace App\Module\Core\Infrastructure\Console; use App\Module\Core\Domain\Entity\User; +use App\Module\Core\Domain\Repository\RoleRepositoryInterface; use App\Module\Core\Domain\Repository\UserRepositoryInterface; +use App\Module\Core\Domain\Security\SystemRoles; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -17,13 +19,14 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; #[AsCommand( name: 'app:create-user', - description: 'Create a new user', + description: 'Cree un utilisateur rattache au role systeme admin ou user.', )] -class CreateUserCommand extends Command +final class CreateUserCommand extends Command { public function __construct( private readonly UserRepositoryInterface $userRepository, private readonly UserPasswordHasherInterface $passwordHasher, + private readonly RoleRepositoryInterface $roleRepository, ) { parent::__construct(); } @@ -31,9 +34,9 @@ class CreateUserCommand extends Command protected function configure(): void { $this - ->addArgument('username', InputArgument::REQUIRED, 'Username') - ->addArgument('password', InputArgument::REQUIRED, 'Plain password') - ->addOption('admin', null, InputOption::VALUE_NONE, 'Grant ROLE_ADMIN') + ->addArgument('username', InputArgument::REQUIRED, 'Nom d\'utilisateur') + ->addArgument('password', InputArgument::REQUIRED, 'Mot de passe en clair') + ->addOption('admin', null, InputOption::VALUE_NONE, 'Rattache au role systeme admin + active is_admin') ; } @@ -43,19 +46,34 @@ class CreateUserCommand extends Command $username = $input->getArgument('username'); $plainPassword = $input->getArgument('password'); + $isAdmin = (bool) $input->getOption('admin'); + + $roleCode = $isAdmin ? SystemRoles::ADMIN_CODE : SystemRoles::USER_CODE; + $role = $this->roleRepository->findByCode($roleCode); + + if (null === $role) { + $io->error(sprintf( + 'Le role systeme "%s" est introuvable. Lance "bin/console doctrine:migrations:migrate" pour le seeder.', + $roleCode, + )); + + return Command::FAILURE; + } $user = new User(); $user->setUsername($username); $user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword)); - - if ($input->getOption('admin')) { - // TODO Task 6 : attacher l'entite Role "admin" en plus du flag is_admin. - $user->setIsAdmin(true); - } + $user->setIsAdmin($isAdmin); + $user->addRbacRole($role); $this->userRepository->save($user); - $io->success(sprintf('User "%s" created%s.', $username, $input->getOption('admin') ? ' with ROLE_ADMIN' : '')); + $io->success(sprintf( + 'Utilisateur "%s" cree, rattache au role systeme "%s"%s.', + $username, + $roleCode, + $isAdmin ? ' (bypass is_admin actif)' : '', + )); return Command::SUCCESS; } diff --git a/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php b/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php index 0ba45fa..bde7cc8 100644 --- a/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php +++ b/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php @@ -4,36 +4,72 @@ declare(strict_types=1); namespace App\Module\Core\Infrastructure\DataFixtures; +use App\Module\Core\Domain\Entity\Role; use App\Module\Core\Domain\Entity\User; +use App\Module\Core\Domain\Repository\RoleRepositoryInterface; +use App\Module\Core\Domain\Security\SystemRoles; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; +/** + * Fixtures de base du module Core : 3 utilisateurs (1 admin + 2 standards) + * rattaches aux roles systeme RBAC seedes par la migration Version20260414150034. + * + * Note : le purger Doctrine execute avant load() supprime l'ensemble des + * entites managees, ce qui inclut la table role. On re-seede donc les roles + * systeme de maniere idempotente avant de rattacher les utilisateurs, afin + * que le workflow "make db-reset && make fixtures" reste one-shot. + */ class AppFixtures extends Fixture { public function __construct( private readonly UserPasswordHasherInterface $passwordHasher, + private readonly RoleRepositoryInterface $roleRepository, ) {} public function load(ObjectManager $manager): void { - // TODO Task 6 : cette fixture sera refactoree pour attacher les entites Role RBAC. + $adminRole = $this->ensureSystemRole($manager, SystemRoles::ADMIN_CODE, 'Administrateur'); + $userRole = $this->ensureSystemRole($manager, SystemRoles::USER_CODE, 'Utilisateur'); + $admin = new User(); $admin->setUsername('admin'); $admin->setIsAdmin(true); $admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin')); + $admin->addRbacRole($adminRole); $manager->persist($admin); $alice = new User(); $alice->setUsername('alice'); $alice->setPassword($this->passwordHasher->hashPassword($alice, 'alice')); + $alice->addRbacRole($userRole); $manager->persist($alice); $bob = new User(); $bob->setUsername('bob'); $bob->setPassword($this->passwordHasher->hashPassword($bob, 'bob')); + $bob->addRbacRole($userRole); $manager->persist($bob); $manager->flush(); } + + /** + * Retourne le role systeme correspondant au code donne, en le creant + * s'il n'existe pas encore (le purger Doctrine a pu vider la table role). + */ + private function ensureSystemRole(ObjectManager $manager, string $code, string $label): Role + { + $role = $this->roleRepository->findByCode($code); + + if (null !== $role) { + return $role; + } + + $role = new Role($code, $label, isSystem: true); + $manager->persist($role); + + return $role; + } } From 0a496f34e0844758edd7ca2c816ac4d98edcc4cc Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 14 Apr 2026 17:15:23 +0200 Subject: [PATCH 09/13] fix(core) : RBAC Task 6 polish - descriptions des roles systeme coherentes ensureSystemRole() recopie desormais la description depuis la migration RBAC pour que les chemins prod (migration) et dev (fixtures) produisent un etat identique. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../DataFixtures/AppFixtures.php | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php b/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php index bde7cc8..855bba5 100644 --- a/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php +++ b/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php @@ -30,8 +30,18 @@ class AppFixtures extends Fixture public function load(ObjectManager $manager): void { - $adminRole = $this->ensureSystemRole($manager, SystemRoles::ADMIN_CODE, 'Administrateur'); - $userRole = $this->ensureSystemRole($manager, SystemRoles::USER_CODE, 'Utilisateur'); + $adminRole = $this->ensureSystemRole( + $manager, + SystemRoles::ADMIN_CODE, + 'Administrateur', + 'Role administrateur - bypass complet via is_admin', + ); + $userRole = $this->ensureSystemRole( + $manager, + SystemRoles::USER_CODE, + 'Utilisateur', + 'Role de base sans permission specifique', + ); $admin = new User(); $admin->setUsername('admin'); @@ -58,16 +68,24 @@ class AppFixtures extends Fixture /** * Retourne le role systeme correspondant au code donne, en le creant * s'il n'existe pas encore (le purger Doctrine a pu vider la table role). + * + * La description est recopiee depuis la migration RBAC pour que les + * deux chemins (migration prod, fixtures dev) produisent un etat + * identique. */ - private function ensureSystemRole(ObjectManager $manager, string $code, string $label): Role - { + private function ensureSystemRole( + ObjectManager $manager, + string $code, + string $label, + string $description, + ): Role { $role = $this->roleRepository->findByCode($code); if (null !== $role) { return $role; } - $role = new Role($code, $label, isSystem: true); + $role = new Role($code, $label, isSystem: true, description: $description); $manager->persist($role); return $role; From eb0b49a7ef2e3f0fa5e4373c95915830df703989 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 14 Apr 2026 17:21:43 +0200 Subject: [PATCH 10/13] fix(core) : RBAC migration deplacee vers le namespace DoctrineMigrations racine Bug decouvert a l'execution de 'make db-reset' sur base vide : Doctrine Migrations 3.x avec plusieurs 'migrations_paths' execute les migrations dans l'ordre (namespace, version) et non (version, namespace). Le Version20260414150034 sous 'App\Module\Core\...' passait donc avant Version20260407095546 sous 'DoctrineMigrations', provoquant un "relation user does not exist". Deplacement du fichier vers 'migrations/' (namespace DoctrineMigrations). Le chemin modulaire reste configure pour les futurs modules, mais la migration RBAC d'initialisation vit a la racine pour que 'make db-reset' fonctionne en one-shot. Smoke test end-to-end valide : - db-reset + fixtures : admin (is_admin=t, role admin), alice/bob (is_admin=f, role user) - app:sync-permissions : 4 permissions Core ajoutees, idempotent au 2e run - User::getRoles() : ['ROLE_USER', 'ROLE_ADMIN'] pour admin, ['ROLE_USER'] pour alice/bob - User::getEffectivePermissions() : union triee des permissions via roles Ticket #343 - 7/7 : smoke test end-to-end OK. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Migrations => migrations}/Version20260414150034.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename {src/Module/Core/Infrastructure/Doctrine/Migrations => migrations}/Version20260414150034.php (99%) diff --git a/src/Module/Core/Infrastructure/Doctrine/Migrations/Version20260414150034.php b/migrations/Version20260414150034.php similarity index 99% rename from src/Module/Core/Infrastructure/Doctrine/Migrations/Version20260414150034.php rename to migrations/Version20260414150034.php index 9129390..04b5869 100644 --- a/src/Module/Core/Infrastructure/Doctrine/Migrations/Version20260414150034.php +++ b/migrations/Version20260414150034.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Module\Core\Infrastructure\Doctrine\Migrations; +namespace DoctrineMigrations; use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; From 7ccc91386202656411507d0d38100e6f765cec79 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 14 Apr 2026 17:25:26 +0200 Subject: [PATCH 11/13] docs : exception CLAUDE.md pour les migrations multi-namespace Documente le bug Doctrine Migrations 3.x (tri par FQCN au lieu de version timestamp avec plusieurs migrations_paths) et la regle provisoire : migrations d'init au namespace racine, namespace modulaire reserve aux migrations applicatives. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 030a208..4c80457 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,6 +140,7 @@ Le code du module Commercial n'est pas touche. - `config/modules.php` = seule source de verite pour l'activation - `config/sidebar.php` = seule source de verite pour l'organisation de la sidebar (chaque item reference son module owner via la cle `module`) - Migrations par module dans `src/Module/{Module}/Infrastructure/Doctrine/Migrations/` +- **Exception connue** : avec plusieurs `migrations_paths` configures, Doctrine Migrations 3.x trie les migrations par FQCN alphabetique et non par version timestamp → ordre d'execution incorrect entre namespaces sur une base vide. Tant que ce n'est pas resolu (via un `MigrationsComparator` custom ou un upgrade), les migrations d'initialisation critiques (setup user, RBAC, etc.) vivent au namespace racine `DoctrineMigrations` dans `migrations/`. Le namespace modulaire reste configure pour les futures migrations applicatives (qui dependent d'un schema deja cree). **Frontend :** - Chaque module est un layer Nuxt auto-detecte (`modules/*/nuxt.config.ts` minimal) From d8bda517f97eed31e746f9ed12c3b168156c7bfa Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 08:12:17 +0200 Subject: [PATCH 12/13] docs : ajoute note delegation Codex pour taches mecaniques --- CLAUDE.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 4c80457..3559259 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -266,3 +266,12 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash` - User admin : `admin` / `admin` (ROLE_ADMIN) - Users internes : `alice` / `alice`, `bob` / `bob` (ROLE_USER) + +## Delegation Codex + +Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification. + +- **Codex** = junior dev rapide et pas cher (executions mecaniques) +- **Claude** = senior dev qui verifie et reflechit (design, review, decisions) + +C'est le meilleur ratio qualite/credits. From 0fc4e1651b19ee73ea54cc8c5368cdd9f93abf8d Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 08:15:43 +0200 Subject: [PATCH 13/13] fix(core) : retire user:write des champs RBAC sensibles du User isAdmin, roles et directPermissions ne doivent pas etre modifiables via PATCH /api/users/{id}. L exposition en ecriture sera traitee par un processor dedie dans le ticket #344 (spec section 2 OUT). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Module/Core/Domain/Entity/User.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Module/Core/Domain/Entity/User.php b/src/Module/Core/Domain/Entity/User.php index d06efdd..b571488 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -55,7 +55,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface private ?string $username = null; #[ORM\Column(name: 'is_admin', options: ['default' => false])] - #[Groups(['me:read', 'user:list', 'user:write'])] + #[Groups(['me:read', 'user:list'])] private bool $isAdmin = false; /** @@ -70,7 +70,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface */ #[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')] #[ORM\JoinTable(name: 'user_role')] - #[Groups(['me:read', 'user:list', 'user:write'])] + #[Groups(['me:read', 'user:list'])] private Collection $roles; /** @@ -83,7 +83,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface */ #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')] #[ORM\JoinTable(name: 'user_permission')] - #[Groups(['me:read', 'user:list', 'user:write'])] + #[Groups(['me:read', 'user:list'])] private Collection $directPermissions; #[ORM\Column]