RBAC - Système complet de permissions (Backend + Frontend) (#7)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
## Résumé
Implémentation complète du système RBAC (Role-Based Access Control) pour Coltura.
### Backend
- Entités Permission et Role avec API Platform CRUD
- PermissionVoter : vérification des permissions effectives (rôles + directes), admin bypass
- Endpoints `PATCH /users/{id}/rbac` pour assigner rôles, permissions directes et isAdmin
- AdminHeadcountGuard : protection contre la suppression du dernier admin
- Commande `app:sync-permissions` pour synchroniser les permissions déclarées par les modules
- Filtrage sidebar par permission RBAC (`permission` key optionnelle dans sidebar.php)
- 115 tests PHPUnit (fonctionnels + unitaires)
### Frontend
- Composable `usePermissions()` avec `can()`, `canAny()`, `canAll()` et admin bypass
- Page `/admin/roles` : DataTable, création/édition via drawer, suppression avec confirmation
- Page `/admin/users` : DataTable, drawer RBAC avec rôles, permissions directes, résumé effectif
- PermissionGroup : checkboxes groupées par module avec "tout sélectionner"
- EffectivePermissions : résumé lecture seule avec badges source ("via Rôle X" / "Direct")
- Warning auto-édition, toggle isAdmin
- Tests Vitest pour usePermissions
### Permissions déclarées
- `core.users.view` — Voir les utilisateurs
- `core.users.manage` — Gérer les utilisateurs
- `core.roles.view` — Voir les rôles RBAC
- `core.roles.manage` — Gérer les rôles et permissions
- `GET /api/permissions` accessible à tout utilisateur authentifié (catalogue read-only)
## Tickets Lesstime
- ERP-23 (#343) — Entités Permission et Role
- ERP-24 (#344) — API CRUD Roles & Permissions
- ERP-25 (#345) — Voter Symfony + usePermissions
- ERP-26 (#346) — Interface Admin : Gestion des Rôles
- ERP-27 (#347) — Interface Admin : Permissions Utilisateur
## Test plan
- [ ] `make db-reset` puis vérifier les fixtures (admin/alice/bob, rôles système)
- [ ] Login admin : sidebar affiche Gestion des rôles + Utilisateurs
- [ ] Login alice : sidebar masque ces onglets (pas de permission)
- [ ] Page /admin/roles : CRUD rôles, permissions groupées, protection rôles système
- [ ] Page /admin/users : assignation rôles + permissions directes, résumé effectif
- [ ] Warning auto-édition quand admin modifie ses propres droits
- [ ] `make test` : 115 tests PHPUnit passent
- [ ] `cd frontend && npm run test` : tests Vitest passent
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Matthieu <mtholot19@gmail.com>
Co-authored-by: tristan <tristan@yuno.malio.fr>
Reviewed-on: MALIO-DEV/Coltura#7
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #7.
This commit is contained in:
556
docs/rbac/ticket-343-spec.md
Normal file
556
docs/rbac/ticket-343-spec.md
Normal file
@@ -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<timestamp>.php` : migration modulaire RBAC Core avec schema + migration de donnees + rollback minimal.
|
||||
|
||||
### Infrastructure - Console
|
||||
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php` : commande `app:sync-permissions` qui scanne les modules actifs et synchronise la table `permission`.
|
||||
|
||||
### Infrastructure - DataFixtures
|
||||
|
||||
- Aucun nouveau fichier necessaire si la logique reste dans le fixture existant.
|
||||
|
||||
### Constantes domaine
|
||||
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Security/SystemRoles.php` : constantes partagees `ADMIN_CODE = 'admin'` et `USER_CODE = 'user'`, utilisees a la fois par les fixtures et par la migration SQL. Place dans `Domain/Security/` (pas `ValueObject/` : ce n'est pas un VO, c'est un conteneur de constantes metier laissant de la place pour d'autres constantes de securite plus tard).
|
||||
|
||||
## 4. Fichiers à modifier
|
||||
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php` : supprimer le stockage JSON `roles`, ajouter `isAdmin`, `roles`, `directPermissions`, initialiser les collections, configurer les relations ManyToMany en `fetch=EAGER`, ajouter `getEffectivePermissions()` et adapter `getRoles()` / mutateurs.
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/CoreModule.php` : ajouter une methode statique `public static function permissions(): array` qui declare les permissions natives du module Core et sert de reference pour les autres modules. Contenu initial exact :
|
||||
```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<int, Permission> */
|
||||
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
|
||||
#[ORM\JoinTable(name: 'role_permission')]
|
||||
private Collection $permissions;
|
||||
}
|
||||
```
|
||||
|
||||
Contraintes fonctionnelles :
|
||||
- `code` porte la cle metier stable du role (`admin`, `user`, ...).
|
||||
- `isSystem = true` interdit la suppression via `Role::ensureDeletable()` au niveau domaine.
|
||||
- `fetch: EAGER` sur `$permissions` : evite qu'un `User::getEffectivePermissions()` cascade du lazy-loading hors contexte EntityManager (refresh JWT, serialisation asynchrone).
|
||||
|
||||
### Evolution de l'entite `User`
|
||||
|
||||
- Suppression de la propriete `private array $roles = []` (et donc de la colonne `roles JSON`).
|
||||
- Ajout :
|
||||
```php
|
||||
#[ORM\Column(name: 'is_admin', options: ['default' => false])]
|
||||
private bool $isAdmin = false;
|
||||
|
||||
/** @var Collection<int, Role> */
|
||||
#[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')]
|
||||
#[ORM\JoinTable(name: 'user_role')]
|
||||
private Collection $roles;
|
||||
|
||||
/** @var Collection<int, Permission> */
|
||||
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
|
||||
#[ORM\JoinTable(name: 'user_permission')]
|
||||
private Collection $directPermissions;
|
||||
```
|
||||
- `$roles` et `$directPermissions` sont initialises en `ArrayCollection` dans le constructeur, comme toute collection Doctrine.
|
||||
- `fetch: EAGER` sur les 3 associations : critique pour eviter des `getRoles()` silencieusement tronques pendant un refresh de token JWT (cf. risque section 11).
|
||||
|
||||
### Evolution de la table `user` (SQL final apres diff)
|
||||
|
||||
La migration introduira :
|
||||
- `ALTER TABLE "user" ADD COLUMN is_admin BOOLEAN DEFAULT FALSE NOT NULL;`
|
||||
- Creation de `user_role`, `user_permission` avec FKs `ON DELETE CASCADE`.
|
||||
- Apres data-migration (section 6), `ALTER TABLE "user" DROP COLUMN roles;`.
|
||||
|
||||
Etat final attendu :
|
||||
- La colonne historique `roles JSON NOT NULL` est supprimee.
|
||||
- La compatibilite Symfony passe par `User::getRoles()` derivee de `$isAdmin`, plus aucune persistence framework.
|
||||
- Les 3 associations `User::$roles`, `User::$directPermissions`, `Role::$permissions` sont explicitement EAGER.
|
||||
|
||||
## 6. Plan de migration Doctrine
|
||||
|
||||
La migration doit etre implementée dans `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.php` et executer `up()` dans cet ordre.
|
||||
|
||||
**Workflow recommande** :
|
||||
1. Ecrire d'abord les entites `Permission`, `Role` et la mutation de `User` (section 5).
|
||||
2. Lancer `bin/console doctrine:migrations:diff` qui genere le squelette SQL de structure (CREATE TABLE + FKs + DROP COLUMN).
|
||||
3. Editer **manuellement** le fichier genere pour inserer le data-migration step entre la creation des tables et le `DROP COLUMN roles` — sinon les donnees admin sont perdues.
|
||||
4. Le fichier final vit dans le chemin ci-dessus en respectant le namespace configure dans `doctrine_migrations.yaml`.
|
||||
|
||||
### `up()` - ordre recommande apres edition manuelle
|
||||
|
||||
1. Creer la colonne `"user".is_admin` avec `DEFAULT FALSE`.
|
||||
2. Creer les tables `permission`, `"role"`, `role_permission`, `user_role`, `user_permission` et leurs indexes/foreign keys.
|
||||
3. Inserer par SQL brut les roles systeme `admin` et `user` en s'appuyant sur les codes centralises dans `SystemRoles`.
|
||||
4. Mettre a jour `"user".is_admin` a `TRUE` pour les utilisateurs dont la colonne JSON `roles` contient `ROLE_ADMIN`.
|
||||
5. Inserer dans `user_role` le role `admin` pour les utilisateurs dont la colonne JSON `roles` contient `ROLE_ADMIN`.
|
||||
6. Inserer dans `user_role` le role `user` pour les utilisateurs qui ne portent pas `ROLE_ADMIN`, y compris si `roles` vaut `NULL`, `[]` ou `["ROLE_USER"]`.
|
||||
7. Verifier que les insertions utilisent `ON CONFLICT DO NOTHING` ou l'equivalent applicable afin de rester robustes face a une base deja partiellement migree sur un environnement de dev.
|
||||
8. Supprimer la colonne `"user".roles` uniquement apres la migration de donnees.
|
||||
|
||||
### SQL de migration de donnees - logique precise
|
||||
|
||||
Detection admin :
|
||||
|
||||
```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<timestamp>.php` avec schema + migration de donnees + down().
|
||||
7. Mettre a jour `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` et `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/CreateUserCommand.php`.
|
||||
8. Ajouter les alias repository dans `/home/matthieu/dev_malio/Coltura/config/services.yaml`.
|
||||
9. Ecrire les tests unitaires et d'integration couvrant domaine, sync, fixtures et migration.
|
||||
|
||||
## 13. Critères d'acceptation (DoD)
|
||||
|
||||
- Les entites `Role` et `Permission` existent dans le module Core avec mappings Doctrine valides et identifiants anglais.
|
||||
- `User` ne persiste plus de colonne JSON `roles` apres migration, mais expose toujours un `getRoles()` compatible Symfony.
|
||||
- `User::getEffectivePermissions()` retourne l'union sans doublon des permissions de roles et des permissions directes.
|
||||
- `CoreModule` expose une methode statique `permissions()` servant de reference au mecanisme de sync.
|
||||
- La commande `app:sync-permissions` est transactionnelle, idempotente, non destructive et gere correctement `orphan = true` / revival.
|
||||
- Les roles systeme `admin` et `user` sont crees par la migration et par les fixtures avec `isSystem = true`.
|
||||
- La migration convertit de facon sure les etats historiques `ROLE_ADMIN`, `ROLE_USER`, tableau vide, `NULL` et combinaisons mixtes sans perte de comptes.
|
||||
- La suppression d'un role systeme leve `SystemRoleDeletionException` au niveau domaine.
|
||||
- Les associations `User::$roles`, `User::$directPermissions` et `Role::$permissions` sont explicitement configurees en `fetch=EAGER` et ce point est verifie par tests.
|
||||
- Les fixtures attribuent `is_admin = true` + role `admin` a l'utilisateur `admin`, et le role `user` aux utilisateurs standards.
|
||||
- Le spec est compatible avec l'architecture modulaire actuelle basee sur `/home/matthieu/dev_malio/Coltura/config/modules.php` et n'introduit aucune resource API Platform ni voter dans ce ticket.
|
||||
275
docs/rbac/ticket-344-spec.md
Normal file
275
docs/rbac/ticket-344-spec.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# Ticket #344 - 2/5 - API CRUD Roles & Permissions (Backend)
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Exposer via API Platform le socle RBAC livre par le ticket #343 (entites `Role`, `Permission`, relations `User->roles`/`directPermissions`, flag `isAdmin`). Ce ticket livre la surface HTTP minimale permettant :
|
||||
|
||||
- de lister et consulter les permissions synchronisees par `app:sync-permissions`,
|
||||
- de gerer le cycle de vie des roles (CRUD) tout en protegeant les roles systeme,
|
||||
- d'attribuer `isAdmin`, les roles RBAC et les permissions directes a un utilisateur sans polluer le groupe `user:write` (commit `0fc4e16`).
|
||||
|
||||
Le ticket n'introduit **aucune logique d'autorisation metier** : toute la verification `is_granted('module.resource.action')` est traitee par le voter du ticket #345. A ce stade, les operations sont gardees par un simple `is_granted('ROLE_ADMIN')`, remplace au #345.
|
||||
|
||||
## 2. Perimetre
|
||||
|
||||
### IN
|
||||
|
||||
- Exposer l'entite `Permission` en API Platform en lecture seule (`GetCollection`, `Get`), groupe `permission:read`, filtres `module` et `orphan`.
|
||||
- Exposer l'entite `Role` en API Platform avec CRUD complet (`GetCollection`, `Get`, `Post`, `Patch`, `Delete`), groupes `role:read` et `role:write`, filtre `isSystem`.
|
||||
- Ajouter un processor `RoleProcessor` decorant `PersistProcessor` et `RemoveProcessor` pour :
|
||||
- refuser la suppression d'un role systeme en traduisant `SystemRoleDeletionException` en `403`,
|
||||
- empecher la mutation de `code` et `isSystem` sur un role systeme existant.
|
||||
- Ajouter une operation nommee `user_rbac_patch` (`PATCH /api/users/{id}/rbac`) sur l'entite `User` avec son propre groupe `user:rbac:write` exposant `isAdmin`, `roles` et `directPermissions`. Laisser `user:write` propre pour les champs profil (compatible avec la decision de `0fc4e16`). Le nom explicite est indispensable : API Platform 4 identifie les operations par nom, un `new Patch` sans `name:` entrerait en collision avec l'operation profil existante.
|
||||
- Ajouter un processor `UserRbacProcessor` qui persiste les mutations RBAC de l'utilisateur sans toucher au password hashing (decorator de `PersistProcessor`, pas du `UserPasswordHasherProcessor`).
|
||||
- Ajouter sur `Role` les contraintes Symfony Validator : `UniqueEntity(fields: ['code'])`, `Assert\NotBlank` et `Assert\Regex` sur `code`, `Assert\NotBlank` sur `label` (cf. section 6).
|
||||
- Garder toutes les operations sous `is_granted('ROLE_ADMIN')` avec un commentaire `// TODO ticket #345 : remplacer par is_granted('core.roles.manage')`.
|
||||
- Tests PHPUnit unitaires (processors) et fonctionnels (`ApiTestCase`) couvrant les chemins nominaux et les cas 403/422.
|
||||
|
||||
### OUT
|
||||
|
||||
- Ticket `#345` : voter `PermissionVoter`, remplacement du `is_granted('ROLE_ADMIN')` par les codes de permission, composable front `usePermissions`.
|
||||
- Ticket `#346` : ecrans d'administration front (liste/edition des roles et permissions).
|
||||
- Ticket `#347` : UX des erreurs 403 et integration front de l'ecran de gestion des permissions utilisateur.
|
||||
- Endpoint d'ecriture sur `Permission` : la table reste la propriete exclusive de `app:sync-permissions` (source de verite = code).
|
||||
- Lecture des permissions effectives d'un `User` via `/api/me` : traitee au #345 en meme temps que le voter.
|
||||
- Exposition d'un endpoint de bulk-assign permissions sur plusieurs utilisateurs : hors scope.
|
||||
|
||||
## 3. Fichiers a creer
|
||||
|
||||
### Infrastructure - Processors
|
||||
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php`
|
||||
Decorator de `ApiPlatform\Doctrine\Common\State\PersistProcessor` et `RemoveProcessor`. Charge de la garde `ensureDeletable()` et de la protection des champs immuables sur un role systeme.
|
||||
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php`
|
||||
Decorator de `PersistProcessor` specifique a l'operation `PATCH /api/users/{id}/rbac`. Persiste les mutations `isAdmin`, `roles`, `directPermissions` sans passer par `UserPasswordHasherProcessor`.
|
||||
|
||||
### Tests unitaires
|
||||
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessorTest.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessorTest.php`
|
||||
|
||||
### Tests fonctionnels
|
||||
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/PermissionApiTest.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/RoleApiTest.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/UserRbacApiTest.php`
|
||||
|
||||
## 4. Fichiers a modifier
|
||||
|
||||
### Entite `Permission`
|
||||
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Permission.php`
|
||||
|
||||
- Ajouter l'attribut `#[ApiResource]` avec operations `GetCollection` + `Get` uniquement.
|
||||
- Normalization context : groupe `permission:read` uniquement.
|
||||
- Pas de `denormalizationContext` (lecture seule).
|
||||
- Security `is_granted('ROLE_ADMIN')` sur les deux operations (TODO #345).
|
||||
- Ajouter `#[Groups(['permission:read'])]` sur `$id`, `$code`, `$label`, `$module`, `$orphan`. Pas d'ajout du groupe `role:read` : on laisse API Platform serialiser la relation `Role::$permissions` en IRIs par defaut, le front resoudra les details en 2 appels si necessaire (decision explicite pour garder les payloads petits et les permissions paginable independamment).
|
||||
- Ajouter les filtres API Platform `SearchFilter` sur `module` (exact) et `BooleanFilter` sur `orphan`.
|
||||
|
||||
Extrait attendu :
|
||||
|
||||
```php
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
normalizationContext: ['groups' => ['permission:read']],
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
normalizationContext: ['groups' => ['permission:read']],
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['module' => 'exact'])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['orphan'])]
|
||||
```
|
||||
|
||||
### Entite `Role`
|
||||
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Role.php`
|
||||
|
||||
- Ajouter l'attribut `#[ApiResource]` avec operations `GetCollection`, `Get`, `Post`, `Patch`, `Delete`.
|
||||
- Normalization context : `role:read`. Denormalization context : `role:write`.
|
||||
- Processor `RoleProcessor::class` sur `Post`, `Patch` et `Delete`.
|
||||
- Security `is_granted('ROLE_ADMIN')` sur les 5 operations (TODO #345).
|
||||
- Groupes :
|
||||
- `$id` : `role:read`.
|
||||
- `$code` : `role:read`, `role:write`. L'immuabilite apres creation est portee par `RoleProcessor` (variante A, cf. section 5), pas par un decoupage de groupes.
|
||||
- `$label` : `role:read`, `role:write`.
|
||||
- `$description` : `role:read`, `role:write`.
|
||||
- `$isSystem` : `role:read` (jamais writable via API).
|
||||
- `$permissions` : `role:read`, `role:write`. Serialise en IRIs (comportement API Platform par defaut sur une relation ManyToMany).
|
||||
- Filtre `BooleanFilter` sur `isSystem`.
|
||||
- **Important** : le constructeur actuel `public function __construct(string $code, string $label, bool $isSystem = false, ?string $description = null)` doit etre compatible avec la denormalisation API Platform sur `POST`. API Platform 4 resout les arguments du constructeur par nom de propriete denormalise. Verifier (ou adapter) que `isSystem` ne peut pas etre injecte par le POST car il n'est pas dans `role:write`.
|
||||
|
||||
### Entite `User`
|
||||
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php`
|
||||
|
||||
- Ajouter dans la liste des operations `ApiResource` existantes une operation dediee :
|
||||
|
||||
```php
|
||||
new Patch(
|
||||
name: 'user_rbac_patch',
|
||||
uriTemplate: '/users/{id}/rbac',
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
denormalizationContext: ['groups' => ['user:rbac:write']],
|
||||
processor: UserRbacProcessor::class,
|
||||
),
|
||||
```
|
||||
|
||||
Le `name:` est OBLIGATOIRE : sans lui, API Platform 4 deduit un nom par defaut qui peut collisionner avec la `Patch` profil existante (meme classe, meme methode HTTP) et provoquer un ecrasement silencieux de la route `/api/users/{id}`.
|
||||
|
||||
- Ajouter le groupe `user:rbac:write` sur les proprietes :
|
||||
- `$isAdmin`
|
||||
- `$roles`
|
||||
- `$directPermissions`
|
||||
- Ne PAS toucher `user:write` : la decision de `0fc4e16` est confirmee par ce ticket.
|
||||
|
||||
Raison de l'endpoint dedie (option B) :
|
||||
- Separation des preoccupations : un `PATCH /api/users/{id}` reste un endpoint "profil" ; la promotion admin et la gestion des permissions est un acte administratif explicite et tracable.
|
||||
- Facilite future l'ajout d'un audit log dedie (`#355` audit log project) sur l'endpoint RBAC sans polluer l'audit profil.
|
||||
- Contrat front simple : une seule route, un seul groupe, une seule validation.
|
||||
|
||||
## 5. Regles metier et cas limites
|
||||
|
||||
### Role
|
||||
|
||||
- **Creation (`POST /api/roles`)** :
|
||||
- `code`, `label` obligatoires. `description` optionnel. `permissions` optionnel (tableau d'IRIs).
|
||||
- `isSystem` est toujours `false` pour les roles crees via API (n'est pas dans `role:write`).
|
||||
- Unicite du `code` geree par la contrainte DB `uniq_role_code` → 422 via `UniqueEntity` validator a ajouter sur l'entite (voir section 6).
|
||||
|
||||
- **Modification (`PATCH /api/roles/{id}`)** :
|
||||
- `label`, `description`, `permissions` modifiables librement, y compris sur un role systeme (utile pour customiser l'apparence dans l'UI sans casser la relation).
|
||||
- `code` **immuable apres creation** — strategie retenue (variante A) : un seul groupe `role:write` contenant `code`, et une garde centralisee dans `RoleProcessor`. Le processor compare la valeur entrante a l'etat d'origine via `UnitOfWork::getOriginalEntityData($role)['code']` ; si elle differe, leve `BadRequestHttpException` avec un message francais explicite. Regle unique et uniforme : roles systeme ET roles customs sont concernes. Justification : garder la regle metier dans le domaine applicatif plutot que dupliquer les groupes de serialisation.
|
||||
|
||||
- **Suppression (`DELETE /api/roles/{id}`)** :
|
||||
- `RoleProcessor` appelle `$role->ensureDeletable()` avant de deleguer au `RemoveProcessor`.
|
||||
- `SystemRoleDeletionException` est catchee et re-levee en `Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException` (403).
|
||||
- Les relations `user_role` et `role_permission` sur ce role sont nettoyees automatiquement par le `ON DELETE CASCADE` des contraintes `FK_2DE8C6A3D60322AC` (`user_role.role_id`) et `FK_6F7DF886D60322AC` (`role_permission.role_id`) posees dans `migrations/Version20260414150034.php`. Aucun nettoyage manuel necessaire dans `RoleProcessor`. Verifier en test fonctionnel par un DELETE d'un role custom attache a un user, puis assert que le user existe toujours et que `user_role` est vide pour ce couple.
|
||||
|
||||
### Permission
|
||||
|
||||
- Lecture seule via API. Aucun endpoint de mutation.
|
||||
- Si un admin veut forcer une permission sur un utilisateur, il passe par `directPermissions` de `User`.
|
||||
|
||||
### User (operation RBAC)
|
||||
|
||||
- `PATCH /api/users/{id}/rbac` n'accepte que `isAdmin`, `roles`, `directPermissions`. Tout autre champ dans le payload est ignore (comportement par defaut d'API Platform avec un `denormalizationContext` restreint).
|
||||
- **Garde minimale auto-suicide** : `UserRbacProcessor` refuse (`BadRequestHttpException` 400) toute requete ou l'user cible est egal a l'user courant du `Security::getUser()` ET `isAdmin` passe de `true` a `false`. Sans cette garde, un admin peut se degrader seul et perdre acces a l'endpoint, creant une situation de recovery penible. C'est une garde locale et pragmatique, volontairement plus stricte que "le dernier admin" : on interdit l'auto-degradation, point. La garde "plus d'un admin restant" reste reportee au #345 ou un inventaire global fera sens avec le voter. TODO a placer dans le processor avec reference a #345.
|
||||
- Le password n'est jamais touche par cet endpoint (contrairement a `UserPasswordHasherProcessor` sur `PATCH /api/users/{id}`).
|
||||
|
||||
## 6. Validation
|
||||
|
||||
- Ajouter sur `Role` une contrainte `#[UniqueEntity(fields: ['code'])]` pour un 422 propre au lieu d'un 500 SQL en cas de conflit.
|
||||
- Ajouter sur `Role::$code` un `#[Assert\NotBlank]` et un `#[Assert\Regex('/^[a-z][a-z0-9_]*$/')]` (meme convention que les permissions).
|
||||
- Ajouter sur `Role::$label` un `#[Assert\NotBlank]`.
|
||||
|
||||
## 7. Plan de tests
|
||||
|
||||
### Unitaires
|
||||
|
||||
**`RoleProcessorTest`**
|
||||
|
||||
- `process()` d'un role non-systeme en DELETE delegue au `RemoveProcessor` sans lever.
|
||||
- `process()` d'un role systeme en DELETE leve `AccessDeniedHttpException` (403) et n'appelle pas le decorator.
|
||||
- `process()` d'un role systeme en PATCH dont le `code` a change leve `BadRequestHttpException`.
|
||||
- `process()` d'un role systeme en PATCH dont seuls `label`/`permissions` changent delegue au `PersistProcessor`.
|
||||
- `process()` d'un role non-systeme en POST delegue au `PersistProcessor`.
|
||||
|
||||
**`UserRbacProcessorTest`**
|
||||
|
||||
- `process()` persiste un user avec `isAdmin = true` via le decorator.
|
||||
- `process()` persiste une collection de `roles` mise a jour.
|
||||
- `process()` ne declenche jamais le hashing de password (verifier que `UserPasswordHasherProcessor` n'est pas dans la chaine).
|
||||
|
||||
### Fonctionnels (`ApiTestCase`)
|
||||
|
||||
**`PermissionApiTest`**
|
||||
|
||||
- `GET /api/permissions` en tant qu'admin retourne la liste des permissions synchronisees.
|
||||
- `GET /api/permissions?module=core` filtre par module.
|
||||
- `GET /api/permissions?orphan=true` retourne uniquement les orphelines.
|
||||
- `GET /api/permissions/{id}` retourne les champs attendus (groupe `permission:read`).
|
||||
- `POST /api/permissions` en tant qu'admin retourne `405 Method Not Allowed`.
|
||||
- `GET /api/permissions` non authentifie retourne `401`.
|
||||
- `GET /api/permissions` en tant que user standard retourne `403`.
|
||||
|
||||
**`RoleApiTest`**
|
||||
|
||||
- `POST /api/roles` avec `{code, label, description}` retourne `201` et persiste `isSystem = false`.
|
||||
- `POST /api/roles` avec un `code` deja utilise retourne `422`.
|
||||
- `POST /api/roles` avec un `code` invalide (`MAJ`, `space`) retourne `422`.
|
||||
- `PATCH /api/roles/{id}` sur un role custom modifie `label` et ajoute des permissions via IRIs → `200`.
|
||||
- `PATCH /api/roles/{id}` sur le role `admin` (systeme) modifiant seulement `label` → `200`.
|
||||
- `PATCH /api/roles/{id}` sur le role `admin` tentant de modifier `code` → `400`.
|
||||
- `DELETE /api/roles/{id}` sur un role custom → `204`.
|
||||
- `DELETE /api/roles/{id}` sur le role `admin` → `403` avec `SystemRoleDeletionException` traduite.
|
||||
- `DELETE /api/roles/{id}` d'un role custom attache a un user : le user reste, la relation `user_role` est nettoyee par le CASCADE.
|
||||
- Toute operation sans auth retourne `401`.
|
||||
- Toute operation en tant que user standard retourne `403`.
|
||||
|
||||
**`UserRbacApiTest`**
|
||||
|
||||
- `PATCH /api/users/{id}/rbac` en tant qu'admin avec `{isAdmin: true}` promeut le user.
|
||||
- `PATCH /api/users/{id}/rbac` avec `{roles: [IRI...]}` remplace la collection de roles RBAC.
|
||||
- `PATCH /api/users/{id}/rbac` avec `{directPermissions: [IRI...]}` remplace les permissions directes.
|
||||
- `PATCH /api/users/{id}/rbac` en tant que user standard retourne `403`.
|
||||
- `PATCH /api/users/{id}/rbac` non authentifie retourne `401`.
|
||||
- `PATCH /api/users/{id}/rbac` avec un champ `username` dans le payload n'est pas persiste (denormalization context restreint).
|
||||
- `PATCH /api/users/{id}` sans `/rbac` avec `{isAdmin: true}` ne modifie PAS `isAdmin` (confirme la decision `0fc4e16`).
|
||||
|
||||
## 8. Securite et traduction d'exceptions
|
||||
|
||||
- `SystemRoleDeletionException` → `AccessDeniedHttpException` (403) dans `RoleProcessor` (pas via un listener global : on garde la traduction locale au perimetre RBAC).
|
||||
- `BadRequestHttpException` pour la mutation de `code` sur un role systeme : message explicite en francais, dans le payload Hydra `hydra:description`.
|
||||
- Toutes les routes ont pour l'instant `security: "is_granted('ROLE_ADMIN')"`. Un commentaire `// TODO ticket #345` doit etre present sur chaque attribut pour faciliter le remplacement.
|
||||
|
||||
## 9. Conventions et architecture
|
||||
|
||||
- Respect strict du modular monolith : tous les fichiers crees vivent dans `src/Module/Core/` ou `tests/Module/Core/`. Aucun import depuis un autre module.
|
||||
- `declare(strict_types=1)` en tete des nouveaux fichiers.
|
||||
- Commentaires PHP en francais, identifiants anglais (`CLAUDE.md`).
|
||||
- Processors branches via l'autoconfiguration Symfony ; aucun wiring manuel dans `services.yaml` attendu si le constructeur est injecte proprement.
|
||||
- Pattern de decorator : utiliser `#[AsDecorator]` ou `#[Autoconfigure]` pour brancher le processor en tant que decorator du `PersistProcessor` API Platform, selon le pattern deja utilise par `UserPasswordHasherProcessor`.
|
||||
- Aucune nouvelle entree necessaire dans `config/modules.php` ni `config/sidebar.php`.
|
||||
|
||||
## 10. Ordre d'execution recommande
|
||||
|
||||
1. Ajouter l'attribut `#[ApiResource]` et les `#[Groups]` sur `Permission`. Ecrire `PermissionApiTest`.
|
||||
2. Ajouter les contraintes Validator sur `Role`. Ajouter `#[ApiResource]` et les `#[Groups]` sur `Role` **sans** processor dans un premier temps pour valider le CRUD nominal.
|
||||
3. Creer `RoleProcessor` et le brancher en decorator. Ajouter les gardes systeme. Ecrire `RoleProcessorTest` + cas `RoleApiTest`.
|
||||
4. Creer `UserRbacProcessor`. Ajouter l'operation `/users/{id}/rbac` et le groupe `user:rbac:write` sur `User`. Ecrire `UserRbacProcessorTest` + `UserRbacApiTest`.
|
||||
5. `make test` complet + `make php-cs-fixer-allow-risky`.
|
||||
6. Documentation : referencer ce spec dans `docs/rbac/` et mettre a jour le fil conducteur RBAC si un index existe.
|
||||
|
||||
## 11. Risques et points d'attention
|
||||
|
||||
- **Constructeur de `Role` et denormalisation POST** : API Platform 4 resout les arguments du constructeur par nom ; `isSystem` est dans la signature mais pas dans `role:write`, donc un client ne peut pas l'injecter — a verifier par un test explicite ("POST avec `isSystem: true` est ignore").
|
||||
- **`code` immuable** : strategie retenue (garde dans processor) simple mais demande une lecture de l'etat initial du role avant persistance. Utiliser `UnitOfWork::getOriginalEntityData()` pour recuperer la valeur d'origine proprement.
|
||||
- **Cascade de delete role → user_role** : depend de `ON DELETE CASCADE` pose par la migration #343. Verifier explicitement en test fonctionnel qu'aucune `ForeignKeyConstraintViolationException` ne remonte.
|
||||
- **`UniqueEntity` sur `code`** : ne couvre pas les conflits en race condition, la DB reste la garde ultime. Acceptable.
|
||||
- **Pas de filtre sur le `module` de Permission cote front** au #346 sans le filtre API : s'assurer que le filtre est bien pose ici.
|
||||
- **Auto-retrait du dernier admin** : garde d'**auto-suicide** posee dans `UserRbacProcessor` (un admin ne peut pas se degrader lui-meme, cf. section 5). La garde "plus d'un admin restant" au niveau global reste reportee au voter #345.
|
||||
- **Infra de test fonctionnel (fixtures et isolation)** : les tests `*ApiTest` dependent de la presence en base des roles systeme `admin` et `user`. L'infra actuelle doit fournir soit un reload des fixtures par classe de test, soit `DAMADoctrineTestBundle` pour transactionner chaque test. A verifier au debut de l'etape 1 de l'ordre d'execution ; si absent, ajouter un trait de bootstrap minimal `RbacFixturesTrait` qui insere les deux roles systeme avant chaque classe de test (pas par test, trop couteux). Ne pas bloquer le ticket sur cette question, adapter au vol.
|
||||
|
||||
## 12. Criteres d'acceptation (DoD)
|
||||
|
||||
- `GET /api/permissions` et `GET /api/permissions/{id}` fonctionnent, filtres `module` et `orphan` operationnels.
|
||||
- CRUD complet sur `/api/roles` operationnel, avec `isSystem` en lecture seule cote API.
|
||||
- `DELETE /api/roles/{admin_id}` retourne `403` avec un message metier.
|
||||
- `PATCH /api/roles/{admin_id}` autorise la modification de `label`/`permissions` mais refuse la modification de `code` avec `400`.
|
||||
- `PATCH /api/users/{id}/rbac` permet de modifier `isAdmin`, `roles` et `directPermissions` ; `PATCH /api/users/{id}` (profil) ne les modifie jamais.
|
||||
- Les operations API sont gardees par `is_granted('ROLE_ADMIN')` et commentees avec la TODO pointant vers #345.
|
||||
- `make test` passe ; `make php-cs-fixer-allow-risky` ne laisse aucun delta.
|
||||
- Aucun import croise entre modules ; tous les fichiers crees vivent dans `Module/Core/` ou `tests/Module/Core/`.
|
||||
- Le spec est mergee avec le code (meme PR ou PR precedente) pour rester la reference du ticket.
|
||||
|
||||
## 13. Remarques de branche
|
||||
|
||||
- Le ticket enonce "Branche a creer : `feat/rbac-api` depuis develop apres merge de #2".
|
||||
- Branche courante locale : `feat/rbac-core`. A confirmer avec l'utilisateur si PR #2 est mergee : si oui, se rebaser sur `develop` et creer `feat/rbac-api` propre ; sinon, empiler `feat/rbac-api` sur `feat/rbac-core` en attendant le merge.
|
||||
574
docs/rbac/ticket-345-spec.md
Normal file
574
docs/rbac/ticket-345-spec.md
Normal file
@@ -0,0 +1,574 @@
|
||||
# Ticket #345 - 3/5 - Voter Symfony + composable usePermissions (Full-stack)
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Ce ticket remplace les gardes placeholder `is_granted('ROLE_ADMIN')` posees par le #344 sur les 13 operations API Platform du perimetre RBAC par des verifications metier basees sur les codes de permission livres au #343 (`core.users.view`, `core.roles.manage`, etc.). Il introduit le `PermissionVoter` Symfony qui interprete ces codes, avec un bypass total pour les utilisateurs `isAdmin = true` (decision gravee au #343 section 11). Il ferme la garde "dernier admin global" reportee par le #344 via un service domaine mutualise entre les chemins de mutation (`PATCH /users/{id}/rbac` et `DELETE /users/{id}`). Enfin il expose les permissions effectives de l'utilisateur courant via `/api/me` et livre le composable front `usePermissions()` qui les consomme.
|
||||
|
||||
A l'issue de ce ticket, l'application dispose d'un systeme d'autorisation applicatif reel, utilisable par les tickets #346 (ecrans d'admin RBAC) et #347 (UX des erreurs 403). Aucune interface d'administration n'est livree ici : le ticket est un socle full-stack sans ecran dedie.
|
||||
|
||||
## 2. Perimetre
|
||||
|
||||
### IN
|
||||
|
||||
- Ajouter la permission `core.roles.view` au catalogue `CoreModule::permissions()` et la synchroniser via `app:sync-permissions`. Documenter la regle par defaut "view + manage par ressource administrable" qui encadre les declarations futures.
|
||||
- Creer `PermissionVoter` Symfony qui :
|
||||
- supporte les attributs au format `module.resource[.sub].action` (regex explicite) sans interferer avec `ROLE_*`,
|
||||
- bypasse a `ACCESS_GRANTED` si `User::isAdmin() === true`,
|
||||
- sinon compare l'attribut a `User::getEffectivePermissions()`.
|
||||
- Remplacer les 13 `is_granted('ROLE_ADMIN')` places par le #344 (et les operations User heritees du profil pre-#344) par les codes metier adequats sur les entites `Permission`, `Role` et `User`. Supprimer les commentaires `// TODO ticket #345` en meme temps.
|
||||
- Creer un service domaine `AdminHeadcountGuard` dans `src/Module/Core/Domain/Security/` qui encapsule la regle "il doit toujours rester au moins un administrateur sur l'instance" et leve `LastAdminProtectionException` quand l'operation ferait tomber le compteur a zero.
|
||||
- Brancher le guard dans `UserRbacProcessor` (apres la garde auto-suicide existante) et dans un nouveau `UserProcessor` decorateur de `RemoveProcessor` qui intercepte `DELETE /api/users/{id}`.
|
||||
- Ajouter `UserRepositoryInterface::countAdmins(): int` et son implementation Doctrine.
|
||||
- Enrichir `/api/me` en exposant `effectivePermissions: list<string>` via un `#[Groups(['me:read'])]` sur la methode existante `User::getEffectivePermissions()`. Aucun changement de `MeProvider`.
|
||||
- Livrer `frontend/shared/composables/usePermissions.ts` consommant `useAuthStore().user` (qui porte deja le payload `/api/me`). API publique : `can(code)`, `canAny(codes)`, `canAll(codes)`.
|
||||
- Etendre `frontend/shared/types/user-data.ts` avec les champs `isAdmin: boolean` et `effectivePermissions: string[]`.
|
||||
- Tests unitaires PHP : `PermissionVoterTest`, `AdminHeadcountGuardTest`, `UserProcessorTest`, extension de `UserRbacProcessorTest`.
|
||||
- Tests fonctionnels API : couverture 403 non-admin / 200 admin sur chaque operation des 3 ressources RBAC, cas "dernier admin global" sur PATCH et DELETE, expo `/api/me` avec `effectivePermissions`.
|
||||
- Test Vitest du composable `usePermissions`.
|
||||
|
||||
### OUT
|
||||
|
||||
- Ticket `#346` : ecrans d'administration RBAC front (liste/edition roles, picker permissions, admin user RBAC).
|
||||
- Ticket `#347` : UX des erreurs 403 (toasts, redirections, page 403 dediee), integration front complete des ecrans admin RBAC.
|
||||
- Decoration des items sidebar par permission : les items portent aujourd'hui un champ `module` owner ; le filtrage par permission individuelle sera ajoute au #346 quand l'UI en aura besoin.
|
||||
- Audit log des mutations RBAC : traite par le futur `#355` audit log project, deliberement independant.
|
||||
- Decoupe fine de `core.users.manage` en sous-permissions (`create`, `edit`, `delete`) : YAGNI, aucun use-case metier identifie a ce jour.
|
||||
- Cache des voter decisions : la verification est O(1) sur un `in_array` avec des collections deja `fetch=EAGER`, aucun cache necessaire.
|
||||
|
||||
## 3. Fichiers a creer
|
||||
|
||||
### Domaine - Securite
|
||||
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Security/AdminHeadcountGuard.php`
|
||||
Service domaine encapsulant l'invariant "au moins un admin reste apres l'operation". Depend uniquement de `UserRepositoryInterface::countAdmins()`. Aucune dependance infrastructure, testable en isolation.
|
||||
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Exception/LastAdminProtectionException.php`
|
||||
Exception metier levee par le guard. Traduite en `BadRequestHttpException` (400) dans les processors.
|
||||
|
||||
### Infrastructure - Security
|
||||
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Security/PermissionVoter.php`
|
||||
Voter Symfony etendant `Symfony\Component\Security\Core\Authorization\Voter\Voter`. Decouvert automatiquement par `autoconfigure: true`.
|
||||
|
||||
### Infrastructure - Processors
|
||||
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessor.php`
|
||||
Decorateur de `RemoveProcessor` cible sur `DELETE /api/users/{id}`. Appelle `AdminHeadcountGuard` avant de deleguer. Meme pattern qu'`UserRbacProcessor`/`RoleProcessor` : `final class`, `#[Autowire]` sur l'inner, `LogicException` fail-fast si le type entrant n'est pas `User`.
|
||||
|
||||
### Frontend - Composable
|
||||
|
||||
- `/home/matthieu/dev_malio/Coltura/frontend/shared/composables/usePermissions.ts`
|
||||
Composable stateless qui lit `useAuthStore().user`. Pas de fetch propre, pas de reset (le cycle de vie est porte par l'auth store).
|
||||
|
||||
### Tests unitaires PHP
|
||||
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/Security/PermissionVoterTest.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Domain/Security/AdminHeadcountGuardTest.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessorTest.php`
|
||||
|
||||
### Tests fonctionnels PHP
|
||||
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/MeApiTest.php` (si absent — sinon extension)
|
||||
Couvre l'enrichissement du payload `/api/me`.
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/UserApiTest.php` (si absent — sinon extension)
|
||||
Couvre la garde "dernier admin global" sur `DELETE /api/users/{id}`.
|
||||
|
||||
### Tests frontend
|
||||
|
||||
- `/home/matthieu/dev_malio/Coltura/frontend/shared/composables/__tests__/usePermissions.test.ts`
|
||||
Vitest. Emplacement a adapter si le projet Nuxt a une autre convention (colocalise avec un fichier `.spec.ts`, ou repertoire `tests/`). A verifier au debut de la task frontend.
|
||||
|
||||
## 4. Fichiers a modifier
|
||||
|
||||
### `CoreModule.php`
|
||||
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/CoreModule.php`
|
||||
|
||||
Ajouter une cinquieme entree au catalogue :
|
||||
|
||||
```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.view', 'label' => 'Voir les roles RBAC'],
|
||||
['code' => 'core.roles.manage', 'label' => 'Gerer les roles et permissions'],
|
||||
['code' => 'core.permissions.view', 'label' => 'Voir le catalogue des permissions'],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
La commande `app:sync-permissions` creera automatiquement `core.roles.view` a la prochaine execution, sans migration Doctrine necessaire (le catalogue est propriete exclusive de la commande de sync depuis le #343).
|
||||
|
||||
### Entite `Permission`
|
||||
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Permission.php`
|
||||
|
||||
Remplacer les 2 gardes placeholder :
|
||||
|
||||
```php
|
||||
new GetCollection(
|
||||
normalizationContext: ['groups' => ['permission:read']],
|
||||
security: "is_granted('core.permissions.view')",
|
||||
),
|
||||
new Get(
|
||||
normalizationContext: ['groups' => ['permission:read']],
|
||||
security: "is_granted('core.permissions.view')",
|
||||
),
|
||||
```
|
||||
|
||||
Supprimer les commentaires `// TODO ticket #345`.
|
||||
|
||||
### Entite `Role`
|
||||
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Role.php`
|
||||
|
||||
Remplacer les 5 gardes placeholder :
|
||||
|
||||
- `GetCollection` → `is_granted('core.roles.view')`
|
||||
- `Get` → `is_granted('core.roles.view')`
|
||||
- `Post` → `is_granted('core.roles.manage')`
|
||||
- `Patch` → `is_granted('core.roles.manage')`
|
||||
- `Delete` → `is_granted('core.roles.manage')`
|
||||
|
||||
Supprimer les commentaires `// TODO ticket #345`.
|
||||
|
||||
### Entite `User`
|
||||
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php`
|
||||
|
||||
Remplacer les 6 gardes `ROLE_ADMIN` restantes :
|
||||
|
||||
- `Get` (item) → `is_granted('core.users.view')`
|
||||
- `GetCollection` → `is_granted('core.users.view')`
|
||||
- `Post` → `is_granted('core.users.manage')`
|
||||
- `Patch` (profil, sans `name:`) → `is_granted('core.users.manage')`
|
||||
- `Patch` (`user_rbac_patch`) → `is_granted('core.users.manage')`
|
||||
- `Delete` → `is_granted('core.users.manage')`
|
||||
|
||||
Note : l'operation `Get /me` n'a aucune garde (seulement `IS_AUTHENTICATED_FULLY` implicite via `security.yaml`). Ce n'est pas une operation RBAC, elle reste inchangee.
|
||||
|
||||
Ajouter le processor `UserProcessor::class` sur l'operation `Delete` :
|
||||
|
||||
```php
|
||||
new Delete(
|
||||
security: "is_granted('core.users.manage')",
|
||||
processor: UserProcessor::class,
|
||||
),
|
||||
```
|
||||
|
||||
Exposer `getEffectivePermissions()` dans le groupe `me:read` — ajouter l'attribut sur la methode existante :
|
||||
|
||||
```php
|
||||
#[Groups(['me:read'])]
|
||||
public function getEffectivePermissions(): array
|
||||
{
|
||||
// implementation existante, inchangee
|
||||
}
|
||||
```
|
||||
|
||||
Supprimer tous les commentaires `// TODO ticket #345` rencontres.
|
||||
|
||||
### `UserRepositoryInterface`
|
||||
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/UserRepositoryInterface.php`
|
||||
|
||||
Ajouter la methode :
|
||||
|
||||
```php
|
||||
/**
|
||||
* Compte le nombre d'utilisateurs avec le flag isAdmin = true.
|
||||
* Utilise par AdminHeadcountGuard pour verifier l'invariant
|
||||
* "au moins un administrateur reste sur l'instance".
|
||||
*/
|
||||
public function countAdmins(): int;
|
||||
```
|
||||
|
||||
### `DoctrineUserRepository`
|
||||
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php`
|
||||
|
||||
Implementer `countAdmins()` via un `QueryBuilder` simple :
|
||||
|
||||
```php
|
||||
public function countAdmins(): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('u')
|
||||
->select('COUNT(u.id)')
|
||||
->where('u.isAdmin = true')
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
```
|
||||
|
||||
### `UserRbacProcessor`
|
||||
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php`
|
||||
|
||||
Ajouter la dependance `AdminHeadcountGuard` et l'invoquer **apres** la garde auto-suicide existante, **avant** de deleguer au persist processor. Supprimer le `TODO ticket #345` du docblock.
|
||||
|
||||
Logique :
|
||||
|
||||
```text
|
||||
1. Garde auto-suicide existante (inchangee).
|
||||
2. Si l'operation entraine la perte du flag isAdmin (wasAdmin && !data.isAdmin):
|
||||
AdminHeadcountGuard::ensureAtLeastOneAdminRemainsAfterDemotion($data);
|
||||
3. Delegation au persist processor.
|
||||
```
|
||||
|
||||
La detection "wasAdmin && !data.isAdmin" reutilise le meme `UnitOfWork::getOriginalEntityData()` deja utilise par la garde auto-suicide.
|
||||
|
||||
### `frontend/shared/types/user-data.ts`
|
||||
|
||||
Ajouter les champs :
|
||||
|
||||
```ts
|
||||
export interface UserData {
|
||||
id: number
|
||||
username: string
|
||||
isAdmin: boolean
|
||||
effectivePermissions: string[]
|
||||
// ... champs existants
|
||||
}
|
||||
```
|
||||
|
||||
### `frontend/shared/services/auth.ts`
|
||||
|
||||
A verifier : si `getCurrentUser()` type deja le retour sur `UserData`, rien a changer — les nouveaux champs arrivent automatiquement car l'API les renvoie. Si un mapping manuel est fait dans le service, l'etendre pour ne pas perdre `isAdmin` et `effectivePermissions`. A valider au debut de la task frontend.
|
||||
|
||||
## 5. PermissionVoter - details d'implementation
|
||||
|
||||
### Regex de support
|
||||
|
||||
```php
|
||||
private const string PERMISSION_CODE_PATTERN = '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/';
|
||||
```
|
||||
|
||||
Garantit :
|
||||
- premier caractere alphabetique minuscule,
|
||||
- au moins un point de separation (ecarte les `ROLE_*`),
|
||||
- segments en snake_case minuscules coherents avec les permissions declarees par les modules.
|
||||
|
||||
### `supports(string $attribute, mixed $subject): bool`
|
||||
|
||||
Retourne `(bool) preg_match(self::PERMISSION_CODE_PATTERN, $attribute)`. Le `$subject` est ignore : les permissions sont portees par l'utilisateur, pas par une ressource ciblee. Pour l'instant l'autorisation est uniquement basee sur l'identite de l'acteur — les scopes ressource (ex. "edit this specific role") seront traites par un voter dedie si un module metier en a besoin.
|
||||
|
||||
### `voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool`
|
||||
|
||||
```text
|
||||
$user = $token->getUser()
|
||||
if (!$user instanceof User) return false // ACCESS_DENIED
|
||||
if ($user->isAdmin()) return true // bypass total
|
||||
return in_array($attribute, $user->getEffectivePermissions(), true)
|
||||
```
|
||||
|
||||
### Interaction avec les autres voters
|
||||
|
||||
Strategie par defaut Symfony `affirmative` : des qu'un voter renvoie GRANTED, l'acces est accorde. `PermissionVoter` ne vote **jamais** sur les attributs `ROLE_*` (filtres par `supports()`), donc :
|
||||
|
||||
- l'authentification classique `IS_AUTHENTICATED_FULLY` et `ROLE_USER` continue de fonctionner via `AuthenticatedVoter` et `RoleVoter` de Symfony,
|
||||
- un eventuel `is_granted('ROLE_ADMIN')` residuel dans le code continuerait de fonctionner via `RoleVoter` sans interference.
|
||||
|
||||
Un test fonctionnel `make test` complet verifiera que l'auth standard marche toujours apres ajout du voter.
|
||||
|
||||
### Wiring
|
||||
|
||||
`autoconfigure: true` dans `services.yaml` (deja active) detecte la classe via l'interface `VoterInterface`. **Aucun** wiring manuel necessaire dans `services.yaml`.
|
||||
|
||||
## 6. AdminHeadcountGuard - regles metier
|
||||
|
||||
### Invariant global
|
||||
|
||||
> Apres toute operation terminee avec succes, `countAdmins() >= 1`.
|
||||
|
||||
### API publique
|
||||
|
||||
```php
|
||||
final class AdminHeadcountGuard
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepositoryInterface $userRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Leve si retirer le flag isAdmin a $user ferait tomber le total a zero.
|
||||
* A appeler UNIQUEMENT dans la branche "l'operation retire effectivement isAdmin".
|
||||
*/
|
||||
public function ensureAtLeastOneAdminRemainsAfterDemotion(User $user): void;
|
||||
|
||||
/**
|
||||
* Leve si supprimer physiquement $user ferait tomber le total a zero.
|
||||
* A appeler UNIQUEMENT dans la branche DELETE sur un user admin.
|
||||
*/
|
||||
public function ensureAtLeastOneAdminRemainsAfterDeletion(User $user): void;
|
||||
}
|
||||
```
|
||||
|
||||
Deux methodes semantiques distinctes plutot qu'une methode generique avec un parametre booleen : ca rend les call-sites lisibles et les tests auto-documentes.
|
||||
|
||||
### Logique
|
||||
|
||||
Pour les deux methodes, la regle effective est identique :
|
||||
|
||||
```text
|
||||
if ($this->userRepository->countAdmins() <= 1) {
|
||||
throw new LastAdminProtectionException(
|
||||
'Impossible : au moins un administrateur doit rester sur l\'instance.'
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Les appelants ne passent le guard que si l'operation retire reellement un admin — le guard n'a donc pas a raisonner sur l'etat entrant. Cette separation des responsabilites (le processor decide "est-ce qu'on perd un admin ?", le guard applique "si oui, compte") garde les deux composants minimalistes et testables independamment.
|
||||
|
||||
### Cas couverts (tests)
|
||||
|
||||
1. `countAdmins() > 1` + demotion → OK (pas d'exception)
|
||||
2. `countAdmins() == 1` + demotion → LEVE
|
||||
3. `countAdmins() > 1` + deletion → OK
|
||||
4. `countAdmins() == 1` + deletion → LEVE
|
||||
5. `countAdmins() == 2` + demotion → OK (il en reste 1)
|
||||
6. `countAdmins() == 0` + demotion → LEVE (cas theorique, garde defensive)
|
||||
|
||||
## 7. Garde "dernier admin" - cohabitation avec l'auto-suicide
|
||||
|
||||
Les deux gardes sont distinctes et non fusionnables :
|
||||
|
||||
- **Auto-suicide (existante, #344)** : "un admin ne peut pas retirer ses PROPRES droits admin". S'applique meme s'il existe d'autres admins. Protege contre le recovery penible d'un admin qui se cliquerait degrade tout seul.
|
||||
- **Dernier admin global (nouveau, #345)** : "l'instance doit toujours avoir au moins un admin". S'applique meme si ce n'est pas l'operation d'un admin sur lui-meme (admin A degrade admin B alors qu'ils sont les deux seuls).
|
||||
|
||||
Ordre d'evaluation dans `UserRbacProcessor` :
|
||||
|
||||
```text
|
||||
1. Garde auto-suicide (cas particulier, message dedie)
|
||||
2. Garde dernier admin global (cas general, message dedie)
|
||||
3. Persist
|
||||
```
|
||||
|
||||
Les messages d'erreur distincts aident le front a afficher le bon feedback utilisateur. Le test `UserRbacProcessorTest` doit couvrir les deux branches.
|
||||
|
||||
### Cas limite : l'admin se degrade lui-meme ET il est le dernier
|
||||
|
||||
Les deux gardes s'appliqueraient. Comme auto-suicide est evalue en premier, c'est son message qui est retourne ("Vous ne pouvez pas retirer vos propres droits administrateur."). Comportement acceptable et coherent : le user voit d'abord la regle la plus specifique.
|
||||
|
||||
## 8. /api/me enrichi - contrat
|
||||
|
||||
Payload avant :
|
||||
```json
|
||||
{
|
||||
"@context": "/api/contexts/User",
|
||||
"@id": "/api/users/5",
|
||||
"@type": "User",
|
||||
"id": 5,
|
||||
"username": "admin",
|
||||
"isAdmin": true
|
||||
}
|
||||
```
|
||||
|
||||
Payload apres :
|
||||
```json
|
||||
{
|
||||
"@context": "/api/contexts/User",
|
||||
"@id": "/api/users/5",
|
||||
"@type": "User",
|
||||
"id": 5,
|
||||
"username": "admin",
|
||||
"isAdmin": true,
|
||||
"effectivePermissions": [
|
||||
"core.permissions.view",
|
||||
"core.roles.manage",
|
||||
"core.roles.view",
|
||||
"core.users.manage",
|
||||
"core.users.view"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Contrat :
|
||||
- `effectivePermissions` est toujours un tableau de strings (jamais `null`).
|
||||
- L'ordre est deterministe (trie alphabetique — implementation existante du #343).
|
||||
- Aucun doublon.
|
||||
- Pour un admin, le tableau contient les permissions effectives (non vides si le role `admin` a des permissions OU si l'user a des directPermissions, vide sinon). **Le bypass ne se refletera PAS dans ce tableau** : `isAdmin: true` reste la source de verite du bypass. Le front l'utilise en priorite dans le composable.
|
||||
|
||||
### Pourquoi le bypass n'est pas materialise dans `effectivePermissions`
|
||||
|
||||
Mettre "toutes les permissions connues" dans le tableau pour les admins serait tentant mais faux :
|
||||
- il faudrait enumerer dynamiquement toutes les permissions de tous les modules actifs, ce qui recouvre la responsabilite de `app:sync-permissions`,
|
||||
- le tableau gonflerait inutilement le payload `/api/me` a chaque requete,
|
||||
- et surtout il deviendrait faux si un module declare une nouvelle permission apres une execution de sync : l'admin aurait temporairement un tableau incomplet alors que son bypass reste effectif.
|
||||
|
||||
La source de verite du bypass est `isAdmin: boolean`. Le composable front regarde ce flag en premier.
|
||||
|
||||
## 9. usePermissions - composable front
|
||||
|
||||
### API publique
|
||||
|
||||
```ts
|
||||
export function usePermissions() {
|
||||
const auth = useAuthStore()
|
||||
|
||||
// Verifie si l'utilisateur courant a la permission demandee.
|
||||
// Bypass automatique si isAdmin = true, coherent avec PermissionVoter cote back.
|
||||
const can = (code: string): boolean => {
|
||||
const user = auth.user
|
||||
if (!user) return false
|
||||
if (user.isAdmin) return true
|
||||
return user.effectivePermissions.includes(code)
|
||||
}
|
||||
|
||||
const canAny = (codes: string[]): boolean => codes.some(can)
|
||||
const canAll = (codes: string[]): boolean => codes.every(can)
|
||||
|
||||
return { can, canAny, canAll }
|
||||
}
|
||||
```
|
||||
|
||||
### Proprietes
|
||||
|
||||
- **Stateless** : aucun `ref` module-level, aucune reactivite dediee. Tout passe par `useAuthStore().user` qui est deja reactif via Pinia.
|
||||
- **Aucun fetch propre** : les permissions arrivent par `/api/me` au login (via `useAuthStore().ensureSession()` ou `.login()`), aucun appel supplementaire n'est necessaire.
|
||||
- **Aucun reset** : le logout efface deja `authStore.user`, donc `can()` retombe naturellement a `false`.
|
||||
- **Bypass synchrone avec le back** : la regle `if (user.isAdmin) return true` duplique deliberement le bypass du `PermissionVoter` cote back. Commentaire francais dans le composable pour rappeler que les deux doivent bouger ensemble si la regle change un jour.
|
||||
|
||||
### Pas de variante `can` reactive (computed)
|
||||
|
||||
Utiliser `computed(() => can('core.users.view'))` dans un composant fonctionne automatiquement puisque `auth.user` est reactif Pinia — Vue re-evalue le computed quand `user` change. Pas besoin d'API supplementaire du composable pour ca.
|
||||
|
||||
## 10. Validation
|
||||
|
||||
Aucune nouvelle contrainte Symfony Validator introduite par ce ticket. Les gardes metier (`AdminHeadcountGuard`, `SystemRoleDeletionException`, auto-suicide) vivent dans les processors et le domaine, pas dans la couche Validator.
|
||||
|
||||
## 11. Plan de tests
|
||||
|
||||
### Unitaires PHP
|
||||
|
||||
**`PermissionVoterTest`**
|
||||
|
||||
- `supports('core.users.view')` retourne `true`.
|
||||
- `supports('ROLE_ADMIN')` retourne `false` (n'interfere pas avec les voters core).
|
||||
- `supports('IS_AUTHENTICATED_FULLY')` retourne `false`.
|
||||
- `supports('invalid attribute')` retourne `false` (espace, majuscule).
|
||||
- `voteOnAttribute` avec un `User` admin retourne GRANTED quelle que soit la permission.
|
||||
- `voteOnAttribute` avec un user portant la permission retourne GRANTED.
|
||||
- `voteOnAttribute` avec un user ne portant pas la permission retourne DENIED.
|
||||
- `voteOnAttribute` avec un token non-authentifie (user null) retourne DENIED.
|
||||
|
||||
**`AdminHeadcountGuardTest`**
|
||||
|
||||
- `ensureAtLeastOneAdminRemainsAfterDemotion` : `countAdmins == 2` → OK.
|
||||
- Meme methode : `countAdmins == 1` → `LastAdminProtectionException`.
|
||||
- Meme methode : `countAdmins == 0` → leve aussi (garde defensive).
|
||||
- `ensureAtLeastOneAdminRemainsAfterDeletion` : memes 3 cas, memes resultats.
|
||||
- `UserRepositoryInterface::countAdmins()` est mockee avec une valeur fixe pour chaque cas (test unitaire isole, pas d'acces BDD).
|
||||
|
||||
**`UserProcessorTest`**
|
||||
|
||||
- `process()` sur un user non-admin en DELETE delegue au `RemoveProcessor`.
|
||||
- `process()` sur un user admin en DELETE avec `countAdmins() > 1` delegue.
|
||||
- `process()` sur un user admin en DELETE avec `countAdmins() == 1` leve `BadRequestHttpException` (traduction de `LastAdminProtectionException`).
|
||||
- `process()` avec `$data` non-`User` leve `LogicException` (fail-fast coherent avec `UserRbacProcessor` / `RoleProcessor`).
|
||||
|
||||
**`UserRbacProcessorTest` (extension)**
|
||||
|
||||
- Cas existants auto-suicide : gardes en l'etat.
|
||||
- Nouveau : PATCH RBAC par admin A sur admin B, `isAdmin: false`, `countAdmins() == 1` (apres perte = 0) → `BadRequestHttpException` "dernier admin".
|
||||
- Nouveau : meme operation avec `countAdmins() == 2` → delegue au persist processor.
|
||||
- Nouveau : PATCH RBAC qui ne touche pas `isAdmin` (change juste `roles` ou `directPermissions`) ne consulte jamais le guard, meme si `countAdmins() == 1`.
|
||||
|
||||
### Fonctionnels API PHP (`AbstractApiTestCase`)
|
||||
|
||||
Pour les 3 ressources (`Permission`, `Role`, `User`), pour chaque operation, 3 cas :
|
||||
|
||||
1. Admin → succes (confirme que le voter bypass fonctionne).
|
||||
2. User standard **avec** la permission requise (attachee via fixture dediee) → succes.
|
||||
3. User standard **sans** la permission → `403`.
|
||||
|
||||
**Fixtures de test** : ajouter des users "portant une permission specifique" n'est pas souhaitable dans `AppFixtures` (fixtures de dev). Creer a la place un trait ou une helper method `AbstractApiTestCase::createUserWithPermission(string $code): User` qui instancie a la volee un user + un role + l'attache dans le test lui-meme, transactionne si `DAMADoctrineTestBundle` est en place.
|
||||
|
||||
**Cas specifiques a ajouter** :
|
||||
|
||||
- `UserRbacApiTest` : PATCH `/api/users/{lastAdminId}/rbac` avec `isAdmin: false` par un **autre** admin → `400` avec message "dernier admin" (et pas "auto-suicide").
|
||||
- `UserApiTest` (nouveau ou extension) : DELETE `/api/users/{lastAdminId}` par un autre admin → `400` avec message "dernier admin".
|
||||
- `UserApiTest` : DELETE `/api/users/{nonAdminId}` fonctionne quel que soit le count (la garde ne doit pas etre appelee).
|
||||
- `MeApiTest` : `GET /api/me` en tant qu'admin retourne `effectivePermissions` (tableau, meme vide si pas de role populaire).
|
||||
- `MeApiTest` : `GET /api/me` en tant que user standard retourne `effectivePermissions` = list triee des codes issus de ses roles et directPermissions.
|
||||
|
||||
### Tests frontend (Vitest)
|
||||
|
||||
**`usePermissions.test.ts`**
|
||||
|
||||
- Utilisateur null → `can()` retourne `false` pour n'importe quel code.
|
||||
- Utilisateur admin → `can('core.users.view')` retourne `true` meme si `effectivePermissions` est vide.
|
||||
- Utilisateur non-admin avec `['core.users.view']` → `can('core.users.view')` = `true`, `can('core.users.manage')` = `false`.
|
||||
- `canAny(['a', 'b'])` retourne `true` si l'un des deux matche, `false` sinon.
|
||||
- `canAll(['a', 'b'])` retourne `true` uniquement si les deux matchent.
|
||||
|
||||
Convention de test frontend a valider avant : si le projet Nuxt a deja un setup Vitest, on s'y aligne ; sinon on note une TODO pour ajouter la conf (sans bloquer le ticket — le composable est assez simple pour etre revu manuellement).
|
||||
|
||||
## 12. Securite et traduction d'exceptions
|
||||
|
||||
- `LastAdminProtectionException` (domaine) → `BadRequestHttpException` (400) dans les processors. Message francais : "Impossible : au moins un administrateur doit rester sur l'instance."
|
||||
- `SystemRoleDeletionException` (existante) → traduction inchangee par le #344, rien a modifier.
|
||||
- Auto-suicide existante → message inchange : "Vous ne pouvez pas retirer vos propres droits administrateur."
|
||||
- Pas de listener global : traduction locale dans chaque processor, coherent avec le pattern du #344.
|
||||
|
||||
## 13. Conventions et architecture
|
||||
|
||||
- Respect strict du modular monolith : tous les fichiers crees vivent dans `src/Module/Core/`, `tests/Module/Core/`, ou `frontend/shared/`. Aucun import inter-modules.
|
||||
- `declare(strict_types=1)` en tete de tous les nouveaux fichiers PHP.
|
||||
- Commentaires PHP et TS en francais, identifiants en anglais (`CLAUDE.md`).
|
||||
- Autoconfigure Symfony detecte `PermissionVoter` via `VoterInterface`. `AdminHeadcountGuard` est autowire via son constructeur standard.
|
||||
- Les processors suivent le pattern du #344 : `final class`, `#[Autowire]` sur l'inner, `LogicException` fail-fast sur type invalide.
|
||||
- Aucune entree necessaire dans `config/modules.php` ni `config/sidebar.php`.
|
||||
- Aucune migration Doctrine : le catalogue de permissions est synchronise par `app:sync-permissions` (commande existante #343), pas par une migration.
|
||||
|
||||
## 14. Ordre d'execution recommande (subagent-driven)
|
||||
|
||||
1. **Catalogue** — ajouter `core.roles.view` dans `CoreModule::permissions()`. Executer `app:sync-permissions` en local pour verifier l'ajout. Pas de test propre (couvert indirectement par les tests sync existants du #343).
|
||||
2. **Guard domaine** — creer `LastAdminProtectionException`, ajouter `UserRepositoryInterface::countAdmins()` + impl Doctrine, creer `AdminHeadcountGuard`. Ecrire `AdminHeadcountGuardTest`.
|
||||
3. **PermissionVoter** — implementation + `PermissionVoterTest`. Verifier via `make test` que l'auth standard reste verte (aucune regression sur `ROLE_*`).
|
||||
4. **UserProcessor DELETE** — creer le processor, wire sur l'operation `Delete` de `User`. Ecrire `UserProcessorTest`.
|
||||
5. **UserRbacProcessor extension** — injecter `AdminHeadcountGuard`, brancher apres la garde auto-suicide. Etendre `UserRbacProcessorTest` avec les nouveaux cas.
|
||||
6. **Remplacement des 13 gardes ROLE_ADMIN** — modifier `Permission`, `Role`, `User`. Supprimer tous les `// TODO ticket #345`.
|
||||
7. **`/api/me` enrichi** — ajouter `#[Groups(['me:read'])]` sur `getEffectivePermissions()`. Creer ou etendre `MeApiTest`.
|
||||
8. **Tests fonctionnels RBAC complets** — helper `createUserWithPermission()` dans `AbstractApiTestCase`, puis couverture 403 non-admin / 200 avec permission sur toutes les operations RBAC des 3 ressources. Cas "dernier admin global" PATCH et DELETE.
|
||||
9. **Frontend types + composable** — etendre `UserData`, creer `usePermissions.ts`, ecrire le test Vitest.
|
||||
10. **Verification finale** — `make test` vert, `make php-cs-fixer-allow-risky` sans delta, build Nuxt OK si modifie.
|
||||
|
||||
Chaque etape doit etre revue (spec compliance + code quality) avant de passer a la suivante, pattern subagent-driven-development retenu pour le #344.
|
||||
|
||||
## 15. Risques et points d'attention
|
||||
|
||||
- **Ordre des voters Symfony** : `PermissionVoter` ne vote jamais sur `ROLE_*` grace au regex de support. Risque quasi-nul d'interference avec `RoleVoter`/`AuthenticatedVoter`, a valider par un test fonctionnel `/login_check` + `GET /api/me` apres ajout du voter.
|
||||
- **Serialisation de `getEffectivePermissions()` via API Platform** : la methode existe depuis le #343 mais n'a jamais ete sous serializer. Risque de rencontrer un `ReflectionException` si le nom de propriete deduit ne matche pas (cas rare, API Platform gere les getters normalement). Mitigation : test fonctionnel `/api/me` en premiere validation.
|
||||
- **Cout SQL de `countAdmins()`** : 1 `COUNT(*)` par operation de mutation admin sensible. Index recommande sur `user.is_admin` (`idx_user_is_admin`) — a verifier si la migration #343 l'a deja cree. Si non, c'est un ajustement cosmetique qu'on peut reporter puisque la table `user` d'un CRM PME reste petite (< 1000 lignes).
|
||||
- **Bypass front/back desynchronise** : si un jour le bypass admin est affine cote back (ex: seulement sur certains modules), le composable front doit bouger en meme temps. Mitigation : commentaire francais explicite dans `usePermissions.ts` pointant vers cette spec.
|
||||
- **Tests fonctionnels et fixtures RBAC** : le #344 a introduit `AbstractApiTestCase`, mais les users de test portant une permission specifique (hors admin/user standard) n'existent pas dans les fixtures. Creer une helper `createUserWithPermission()` transactionnelle dans la classe de test, plutot que polluer `AppFixtures` avec des users de test dedies.
|
||||
- **Ordre d'evaluation auto-suicide vs dernier admin** : les deux gardes pourraient etre declenchees simultanement (admin unique qui se degrade lui-meme). L'auto-suicide gagne en premier par design. A couvrir explicitement par un test.
|
||||
- **Payload `/api/me` plus gros** : l'ajout de `effectivePermissions` alourdit chaque requete `/api/me`. Pour 5 permissions aujourd'hui c'est negligeable, mais si le catalogue grossit fortement (50+ permissions reparties sur plusieurs modules), il faudra peut-etre filtrer cote serveur (ne retourner que les permissions utiles au contexte front). Hors scope, mais a noter pour suivi.
|
||||
- **`UserData` partagee entre auth store et composable** : toute modification future de la shape `UserData` peut impacter `usePermissions`. Rester minimal dans le composable et laisser Pinia porter la verite.
|
||||
|
||||
## 16. Criteres d'acceptation (DoD)
|
||||
|
||||
- Le catalogue `CoreModule::permissions()` contient 5 entrees incluant `core.roles.view`.
|
||||
- `PermissionVoter` existe, supporte uniquement les attributs au format `module.resource.action`, bypass admin effectif, test unitaire complet.
|
||||
- Les 13 operations API Platform du perimetre RBAC sont toutes gardees par un code metier `core.*.*` et plus par `ROLE_ADMIN`. Les commentaires `// TODO ticket #345` ont disparu du code.
|
||||
- `AdminHeadcountGuard` existe comme service domaine, est consomme par `UserRbacProcessor` ET `UserProcessor`, teste en isolation.
|
||||
- `UserRepositoryInterface::countAdmins()` existe et est implementee.
|
||||
- `UserProcessor` intercepte `DELETE /api/users/{id}` et bloque la suppression du dernier admin avec un message explicite.
|
||||
- `UserRbacProcessor` bloque la demotion du dernier admin global (en plus de la garde auto-suicide existante) avec un message distinct.
|
||||
- `GET /api/me` retourne `effectivePermissions: string[]` et `isAdmin: boolean` dans son payload.
|
||||
- `frontend/shared/composables/usePermissions.ts` expose `can`, `canAny`, `canAll`, stateless, bypasse si `isAdmin`.
|
||||
- `frontend/shared/types/user-data.ts` inclut `isAdmin` et `effectivePermissions`.
|
||||
- Tests unitaires PHP : `PermissionVoterTest`, `AdminHeadcountGuardTest`, `UserProcessorTest`, extension `UserRbacProcessorTest` — tous verts.
|
||||
- Tests fonctionnels API : couverture 403 non-admin / 200 admin-ou-porteur sur chaque operation RBAC des 3 ressources, cas dernier admin PATCH et DELETE, `/api/me` enrichi.
|
||||
- Test Vitest `usePermissions.test.ts` vert (ou TODO documentee si setup Vitest absent du projet).
|
||||
- `make test` passe ; `make php-cs-fixer-allow-risky` ne laisse aucun delta.
|
||||
- Aucun import croise entre modules ; tous les fichiers PHP crees vivent dans `Module/Core/` ou `tests/Module/Core/`, tous les fichiers front dans `frontend/shared/`.
|
||||
- Le spec est mergee avec le code (meme PR #3 empilee sur `feat/rbac-api`) pour rester la reference du ticket.
|
||||
|
||||
## 17. Remarques de branche
|
||||
|
||||
- Branche de travail : `feat/rbac-voter`, tiree de `feat/rbac-api`.
|
||||
- Pas de PR dediee : les commits #345 s'empilent sur la PR #3 existante ouverte vers `develop`.
|
||||
- Une fois la PR #3 mergee, la branche finale de l'epic RBAC (`feat/rbac-admin-ui` pour #346) partira de `develop`.
|
||||
Reference in New Issue
Block a user