From e3025bf2c945f7486bd8bd440f3dcff9881ce462 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 14 Apr 2026 16:26:49 +0200 Subject: [PATCH 01/55] docs(rbac) : plan et spec ticket #343 + conventions permissions - Spec detaillee des fondations RBAC backend (entites Role/Permission, sync command, migration, fixtures, tests) dans docs/rbac/ticket-343-spec.md - Ajout CLAUDE.md des regles projet : commentaires francais (PHP + TS/Vue) et convention de nommage des permissions module.resource[.sub].action Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 3 + docs/rbac/ticket-343-spec.md | 556 +++++++++++++++++++++++++++++++++++ 2 files changed, 559 insertions(+) create mode 100644 docs/rbac/ticket-343-spec.md diff --git a/CLAUDE.md b/CLAUDE.md index 76c50b1..030a208 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -222,11 +222,13 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash` ### Backend - Toujours `declare(strict_types=1)` en haut des fichiers PHP +- **Commentaires en francais** : tout commentaire PHP (docblock, inline, bloc) doit etre redige en francais. Le code (noms de classes, methodes, variables) reste en anglais. Objectif : faciliter la relecture par l'equipe FR sans polluer l'API publique du code. - API Platform : utiliser ApiResource, Providers, Processors — pas de controllers - Routes API prefixees `/api` (via `config/routes/api_platform.yaml`) - Le login (`/login_check`) est hors prefix `/api`, nginx reecrit `REQUEST_URI` vers `/login_check` - PHP CS Fixer : regles Symfony + PSR-12 + strict types - Roles : `ROLE_ADMIN`, `ROLE_USER` — hierarchie dans `security.yaml` +- **Permissions RBAC** : format obligatoire `module.resource[.subresource].action` en snake_case, ex : `core.users.view`, `commercial.clients.contacts.edit`. Declarees via la methode statique `permissions()` des `*Module.php`, synchronisees par la commande `app:sync-permissions`. Verification via `is_granted('module.resource.action')` cote API Platform et `usePermissions()` cote front. - PostgreSQL : noms de colonnes toujours en **minuscules** dans le SQL brut - Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour eviter le conflit avec API Platform `{id}` - Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux proprietes de l'entite cible @@ -235,6 +237,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash` ### Frontend - TypeScript strict +- **Commentaires en francais** : tout commentaire TS/Vue (JSDoc, inline, bloc) doit etre redige en francais. Le code reste en anglais. Meme regle que cote backend. - Composable `useApi()` pour tous les appels API (gere cookies, erreurs, toasts, i18n) - Stores Pinia : `useAuthStore` (auth), `useUiStore` (ui) - Middleware global `auth.global.ts` protege les routes + charge la sidebar apres login diff --git a/docs/rbac/ticket-343-spec.md b/docs/rbac/ticket-343-spec.md new file mode 100644 index 0000000..9ba04cb --- /dev/null +++ b/docs/rbac/ticket-343-spec.md @@ -0,0 +1,556 @@ +# Ticket #343 - 1/5 - Entités Permission et Role (Backend) + +## 1. Objectif + +Ce ticket livre la base RBAC backend de l'epic en 5 tickets en remplacant le stockage historique des roles Symfony dans `User::$roles` par un modele metier explicite `Role` + `Permission` + rattachements utilisateur. Le resultat attendu est un socle de persistance et de synchronisation utilisable par les tickets suivants pour exposer l'API, brancher les voters et alimenter les interfaces. Le ticket couvre aussi la migration de donnees depuis la colonne JSON existante et l'outillage necessaire pour synchroniser les permissions declarees par les modules actifs. + +## 2. Périmètre + +### IN + +- Creer l'entite `Role` avec `id`, `code`, `label`, `description`, `isSystem` et relation ManyToMany vers `Permission`. +- Creer l'entite `Permission` avec `id`, `code`, `label`, `module`, `orphan` et unicite sur `code`. +- Faire evoluer `User` avec une relation ManyToMany vers `Role`, une relation ManyToMany vers `Permission` pour les permissions directes et un booleen `is_admin`. +- Faire evoluer `User::getRoles()` pour rester compatible Symfony en retournant toujours `ROLE_USER` et `ROLE_ADMIN` si `is_admin = true`. +- Ajouter `User::getEffectivePermissions()` pour retourner l'union des codes de permissions provenant des roles et des permissions directes. +- Ajouter une methode statique `permissions()` sur `/home/matthieu/dev_malio/Coltura/src/Module/Core/CoreModule.php` et definir le pattern a reproduire pour les autres modules. +- Ajouter une commande console `app:sync-permissions` transactionnelle, idempotente et non destructive avec gestion `orphan`. +- Ajouter une migration Doctrine modulaire Core qui cree les tables RBAC, migre les donnees depuis `user.roles`, cree les roles systeme `admin` et `user`, puis supprime la colonne JSON `roles`. +- Mettre a jour les fixtures Core pour creer les roles systeme et rattacher l'utilisateur admin au role `admin`. +- Ajouter une protection domaine empechant la suppression d'un role systeme via `SystemRoleDeletionException`. +- Integrer les decisions de hardening demandees: fetch `EAGER`, constantes partagees pour les codes systeme, synchronisation non destructive, commentaires PHP en francais, identifiants anglais, `declare(strict_types=1)`, colonnes PostgreSQL en minuscules. + +### OUT + +- Ticket `#344` : ressources API Platform, providers, processors et traduction HTTP de `SystemRoleDeletionException` vers `403`. +- Ticket `#345` : voter / authorisation applicative basee sur les permissions. +- Ticket `#346` : interfaces d'administration RBAC. +- Ticket `#347` : couche de traduction / UX des erreurs `403` et integration front complete. + +## 3. Fichiers à créer + +### Domaine - Entités + +- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Permission.php` : entite Doctrine de permission RBAC, code unique, module source et etat `orphan`. +- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Role.php` : entite Doctrine de role RBAC avec relations vers permissions et garde de role systeme. + +### Domaine - Repositories + +- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php` : contrat de lecture/ecriture des permissions pour la commande de sync et les fixtures. +- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/RoleRepositoryInterface.php` : contrat de lecture/ecriture des roles pour migration fonctionnelle, fixtures et usages futurs. + +### Domaine - Exceptions + +- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Exception/SystemRoleDeletionException.php` : exception domaine levee si une suppression vise un role systeme. + +### Infrastructure - Doctrine + +- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php` : implementation Doctrine de `PermissionRepositoryInterface`. +- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php` : implementation Doctrine de `RoleRepositoryInterface`. + +### Infrastructure - Doctrine Migrations + +- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/Migrations/Version.php` : migration modulaire RBAC Core avec schema + migration de donnees + rollback minimal. + +### Infrastructure - Console + +- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php` : commande `app:sync-permissions` qui scanne les modules actifs et synchronise la table `permission`. + +### Infrastructure - DataFixtures + +- Aucun nouveau fichier necessaire si la logique reste dans le fixture existant. + +### Constantes domaine + +- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Security/SystemRoles.php` : constantes partagees `ADMIN_CODE = 'admin'` et `USER_CODE = 'user'`, utilisees a la fois par les fixtures et par la migration SQL. Place dans `Domain/Security/` (pas `ValueObject/` : ce n'est pas un VO, c'est un conteneur de constantes metier laissant de la place pour d'autres constantes de securite plus tard). + +## 4. Fichiers à modifier + +- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php` : supprimer le stockage JSON `roles`, ajouter `isAdmin`, `roles`, `directPermissions`, initialiser les collections, configurer les relations ManyToMany en `fetch=EAGER`, ajouter `getEffectivePermissions()` et adapter `getRoles()` / mutateurs. +- `/home/matthieu/dev_malio/Coltura/src/Module/Core/CoreModule.php` : ajouter une methode statique `public static function permissions(): array` qui declare les permissions natives du module Core et sert de reference pour les autres modules. Contenu initial exact : + ```php + public static function permissions(): array + { + return [ + ['code' => 'core.users.view', 'label' => 'Voir les utilisateurs'], + ['code' => 'core.users.manage', 'label' => 'Gerer les utilisateurs (creer, editer, supprimer)'], + ['code' => 'core.roles.manage', 'label' => 'Gerer les roles et permissions'], + ['code' => 'core.permissions.view', 'label' => 'Voir la liste des permissions'], + ]; + } + ``` + La cle `module` n'est PAS presente dans le payload : elle est auto-injectee par la commande de sync a partir de `CoreModule::ID`. Le code de permission doit obligatoirement commencer par `self::ID . '.'` sous peine d'echec de la sync (garde anti-typo). +- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php` : aucun changement attendu dans ce ticket. Les nouvelles relations `$roles`, `$directPermissions` sont chargees par Doctrine via leurs mappings `fetch=EAGER` declares sur l'entite. Si les tests d'integration revelent un lazy-load non voulu au refresh JWT ou a la desserialisation, ajouter une methode `findForSecurity(string $username): ?User` avec `leftJoin` + `addSelect` explicites sur `roles`, `roles.permissions`, `directPermissions`, et brancher le user provider dessus. A trancher par les tests, pas en prevention. +- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/UserRepositoryInterface.php` : aucun changement dans ce ticket. Ajout eventuel de `findForSecurity()` uniquement si le cas ci-dessus se materialise. +- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` : remplacer l'usage de `setRoles(array)` par la creation des roles systeme, le rattachement des utilisateurs a ces roles et le positionnement de `is_admin`. +- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/CreateUserCommand.php` : remplacer la gestion historique de `ROLE_ADMIN` par `setIsAdmin(true)` et rattachement au role systeme `admin` si l'option `--admin` est conservee. +- `/home/matthieu/dev_malio/Coltura/config/services.yaml` : ajouter 2 alias repository, aligne sur le pattern existant pour `UserRepositoryInterface` : + ```yaml + App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository' + App\Module\Core\Domain\Repository\PermissionRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository' + ``` + La commande `SyncPermissionsCommand` est auto-configuree via `autoconfigure: true`, aucun binding manuel necessaire. +- `/home/matthieu/dev_malio/Coltura/config/modules.php` : aucun changement de contenu requis, mais la commande `app:sync-permissions` devra s'appuyer sur ce fichier comme source de verite des modules actifs. + +## 5. Schéma cible — mappings Doctrine + +Plutot que de prescrire du SQL verbatim (risque de divergence avec la strategie `#[ORM\GeneratedValue]` par defaut utilisee par `User`, qui genere des sequences PostgreSQL, pas des colonnes `GENERATED IDENTITY`), on decrit le schema cible par les attributs Doctrine. Le SQL effectif sera produit par `bin/console doctrine:migrations:diff` puis augmente manuellement du data-migration step (section 6). + +Conventions : +- `declare(strict_types=1)` en tete de tous les fichiers. +- Identifiants de classe et proprietes en anglais, commentaires en francais (cf. `CLAUDE.md`). +- PostgreSQL : noms de colonnes en snake_case minuscules, Doctrine les deduit des proprietes camelCase (ex: `isSystem` → `is_system`). +- Les tables `user` et `role` sont des mots reserves PostgreSQL ; Doctrine les quote automatiquement via `#[ORM\Table(name: '`role`')]`. + +### Entite `Permission` + +```php +#[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)] +#[ORM\Table(name: 'permission')] +#[ORM\UniqueConstraint(name: 'uniq_permission_code', columns: ['code'])] +#[ORM\Index(name: 'idx_permission_module', columns: ['module'])] +#[ORM\Index(name: 'idx_permission_orphan', columns: ['orphan'])] +class Permission +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + #[ORM\Column(length: 255)] + private string $code; + + #[ORM\Column(length: 255)] + private string $label; + + #[ORM\Column(length: 100)] + private string $module; + + #[ORM\Column(options: ['default' => false])] + private bool $orphan = false; +} +``` + +Contraintes fonctionnelles : +- `code` suit la convention `module.resource[.sub].action` (verifie par le sync command). +- `module` contient l'identifiant declare dans `*Module::ID`, auto-injecte par le sync. +- `orphan = true` signifie "permission conservee en base mais absente de la declaration courante". + +### Entite `Role` + +```php +#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)] +#[ORM\Table(name: '`role`')] +#[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])] +#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])] +class Role +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + #[ORM\Column(length: 100)] + private string $code; + + #[ORM\Column(length: 255)] + private string $label; + + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $description = null; + + #[ORM\Column(name: 'is_system', options: ['default' => false])] + private bool $isSystem = false; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')] + #[ORM\JoinTable(name: 'role_permission')] + private Collection $permissions; +} +``` + +Contraintes fonctionnelles : +- `code` porte la cle metier stable du role (`admin`, `user`, ...). +- `isSystem = true` interdit la suppression via `Role::ensureDeletable()` au niveau domaine. +- `fetch: EAGER` sur `$permissions` : evite qu'un `User::getEffectivePermissions()` cascade du lazy-loading hors contexte EntityManager (refresh JWT, serialisation asynchrone). + +### Evolution de l'entite `User` + +- Suppression de la propriete `private array $roles = []` (et donc de la colonne `roles JSON`). +- Ajout : + ```php + #[ORM\Column(name: 'is_admin', options: ['default' => false])] + private bool $isAdmin = false; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')] + #[ORM\JoinTable(name: 'user_role')] + private Collection $roles; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')] + #[ORM\JoinTable(name: 'user_permission')] + private Collection $directPermissions; + ``` +- `$roles` et `$directPermissions` sont initialises en `ArrayCollection` dans le constructeur, comme toute collection Doctrine. +- `fetch: EAGER` sur les 3 associations : critique pour eviter des `getRoles()` silencieusement tronques pendant un refresh de token JWT (cf. risque section 11). + +### Evolution de la table `user` (SQL final apres diff) + +La migration introduira : +- `ALTER TABLE "user" ADD COLUMN is_admin BOOLEAN DEFAULT FALSE NOT NULL;` +- Creation de `user_role`, `user_permission` avec FKs `ON DELETE CASCADE`. +- Apres data-migration (section 6), `ALTER TABLE "user" DROP COLUMN roles;`. + +Etat final attendu : +- La colonne historique `roles JSON NOT NULL` est supprimee. +- La compatibilite Symfony passe par `User::getRoles()` derivee de `$isAdmin`, plus aucune persistence framework. +- Les 3 associations `User::$roles`, `User::$directPermissions`, `Role::$permissions` sont explicitement EAGER. + +## 6. Plan de migration Doctrine + +La migration doit etre implementée dans `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/Migrations/Version.php` et executer `up()` dans cet ordre. + +**Workflow recommande** : +1. Ecrire d'abord les entites `Permission`, `Role` et la mutation de `User` (section 5). +2. Lancer `bin/console doctrine:migrations:diff` qui genere le squelette SQL de structure (CREATE TABLE + FKs + DROP COLUMN). +3. Editer **manuellement** le fichier genere pour inserer le data-migration step entre la creation des tables et le `DROP COLUMN roles` — sinon les donnees admin sont perdues. +4. Le fichier final vit dans le chemin ci-dessus en respectant le namespace configure dans `doctrine_migrations.yaml`. + +### `up()` - ordre recommande apres edition manuelle + +1. Creer la colonne `"user".is_admin` avec `DEFAULT FALSE`. +2. Creer les tables `permission`, `"role"`, `role_permission`, `user_role`, `user_permission` et leurs indexes/foreign keys. +3. Inserer par SQL brut les roles systeme `admin` et `user` en s'appuyant sur les codes centralises dans `SystemRoles`. +4. Mettre a jour `"user".is_admin` a `TRUE` pour les utilisateurs dont la colonne JSON `roles` contient `ROLE_ADMIN`. +5. Inserer dans `user_role` le role `admin` pour les utilisateurs dont la colonne JSON `roles` contient `ROLE_ADMIN`. +6. Inserer dans `user_role` le role `user` pour les utilisateurs qui ne portent pas `ROLE_ADMIN`, y compris si `roles` vaut `NULL`, `[]` ou `["ROLE_USER"]`. +7. Verifier que les insertions utilisent `ON CONFLICT DO NOTHING` ou l'equivalent applicable afin de rester robustes face a une base deja partiellement migree sur un environnement de dev. +8. Supprimer la colonne `"user".roles` uniquement apres la migration de donnees. + +### SQL de migration de donnees - logique precise + +Detection admin : + +```sql +UPDATE "user" u +SET is_admin = TRUE +WHERE EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code + WHERE role_code = 'ROLE_ADMIN' +); +``` + +Rattachement du role systeme `admin` : + +```sql +INSERT INTO user_role (user_id, role_id) +SELECT u.id, r.id +FROM "user" u +CROSS JOIN "role" r +WHERE r.code = 'admin' + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code + WHERE role_code = 'ROLE_ADMIN' + ) +ON CONFLICT DO NOTHING; +``` + +Rattachement du role systeme `user` : + +```sql +INSERT INTO user_role (user_id, role_id) +SELECT u.id, r.id +FROM "user" u +CROSS JOIN "role" r +WHERE r.code = 'user' + AND NOT EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code + WHERE role_code = 'ROLE_ADMIN' + ) +ON CONFLICT DO NOTHING; +``` + +Cas couverts explicitement : + +- `roles = NULL` : traite comme tableau vide, utilisateur non admin, rattache au role `user`. +- `roles = []` : utilisateur non admin, rattache au role `user`. +- `roles = ["ROLE_USER"]` : utilisateur non admin, rattache au role `user`. +- `roles = ["ROLE_ADMIN"]` : `is_admin = true`, rattache au role `admin`, pas de role `user`. +- `roles = ["ROLE_ADMIN", "ROLE_USER"]` : meme resultat que ci-dessus, sans doublon. + +### Caveat : type reel de la colonne `user.roles` + +Le mapping Doctrine actuel (`array` PHP → default) peut avoir genere une colonne `JSON` OU `TEXT` selon la version de Symfony/Doctrine. Le cast `::jsonb` fonctionne directement sur `JSON`, mais pas sur `TEXT`. **Avant d'executer la migration en prod**, verifier avec : + +```bash +docker exec -it db-coltura psql -U malio -d coltura -c '\d "user"' +``` + +- Si `roles | json` : le SQL ci-dessus fonctionne tel quel. +- Si `roles | text` : remplacer `u.roles::jsonb` par `u.roles::text::jsonb` dans les 3 requetes. +- Si la colonne est deja `jsonb` (rare) : remplacer `u.roles::jsonb` par `u.roles`. + +### `down()` - rollback minimal et coherent + +1. Recreer la colonne `"user".roles` en `JSON NOT NULL DEFAULT '[]'`. +2. Rehydrater `"user".roles` avec `["ROLE_ADMIN"]` si `is_admin = true`, sinon `["ROLE_USER"]`. +3. Supprimer les foreign keys et tables de jointure `user_permission`, `user_role`, `role_permission`. +4. Supprimer les tables `permission` et `"role"`. +5. Supprimer la colonne `"user".is_admin`. + +Le rollback ne restitue pas la granularite RBAC complete, ce qui est acceptable pour un `down()` technique, mais il doit restituer une application compatible avec le modele historique base sur `ROLE_ADMIN` / `ROLE_USER`. + +## 7. Algorithme sync-permissions + +La commande `app:sync-permissions` doit vivre dans `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php` et encapsuler toute l'operation dans une transaction Doctrine unique. + +### Source de verite + +- Le scan des modules actifs vient de `/home/matthieu/dev_malio/Coltura/config/modules.php`. +- Chaque classe module active peut exposer `public static function permissions(): array`. +- Par compatibilite montante, si une classe module n'expose pas encore `permissions()`, elle est traitee comme retournant `[]`. + +### Format de declaration attendu + +Chaque entree retournee par `*Module::permissions()` contient STRICTEMENT deux cles : + +- `code` — string suivant la convention `module.resource[.sub].action`, par exemple `core.users.view` ou `commercial.quote.line.delete`. +- `label` — string lisible en francais, affichee dans l'UI d'administration des roles. + +La cle `module` N'EST PAS dans le payload : elle est **auto-injectee** par la commande de sync a partir de `$moduleClass::ID`. Ainsi on ne peut pas declarer accidentellement une permission d'un autre module. + +Garde anti-typo : le sync command verifie que chaque `code` commence obligatoirement par `$moduleClass::ID . '.'`. Si non, la commande echoue avec un message explicite citant la classe module et le code incrimine, sans toucher a la base. + +### Pseudocode + +```text +begin transaction + +load active module classes from /home/matthieu/dev_malio/Coltura/config/modules.php +desired_permissions = empty map keyed by code + +for each module class: + if method permissions() exists: + declared_permissions = module class::permissions() + else: + declared_permissions = [] + + for each declared permission in declared_permissions: + assert array_keys(permission) == ['code', 'label'] // erreur explicite sinon + assert str_starts_with(permission.code, module class::ID + '.') // erreur explicite sinon + normalized = { + code: permission.code, + label: permission.label, + module: module class::ID, // auto-injection + } + desired_permissions[code] = normalized + +load existing permissions from database indexed by code + +for each code in desired_permissions: + if code exists in database: + update label + update module + set orphan = false + else: + insert permission with orphan = false + +for each database permission code not present in desired_permissions: + set orphan = true + +flush +commit transaction +``` + +### Propriétés attendues + +- Idempotente : deux executions consecutives sans changement de declarations ne doivent produire aucun delta metier. +- Non destructive : aucune suppression de ligne `permission`; les permissions disparues deviennent `orphan = true`. +- Reversible metier : une permission orpheline redeclaree repasse a `orphan = false` et voit ses metadonnees mises a jour. +- Transactionnelle : pas d'etat intermediaire visible si une validation ou un flush echoue. + +## 8. Méthodes clés détaillées + +### User + +Signature : + +```text +public function getRoles(): array +``` + +Retourne toujours `ROLE_USER`. Ajoute `ROLE_ADMIN` si `is_admin = true`. Ne lit plus aucune colonne JSON. Le resultat doit etre deduplique et stable pour Symfony Security. + +Signature : + +```text +public function getEffectivePermissions(): array +``` + +Retourne l'union des codes des permissions issues de `User::$roles[*]->permissions` et de `User::$directPermissions`. Le resultat est un tableau unique de chaines, sans doublon, trie de facon deterministe si possible pour des assertions de test stables. + +### Role + +Signature : + +```text +public function addPermission(Permission $permission): self +``` + +Ajoute la permission a la collection si absente, sans doublon. + +Signature : + +```text +public function removePermission(Permission $permission): self +``` + +Retire la permission de la collection si presente. + +Signature : + +```text +public function ensureDeletable(): void +``` + +Leve `SystemRoleDeletionException` si `isSystem = true`. Cette garde doit vivre dans le domaine, meme si la traduction HTTP vers `403` est hors scope de ce ticket. + +### Permission + +Signature : + +```text +public function markOrphan(): self +``` + +Passe `orphan` a `true` sans detruire la permission. + +Signature : + +```text +public function revive(string $label, string $module): self +``` + +Repasse `orphan` a `false` et remet a jour les metadonnees issues de la declaration modulaire. + +## 9. Fixtures mises à jour + +Le fichier cible reste `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php`. + +### Principe cle : decouplage via `is_admin` + +Le role `admin` n'a **pas besoin** de contenir "toutes les permissions" pour rendre l'admin techniquement tout-puissant : cette capacite vient du bypass `is_admin = true` dans le futur `PermissionVoter` (ticket #345). Le role `admin` est donc un **conteneur metier semantique** (il represente le bundle "administrateur") mais n'est pas fonctionnellement requis pour que l'admin puisse tout faire. + +Consequence directe : les fixtures deviennent auto-suffisantes. Elles ne dependent plus d'un passage prealable de `app:sync-permissions`. `make db-reset && make fixtures` reste un one-shot. + +### Jeu de donnees attendu + +- Role systeme `admin` (`SystemRoles::ADMIN_CODE`) + - `code = admin` + - `label = Administrateur` + - `description = Role administrateur — bypass complet via is_admin` + - `isSystem = true` + - `permissions = []` — volontairement vide, le bypass fait tout le travail. Une fois `app:sync-permissions` passe, un admin pourra assigner via UI les permissions au role si besoin d'un scenario "quasi-admin sans bypass". +- Role systeme `user` (`SystemRoles::USER_CODE`) + - `code = user` + - `label = Utilisateur` + - `description = Role de base sans permission specifique` + - `isSystem = true` + - `permissions = []` + +### Assignations utilisateurs + +- `admin` (user) : `is_admin = true`, role `admin` +- `alice` : `is_admin = false`, role `user` +- `bob` : `is_admin = false`, role `user` +- Aucune permission directe (`directPermissions`) n'est prechargee dans ce ticket. + +### Autonomie du workflow + +`make db-reset && make fixtures` fonctionne sans passer par `app:sync-permissions` au prealable, car aucune fixture ne depend du contenu de la table `permission`. Optionnellement, apres chargement des fixtures, l'utilisateur peut executer `bin/console app:sync-permissions` pour peupler la table `permission` avec les declarations de `CoreModule::permissions()`, mais c'est une etape independante et optionnelle a ce stade. + +## 10. Plan de tests PHPUnit + +### Unitaires - domaine + +- `User::getRoles()` retourne `['ROLE_USER']` quand `is_admin = false`. +- `User::getRoles()` retourne `['ROLE_USER', 'ROLE_ADMIN']` quand `is_admin = true`. +- `User::getEffectivePermissions()` fusionne permissions de roles et permissions directes sans doublon. +- `User::getEffectivePermissions()` retourne un tableau vide pour un utilisateur sans role ni permission directe. +- `Role::addPermission()` n'ajoute pas de doublon. +- `Role::removePermission()` retire correctement une permission existante. +- `Role::ensureDeletable()` leve `SystemRoleDeletionException` pour un role systeme. +- `Permission::markOrphan()` passe `orphan` a `true`. +- `Permission::revive()` remet `orphan` a `false` et met a jour `label` / `module`. + +### Integration - persistence et console + +- La commande `app:sync-permissions` cree les permissions declarees par `CoreModule::permissions()`. +- Deux executions successives de `app:sync-permissions` sur le meme jeu de modules sont idempotentes. +- Une permission supprimee d'une declaration modulaire n'est pas deletee mais marquee `orphan = true`. +- Une permission redeclaree apres etat orphelin est revivee avec `orphan = false`. +- Les repositories Doctrine chargent bien `User::$roles`, `User::$directPermissions` et `Role::$permissions` sans lazy loading hors EntityManager grace a `fetch=EAGER`. +- Les fixtures chargent les roles systeme et rattachent les utilisateurs attendus. + +### Integration - migration + +- `up()` sur une base contenant `roles = ["ROLE_ADMIN"]` cree `is_admin = true`, rattache le role `admin` et supprime la colonne JSON. +- `up()` sur une base contenant `roles = ["ROLE_USER"]` rattache le role `user` et laisse `is_admin = false`. +- `up()` sur une base contenant `roles = ["ROLE_ADMIN", "ROLE_USER"]` ne cree aucun doublon et conserve le comportement admin. +- `up()` sur une base contenant `roles = []` ou `NULL` rattache quand meme le role `user`. +- `down()` recree une colonne `roles` JSON exploitable et restaure `ROLE_ADMIN` ou `ROLE_USER` de facon coherente. + +### Prerequis d'infrastructure de test + +Les tests d'integration migration up/down exigent une base de test dediee avec un outillage pour jouer/rejouer les migrations. Verifier l'etat de l'infra avant d'ecrire ces tests : + +- Si `make test` applique deja les migrations sur une base isolee : les tests peuvent etre ecrits en utilisant `KernelTestCase` + `EntityManager` + `MigrationRepository`. +- Sinon, ajouter `DAMADoctrineTestBundle` (transactionne chaque test) ou une recipe dediee `make test-migration` qui monte une base jetable puis lance les migrations. +- **Si l'outillage manque** : ne pas bloquer le ticket. Ecrire a la place un test SQL de bas niveau sur une base transactionnellement reinitialisee (via `BEGIN` / `ROLLBACK` a chaque cas) et poser une TODO explicite dans le ticket suivant pour normaliser l'infra de test migration. + +## 11. Risques et points d'attention + +- Risque de chargement paresseux pendant refresh JWT, serialisation ou security context hors EntityManager. + - Mitigation : imposer `fetch=EAGER` sur `User::$roles`, `User::$directPermissions` et `Role::$permissions`, puis le verifier par tests d'integration. +- Risque de perte de donnees pendant la suppression de la colonne `user.roles`. + - Mitigation : creer les roles systeme et inserer les jointures `user_role` avant tout `DROP COLUMN`, avec tests de migration sur etats mixtes. +- Risque de divergence entre migration SQL brute et fixtures sur les codes des roles systeme. + - Mitigation : centraliser `admin` et `user` dans `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Security/SystemRoles.php` et documenter que la migration doit reprendre ces valeurs telles quelles. +- Risque d'accumulation de permissions orphelines sur des environnements de dev ou apres refactors de codes. + - Mitigation : conserver `orphan = true` pour la non-destruction, mais ajouter un suivi explicite dans les tests et dans la documentation d'exploitation; une strategie de purge pourra etre traitee plus tard si necessaire. +- Risque de sync incoherente entre dev et prod si un module actif ne declare pas encore `permissions()`. + - Mitigation : traiter l'absence de methode comme `[]` pour la compatibilite immediate et documenter que chaque nouveau module devra ajouter `permissions()` dans les tickets suivants. +- Risque de cout SQL/ORM du `fetch=EAGER` quand un utilisateur porte beaucoup de roles et permissions. + - Mitigation : limiter pour l'instant le perimetre aux trois associations critiques et surveiller les requetes; un ajustement vers des requetes dediees pourra etre etudie si la volumetrie augmente. +- Risque de semantique confuse entre `is_admin` et role systeme `admin`. + - Mitigation : regle gravee a partir de ce ticket. `is_admin` est le SEUL levier technique de bypass — c'est lui qui fait qu'un admin peut tout faire, via le futur `PermissionVoter` (ticket #345). Le role `admin` est un **conteneur metier semantique** : il identifie visuellement les admins dans l'UI et laisse la porte ouverte a un scenario "quasi-admin sans bypass" (admin qui aurait beaucoup de permissions explicites mais pas le bypass). Les fixtures/migrations posent les deux (`is_admin = true` ET rattachement au role `admin`) pour le compte admin, mais la logique d'autorisation ne regarde QUE `is_admin` + les permissions effectives. Ne jamais coder `if ($user->hasRole('admin'))` : toujours `if ($user->isAdmin())` ou `is_granted('permission.code')`. + +## 12. Ordre d'exécution recommandé + +1. Creer `Permission`, `Role`, `SystemRoleDeletionException` et `SystemRoles`. +2. Creer `PermissionRepositoryInterface`, `RoleRepositoryInterface` et leurs implementations Doctrine. +3. Faire evoluer `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php` avec `is_admin`, `roles`, `directPermissions`, `getRoles()` et `getEffectivePermissions()`. +4. Ajouter `CoreModule::permissions()` et documenter le pattern de declaration statique pour les autres modules. +5. Ajouter la commande `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php`. +6. Ecrire la migration `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/Migrations/Version.php` avec schema + migration de donnees + down(). +7. Mettre a jour `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` et `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/CreateUserCommand.php`. +8. Ajouter les alias repository dans `/home/matthieu/dev_malio/Coltura/config/services.yaml`. +9. Ecrire les tests unitaires et d'integration couvrant domaine, sync, fixtures et migration. + +## 13. Critères d'acceptation (DoD) + +- Les entites `Role` et `Permission` existent dans le module Core avec mappings Doctrine valides et identifiants anglais. +- `User` ne persiste plus de colonne JSON `roles` apres migration, mais expose toujours un `getRoles()` compatible Symfony. +- `User::getEffectivePermissions()` retourne l'union sans doublon des permissions de roles et des permissions directes. +- `CoreModule` expose une methode statique `permissions()` servant de reference au mecanisme de sync. +- La commande `app:sync-permissions` est transactionnelle, idempotente, non destructive et gere correctement `orphan = true` / revival. +- Les roles systeme `admin` et `user` sont crees par la migration et par les fixtures avec `isSystem = true`. +- La migration convertit de facon sure les etats historiques `ROLE_ADMIN`, `ROLE_USER`, tableau vide, `NULL` et combinaisons mixtes sans perte de comptes. +- La suppression d'un role systeme leve `SystemRoleDeletionException` au niveau domaine. +- Les associations `User::$roles`, `User::$directPermissions` et `Role::$permissions` sont explicitement configurees en `fetch=EAGER` et ce point est verifie par tests. +- Les fixtures attribuent `is_admin = true` + role `admin` a l'utilisateur `admin`, et le role `user` aux utilisateurs standards. +- Le spec est compatible avec l'architecture modulaire actuelle basee sur `/home/matthieu/dev_malio/Coltura/config/modules.php` et n'introduit aucune resource API Platform ni voter dans ce ticket. -- 2.39.5 From f0ea9201f54a02c4a822c6e5abe0716029dd616e Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 14 Apr 2026 16:30:15 +0200 Subject: [PATCH 02/55] feat(core) : RBAC Task 1 - entites Permission et Role + domaine securite - Entite Permission avec methodes markOrphan/revive/updateMetadata - Entite Role avec addPermission/removePermission/ensureDeletable - Constantes SystemRoles (codes admin/user partages) - Exception SystemRoleDeletionException pour la garde de suppression - Tests unitaires couvrant le comportement domaine (pas de BDD) Ticket #343 - 1/7 : fondations RBAC (domaine pur, sans persistence). Les entites ne portent pas encore repositoryClass (ajoute en Task 2). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Module/Core/Domain/Entity/Permission.php | 105 +++++++++++++ src/Module/Core/Domain/Entity/Role.php | 144 ++++++++++++++++++ .../Exception/SystemRoleDeletionException.php | 27 ++++ .../Core/Domain/Security/SystemRoles.php | 23 +++ .../Core/Domain/Entity/PermissionTest.php | 57 +++++++ tests/Module/Core/Domain/Entity/RoleTest.php | 79 ++++++++++ .../Core/Domain/Security/SystemRolesTest.php | 24 +++ 7 files changed, 459 insertions(+) create mode 100644 src/Module/Core/Domain/Entity/Permission.php create mode 100644 src/Module/Core/Domain/Entity/Role.php create mode 100644 src/Module/Core/Domain/Exception/SystemRoleDeletionException.php create mode 100644 src/Module/Core/Domain/Security/SystemRoles.php create mode 100644 tests/Module/Core/Domain/Entity/PermissionTest.php create mode 100644 tests/Module/Core/Domain/Entity/RoleTest.php create mode 100644 tests/Module/Core/Domain/Security/SystemRolesTest.php diff --git a/src/Module/Core/Domain/Entity/Permission.php b/src/Module/Core/Domain/Entity/Permission.php new file mode 100644 index 0000000..f23759a --- /dev/null +++ b/src/Module/Core/Domain/Entity/Permission.php @@ -0,0 +1,105 @@ + false])] + private bool $orphan = false; + + public function __construct(string $code, string $label, string $module) + { + $this->code = $code; + $this->label = $label; + $this->module = $module; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCode(): string + { + return $this->code; + } + + public function getLabel(): string + { + return $this->label; + } + + public function getModule(): string + { + return $this->module; + } + + public function isOrphan(): bool + { + return $this->orphan; + } + + /** + * Marque la permission comme orpheline : son code n'est plus declare par + * aucun module. Elle reste en base pour preserver les assignations et + * permettre une reactivation ulterieure, mais doit etre ignoree par les + * verifications d'autorisation. + */ + public function markOrphan(): self + { + $this->orphan = true; + + return $this; + } + + /** + * Reactive une permission precedemment orpheline : son code reapparait + * dans le code source d'un module. On en profite pour rafraichir les + * metadonnees (libelle et module d'appartenance). + */ + public function revive(string $label, string $module): self + { + $this->orphan = false; + $this->label = $label; + $this->module = $module; + + return $this; + } + + /** + * Met a jour les metadonnees d'une permission active sans toucher a son + * statut d'orphelin. Utilise par la commande de synchronisation lorsque + * seul le libelle ou le module proprietaire a change cote code. + */ + public function updateMetadata(string $label, string $module): self + { + $this->label = $label; + $this->module = $module; + + return $this; + } +} diff --git a/src/Module/Core/Domain/Entity/Role.php b/src/Module/Core/Domain/Entity/Role.php new file mode 100644 index 0000000..8db87df --- /dev/null +++ b/src/Module/Core/Domain/Entity/Role.php @@ -0,0 +1,144 @@ + false])] + private bool $isSystem = false; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')] + #[ORM\JoinTable(name: 'role_permission')] + private Collection $permissions; + + public function __construct(string $code, string $label, bool $isSystem = false, ?string $description = null) + { + $this->code = $code; + $this->label = $label; + $this->isSystem = $isSystem; + $this->description = $description; + $this->permissions = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCode(): string + { + return $this->code; + } + + public function getLabel(): string + { + return $this->label; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function isSystem(): bool + { + return $this->isSystem; + } + + /** @return Collection */ + public function getPermissions(): Collection + { + return $this->permissions; + } + + /** + * Met a jour le libelle affichable du role. Le code reste immuable pour + * garantir la stabilite des references cote fixtures et migrations. + */ + public function setLabel(string $label): self + { + $this->label = $label; + + return $this; + } + + /** + * Met a jour la description libre du role (champ documentaire). + */ + public function setDescription(?string $description): self + { + $this->description = $description; + + return $this; + } + + /** + * Ajoute une permission au role. Idempotent : ajouter deux fois la meme + * permission n'entraine pas de doublon dans la collection. + */ + public function addPermission(Permission $permission): self + { + if (!$this->permissions->contains($permission)) { + $this->permissions->add($permission); + } + + return $this; + } + + /** + * Retire une permission du role. Idempotent : retirer une permission + * absente est un no-op silencieux. + */ + public function removePermission(Permission $permission): self + { + $this->permissions->removeElement($permission); + + return $this; + } + + /** + * Garde domaine : refuse la suppression d'un role marque comme systeme. + * La traduction HTTP (403) est faite au niveau application / API Platform. + */ + public function ensureDeletable(): void + { + if ($this->isSystem) { + throw SystemRoleDeletionException::forRole($this); + } + } +} diff --git a/src/Module/Core/Domain/Exception/SystemRoleDeletionException.php b/src/Module/Core/Domain/Exception/SystemRoleDeletionException.php new file mode 100644 index 0000000..aa34d01 --- /dev/null +++ b/src/Module/Core/Domain/Exception/SystemRoleDeletionException.php @@ -0,0 +1,27 @@ +getCode())); + } +} diff --git a/src/Module/Core/Domain/Security/SystemRoles.php b/src/Module/Core/Domain/Security/SystemRoles.php new file mode 100644 index 0000000..ba1d68c --- /dev/null +++ b/src/Module/Core/Domain/Security/SystemRoles.php @@ -0,0 +1,23 @@ +getId()); + self::assertSame('core.users.view', $permission->getCode()); + self::assertSame('Voir les utilisateurs', $permission->getLabel()); + self::assertSame('core', $permission->getModule()); + self::assertFalse($permission->isOrphan()); + } + + public function testMarkOrphanSetsFlag(): void + { + $permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core'); + + $permission->markOrphan(); + + self::assertTrue($permission->isOrphan()); + } + + public function testReviveResetsOrphanAndUpdatesMetadata(): void + { + $permission = new Permission('core.users.view', 'Old label', 'core'); + $permission->markOrphan(); + + $permission->revive('New label', 'commercial'); + + self::assertFalse($permission->isOrphan()); + self::assertSame('New label', $permission->getLabel()); + self::assertSame('commercial', $permission->getModule()); + } + + public function testUpdateMetadataDoesNotTouchOrphan(): void + { + $permission = new Permission('core.users.view', 'Old', 'core'); + $permission->markOrphan(); + + $permission->updateMetadata('Lbl', 'core'); + + self::assertTrue($permission->isOrphan()); + self::assertSame('Lbl', $permission->getLabel()); + } +} diff --git a/tests/Module/Core/Domain/Entity/RoleTest.php b/tests/Module/Core/Domain/Entity/RoleTest.php new file mode 100644 index 0000000..3910550 --- /dev/null +++ b/tests/Module/Core/Domain/Entity/RoleTest.php @@ -0,0 +1,79 @@ +getId()); + self::assertSame('custom', $role->getCode()); + self::assertSame('Custom', $role->getLabel()); + self::assertNull($role->getDescription()); + self::assertFalse($role->isSystem()); + self::assertTrue($role->getPermissions()->isEmpty()); + } + + public function testAddPermissionAddsOnce(): void + { + $role = new Role('custom', 'Custom'); + $permission = new Permission('core.users.view', 'Voir', 'core'); + + $role->addPermission($permission); + $role->addPermission($permission); + + self::assertSame(1, $role->getPermissions()->count()); + } + + public function testRemovePermissionRemovesWhenPresent(): void + { + $role = new Role('custom', 'Custom'); + $permission = new Permission('core.users.view', 'Voir', 'core'); + + $role->addPermission($permission); + $role->removePermission($permission); + + self::assertSame(0, $role->getPermissions()->count()); + } + + public function testRemovePermissionIsNoOpWhenAbsent(): void + { + $role = new Role('custom', 'Custom'); + $permission = new Permission('core.users.view', 'Voir', 'core'); + + $role->removePermission($permission); + + self::assertSame(0, $role->getPermissions()->count()); + } + + public function testEnsureDeletableAllowsNonSystemRole(): void + { + $role = new Role('custom', 'Custom', false); + + $role->ensureDeletable(); + + $this->expectNotToPerformAssertions(); + } + + public function testEnsureDeletableThrowsForSystemRole(): void + { + $role = new Role('admin', 'Admin', true); + + $this->expectException(SystemRoleDeletionException::class); + $this->expectExceptionMessage('admin'); + + $role->ensureDeletable(); + } +} diff --git a/tests/Module/Core/Domain/Security/SystemRolesTest.php b/tests/Module/Core/Domain/Security/SystemRolesTest.php new file mode 100644 index 0000000..0a1934e --- /dev/null +++ b/tests/Module/Core/Domain/Security/SystemRolesTest.php @@ -0,0 +1,24 @@ + Date: Tue, 14 Apr 2026 16:37:53 +0200 Subject: [PATCH 03/55] refactor(core) : RBAC Task 1 - polish apres revue qualite - Permission : guards constructeur (code/label/module non vides, code avec point) - Permission::revive() reutilise updateMetadata() pour eviter la duplication - Suppression de SystemRolesTest (tautologique, ne capture aucun comportement) - Role::permissions : commentaire explicite sur la raison du fetch EAGER - Alignement des types de retour sur static (style User.php) - Nouveau test Role::addPermission avec permissions distinctes Ticket #343 - Task 1 polish (revue qualite). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Module/Core/Domain/Entity/Permission.php | 36 +++++++++++++++---- src/Module/Core/Domain/Entity/Role.php | 17 ++++++--- .../Core/Domain/Entity/PermissionTest.php | 30 ++++++++++++++++ tests/Module/Core/Domain/Entity/RoleTest.php | 12 +++++++ .../Core/Domain/Security/SystemRolesTest.php | 24 ------------- 5 files changed, 84 insertions(+), 35 deletions(-) delete mode 100644 tests/Module/Core/Domain/Security/SystemRolesTest.php diff --git a/src/Module/Core/Domain/Entity/Permission.php b/src/Module/Core/Domain/Entity/Permission.php index f23759a..19e5ff5 100644 --- a/src/Module/Core/Domain/Entity/Permission.php +++ b/src/Module/Core/Domain/Entity/Permission.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Module\Core\Domain\Entity; use Doctrine\ORM\Mapping as ORM; +use InvalidArgumentException; // TODO: brancher repositoryClass au ticket 343 partie 2 (Task 2). #[ORM\Entity] @@ -31,8 +32,29 @@ class Permission #[ORM\Column(options: ['default' => false])] private bool $orphan = false; + /** + * Invariants : une permission doit avoir un code non vide respectant la + * convention "module.resource[.sub].action" (donc contenir au moins un + * point), un libelle non vide et un module proprietaire non vide. Ces + * garde-fous evitent la persistence de lignes incoherentes si un appelant + * (fixture, commande de synchro, import) oublie un champ ou passe une + * chaine vide. + */ public function __construct(string $code, string $label, string $module) { + if ('' === $code) { + throw new InvalidArgumentException('Le code de permission ne peut pas etre vide.'); + } + if (!str_contains($code, '.')) { + throw new InvalidArgumentException(sprintf('Le code de permission "%s" ne respecte pas la convention "module.resource[.sub].action".', $code)); + } + if ('' === $label) { + throw new InvalidArgumentException('Le libelle de permission ne peut pas etre vide.'); + } + if ('' === $module) { + throw new InvalidArgumentException('Le module proprietaire de la permission ne peut pas etre vide.'); + } + $this->code = $code; $this->label = $label; $this->module = $module; @@ -69,7 +91,7 @@ class Permission * permettre une reactivation ulterieure, mais doit etre ignoree par les * verifications d'autorisation. */ - public function markOrphan(): self + public function markOrphan(): static { $this->orphan = true; @@ -78,14 +100,14 @@ class Permission /** * Reactive une permission precedemment orpheline : son code reapparait - * dans le code source d'un module. On en profite pour rafraichir les - * metadonnees (libelle et module d'appartenance). + * dans le code source d'un module. Equivaut a updateMetadata() suivi d'un + * clearing du flag orphan ; on delegue a updateMetadata() pour ne pas + * dupliquer la logique d'affectation des metadonnees. */ - public function revive(string $label, string $module): self + public function revive(string $label, string $module): static { + $this->updateMetadata($label, $module); $this->orphan = false; - $this->label = $label; - $this->module = $module; return $this; } @@ -95,7 +117,7 @@ class Permission * statut d'orphelin. Utilise par la commande de synchronisation lorsque * seul le libelle ou le module proprietaire a change cote code. */ - public function updateMetadata(string $label, string $module): self + public function updateMetadata(string $label, string $module): static { $this->label = $label; $this->module = $module; diff --git a/src/Module/Core/Domain/Entity/Role.php b/src/Module/Core/Domain/Entity/Role.php index 8db87df..3782a58 100644 --- a/src/Module/Core/Domain/Entity/Role.php +++ b/src/Module/Core/Domain/Entity/Role.php @@ -42,6 +42,15 @@ class Role private bool $isSystem = false; /** @var Collection */ + // Choix deliberé de fetch: 'EAGER' (durcissement, pas oubli de perf) : + // - Evite un lazy-load silencieux pendant un refresh de token JWT ou une + // serialisation hors contexte EntityManager (voir ticket #343, section + // 11 risque #1) ou la collection serait inaccessible et provoquerait + // une erreur opaque. + // - Compromis accepte : surcout SQL volontaire, acceptable a l'echelle + // d'un CRM/ERP PME ou un role porte quelques dizaines de permissions. + // - Si la volumetrie augmente significativement : revoir vers une + // projection cachee (ticket a ouvrir a ce moment-la). #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')] #[ORM\JoinTable(name: 'role_permission')] private Collection $permissions; @@ -90,7 +99,7 @@ class Role * Met a jour le libelle affichable du role. Le code reste immuable pour * garantir la stabilite des references cote fixtures et migrations. */ - public function setLabel(string $label): self + public function setLabel(string $label): static { $this->label = $label; @@ -100,7 +109,7 @@ class Role /** * Met a jour la description libre du role (champ documentaire). */ - public function setDescription(?string $description): self + public function setDescription(?string $description): static { $this->description = $description; @@ -111,7 +120,7 @@ class Role * Ajoute une permission au role. Idempotent : ajouter deux fois la meme * permission n'entraine pas de doublon dans la collection. */ - public function addPermission(Permission $permission): self + public function addPermission(Permission $permission): static { if (!$this->permissions->contains($permission)) { $this->permissions->add($permission); @@ -124,7 +133,7 @@ class Role * Retire une permission du role. Idempotent : retirer une permission * absente est un no-op silencieux. */ - public function removePermission(Permission $permission): self + public function removePermission(Permission $permission): static { $this->permissions->removeElement($permission); diff --git a/tests/Module/Core/Domain/Entity/PermissionTest.php b/tests/Module/Core/Domain/Entity/PermissionTest.php index 0b039c8..af4695c 100644 --- a/tests/Module/Core/Domain/Entity/PermissionTest.php +++ b/tests/Module/Core/Domain/Entity/PermissionTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Tests\Module\Core\Domain\Entity; use App\Module\Core\Domain\Entity\Permission; +use InvalidArgumentException; use PHPUnit\Framework\TestCase; /** @@ -54,4 +55,33 @@ final class PermissionTest extends TestCase self::assertTrue($permission->isOrphan()); self::assertSame('Lbl', $permission->getLabel()); } + + public function testConstructorRejectsEmptyCode(): void + { + $this->expectException(InvalidArgumentException::class); + + new Permission('', 'Libelle', 'core'); + } + + public function testConstructorRejectsCodeWithoutDot(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('invalid_format'); + + new Permission('invalid_format', 'Libelle', 'core'); + } + + public function testConstructorRejectsEmptyLabel(): void + { + $this->expectException(InvalidArgumentException::class); + + new Permission('core.users.view', '', 'core'); + } + + public function testConstructorRejectsEmptyModule(): void + { + $this->expectException(InvalidArgumentException::class); + + new Permission('core.users.view', 'Libelle', ''); + } } diff --git a/tests/Module/Core/Domain/Entity/RoleTest.php b/tests/Module/Core/Domain/Entity/RoleTest.php index 3910550..23e8dd2 100644 --- a/tests/Module/Core/Domain/Entity/RoleTest.php +++ b/tests/Module/Core/Domain/Entity/RoleTest.php @@ -37,6 +37,18 @@ final class RoleTest extends TestCase self::assertSame(1, $role->getPermissions()->count()); } + public function testAddPermissionAddsMultipleDistinct(): void + { + $role = new Role('custom', 'Custom'); + $permissionView = new Permission('core.users.view', 'Voir', 'core'); + $permissionEdit = new Permission('core.users.edit', 'Editer', 'core'); + + $role->addPermission($permissionView); + $role->addPermission($permissionEdit); + + self::assertSame(2, $role->getPermissions()->count()); + } + public function testRemovePermissionRemovesWhenPresent(): void { $role = new Role('custom', 'Custom'); diff --git a/tests/Module/Core/Domain/Security/SystemRolesTest.php b/tests/Module/Core/Domain/Security/SystemRolesTest.php deleted file mode 100644 index 0a1934e..0000000 --- a/tests/Module/Core/Domain/Security/SystemRolesTest.php +++ /dev/null @@ -1,24 +0,0 @@ - Date: Tue, 14 Apr 2026 16:40:44 +0200 Subject: [PATCH 04/55] feat(core) : RBAC Task 2 - repositories Permission et Role - PermissionRepositoryInterface avec findByCode et findAllCodes (pour le sync command et le futur PermissionVoter) - RoleRepositoryInterface avec findByCode - Implementations Doctrine alignees sur DoctrineUserRepository - Alias DI dans config/services.yaml - Rebranchement de repositoryClass sur les entites Permission et Role Ticket #343 - 2/7 : couche persistence RBAC. Co-Authored-By: Claude Opus 4.6 (1M context) --- config/services.yaml | 6 ++ src/Module/Core/Domain/Entity/Permission.php | 4 +- src/Module/Core/Domain/Entity/Role.php | 4 +- .../PermissionRepositoryInterface.php | 33 ++++++++++ .../Repository/RoleRepositoryInterface.php | 27 ++++++++ .../ApiPlatform/State/Provider/MeProvider.php | 3 +- .../Doctrine/DoctrinePermissionRepository.php | 62 +++++++++++++++++++ .../Doctrine/DoctrineRoleRepository.php | 45 ++++++++++++++ 8 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php create mode 100644 src/Module/Core/Domain/Repository/RoleRepositoryInterface.php create mode 100644 src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php create mode 100644 src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php diff --git a/config/services.yaml b/config/services.yaml index 9690014..9457e91 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -16,5 +16,11 @@ services: App\: resource: '../src/' + App\Module\Core\Domain\Repository\PermissionRepositoryInterface: + alias: App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository + + App\Module\Core\Domain\Repository\RoleRepositoryInterface: + alias: App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository + App\Module\Core\Domain\Repository\UserRepositoryInterface: alias: App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository diff --git a/src/Module/Core/Domain/Entity/Permission.php b/src/Module/Core/Domain/Entity/Permission.php index 19e5ff5..e5e92a0 100644 --- a/src/Module/Core/Domain/Entity/Permission.php +++ b/src/Module/Core/Domain/Entity/Permission.php @@ -4,11 +4,11 @@ declare(strict_types=1); namespace App\Module\Core\Domain\Entity; +use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository; use Doctrine\ORM\Mapping as ORM; use InvalidArgumentException; -// TODO: brancher repositoryClass au ticket 343 partie 2 (Task 2). -#[ORM\Entity] +#[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)] #[ORM\Table(name: 'permission')] #[ORM\UniqueConstraint(name: 'uniq_permission_code', columns: ['code'])] #[ORM\Index(name: 'idx_permission_module', columns: ['module'])] diff --git a/src/Module/Core/Domain/Entity/Role.php b/src/Module/Core/Domain/Entity/Role.php index 3782a58..7583454 100644 --- a/src/Module/Core/Domain/Entity/Role.php +++ b/src/Module/Core/Domain/Entity/Role.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Module\Core\Domain\Entity; use App\Module\Core\Domain\Exception\SystemRoleDeletionException; +use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; @@ -17,8 +18,7 @@ use Doctrine\ORM\Mapping as ORM; * "personnalise" (cree par un administrateur). Seuls les roles personnalises * peuvent etre supprimes. */ -// TODO: brancher repositoryClass au ticket 343 partie 2 (Task 2). -#[ORM\Entity] +#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)] #[ORM\Table(name: '`role`')] #[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])] #[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])] diff --git a/src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php b/src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php new file mode 100644 index 0000000..7464c93 --- /dev/null +++ b/src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php @@ -0,0 +1,33 @@ + + */ + public function findAll(): array; + + /** + * @return array liste des codes connus, pour la commande de sync et le futur voter + */ + public function findAllCodes(): array; + + public function save(Permission $permission): void; +} diff --git a/src/Module/Core/Domain/Repository/RoleRepositoryInterface.php b/src/Module/Core/Domain/Repository/RoleRepositoryInterface.php new file mode 100644 index 0000000..c952d7d --- /dev/null +++ b/src/Module/Core/Domain/Repository/RoleRepositoryInterface.php @@ -0,0 +1,27 @@ + + */ + public function findAll(): array; + + public function save(Role $role): void; +} diff --git a/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/MeProvider.php b/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/MeProvider.php index 5359d7b..ea088c8 100644 --- a/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/MeProvider.php +++ b/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/MeProvider.php @@ -6,6 +6,7 @@ namespace App\Module\Core\Infrastructure\ApiPlatform\State\Provider; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; +use Symfony\Bundle\SecurityBundle\Security; /** * @implements ProviderInterface @@ -13,7 +14,7 @@ use ApiPlatform\State\ProviderInterface; class MeProvider implements ProviderInterface { public function __construct( - private readonly \Symfony\Bundle\SecurityBundle\Security $security, + private readonly Security $security, ) {} public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object diff --git a/src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php b/src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php new file mode 100644 index 0000000..b62f2cb --- /dev/null +++ b/src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php @@ -0,0 +1,62 @@ + + */ +class DoctrinePermissionRepository extends ServiceEntityRepository implements PermissionRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Permission::class); + } + + public function findById(int $id): ?Permission + { + return $this->find($id); + } + + public function findByCode(string $code): ?Permission + { + return $this->findOneBy(['code' => $code]); + } + + /** + * @return array + */ + public function findAll(): array + { + return parent::findAll(); + } + + /** + * @return array + */ + public function findAllCodes(): array + { + // Requete legere : on ne selectionne que la colonne code (pas d'hydratation + // d'entites Permission) car findAllCodes() est appelee par la commande de + // sync et le futur voter qui n'ont besoin que des chaines. + $rows = $this->createQueryBuilder('p') + ->select('p.code') + ->getQuery() + ->getArrayResult() + ; + + return array_column($rows, 'code'); + } + + public function save(Permission $permission): void + { + $this->getEntityManager()->persist($permission); + $this->getEntityManager()->flush(); + } +} diff --git a/src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php b/src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php new file mode 100644 index 0000000..7667440 --- /dev/null +++ b/src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php @@ -0,0 +1,45 @@ + + */ +class DoctrineRoleRepository extends ServiceEntityRepository implements RoleRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Role::class); + } + + public function findById(int $id): ?Role + { + return $this->find($id); + } + + public function findByCode(string $code): ?Role + { + return $this->findOneBy(['code' => $code]); + } + + /** + * @return array + */ + public function findAll(): array + { + return parent::findAll(); + } + + public function save(Role $role): void + { + $this->getEntityManager()->persist($role); + $this->getEntityManager()->flush(); + } +} -- 2.39.5 From 7aa32b1972d71ea979b3be1ba7f1e61d4dd9a419 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 14 Apr 2026 16:48:49 +0200 Subject: [PATCH 05/55] feat(core) : RBAC Task 3 - mutation User (isAdmin + roles RBAC + permissions directes) - Suppression de la colonne JSON roles (persiste jusqu'a la migration Task 5) - Ajout is_admin bool (seul levier de bypass RBAC via getRoles()) - Ajout ManyToMany User-Role (EAGER, table user_role) - Ajout ManyToMany User-Permission directes (EAGER, table user_permission) - getEffectivePermissions() : union dedupliquee triee, utilisee par le futur PermissionVoter (#345) - getRbacRoles() pour ne pas shadow getRoles() de UserInterface Symfony - Tests unitaires couvrant derivation getRoles, union, deduplication, tri Ticket #343 - 3/7 : migration du User vers le modele RBAC relationnel. Fetch EAGER documente : evite le lazy-load au refresh JWT. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Module/Core/Domain/Entity/User.php | 157 ++++++++++++++++-- .../Console/CreateUserCommand.php | 3 +- .../DataFixtures/AppFixtures.php | 5 +- tests/Module/Core/Domain/Entity/UserTest.php | 132 +++++++++++++++ 4 files changed, 282 insertions(+), 15 deletions(-) create mode 100644 tests/Module/Core/Domain/Entity/UserTest.php diff --git a/src/Module/Core/Domain/Entity/User.php b/src/Module/Core/Domain/Entity/User.php index cef1bd0..d06efdd 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -14,6 +14,8 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserPasswordHashe use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider; use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository; use DateTimeImmutable; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; @@ -52,10 +54,37 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[Groups(['me:read', 'user:list', 'user:write'])] private ?string $username = null; - /** @var list */ - #[ORM\Column] + #[ORM\Column(name: 'is_admin', options: ['default' => false])] #[Groups(['me:read', 'user:list', 'user:write'])] - private array $roles = []; + private bool $isAdmin = false; + + /** + * Les roles RBAC metier rattaches a l'utilisateur. + * + * Le fetch EAGER est delibere : evite un lazy-load silencieux pendant + * un refresh de token JWT ou une serialisation hors contexte EntityManager + * (cf. docs/rbac/ticket-343-spec.md section 11 risque 1). Le surcout SQL est + * accepte a l'echelle d'un CRM/ERP PME ; a revoir si la volumetrie augmente. + * + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')] + #[ORM\JoinTable(name: 'user_role')] + #[Groups(['me:read', 'user:list', 'user:write'])] + private Collection $roles; + + /** + * Les permissions directes accordees hors des roles. + * + * Meme justification EAGER que pour $roles : garantie que + * getEffectivePermissions() fonctionne dans tous les contextes de chargement. + * + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')] + #[ORM\JoinTable(name: 'user_permission')] + #[Groups(['me:read', 'user:list', 'user:write'])] + private Collection $directPermissions; #[ORM\Column] private ?string $password = null; @@ -68,7 +97,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface public function __construct() { - $this->createdAt = new DateTimeImmutable(); + $this->createdAt = new DateTimeImmutable(); + $this->roles = new ArrayCollection(); + $this->directPermissions = new ArrayCollection(); } public function getId(): ?int @@ -93,23 +124,127 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return (string) $this->username; } - /** @return list */ + /** + * Retourne les roles Symfony Security, derives de $isAdmin. + * + * ROLE_USER est toujours present pour que Symfony accepte l'authentification. + * ROLE_ADMIN est ajoute si l'utilisateur porte le flag is_admin — c'est le + * SEUL levier technique de bypass RBAC (cf. section 11 du spec). + * + * Important : ne JAMAIS iterer $this->roles (la Collection de Role) ici. + * Cette methode peut etre appelee pendant un refresh JWT, moment ou la + * Collection peut ne pas etre hydratee. On se contente d'un calcul base + * sur un scalaire. + * + * @return list + */ public function getRoles(): array { - $roles = $this->roles; - $roles[] = 'ROLE_USER'; + $roles = ['ROLE_USER']; - return array_values(array_unique($roles)); + if ($this->isAdmin) { + $roles[] = 'ROLE_ADMIN'; + } + + return $roles; } - /** @param list $roles */ - public function setRoles(array $roles): static + public function isAdmin(): bool { - $this->roles = $roles; + return $this->isAdmin; + } + + public function setIsAdmin(bool $isAdmin): static + { + $this->isAdmin = $isAdmin; return $this; } + /** + * Retourne la collection de roles RBAC rattaches a l'utilisateur. + * + * NE PAS confondre avec getRoles() qui renvoie les roles Symfony scalaires. + * + * @return Collection + */ + public function getRbacRoles(): Collection + { + return $this->roles; + } + + public function addRbacRole(Role $role): static + { + if (!$this->roles->contains($role)) { + $this->roles->add($role); + } + + return $this; + } + + public function removeRbacRole(Role $role): static + { + $this->roles->removeElement($role); + + return $this; + } + + /** + * @return Collection + */ + public function getDirectPermissions(): Collection + { + return $this->directPermissions; + } + + public function addDirectPermission(Permission $permission): static + { + if (!$this->directPermissions->contains($permission)) { + $this->directPermissions->add($permission); + } + + return $this; + } + + public function removeDirectPermission(Permission $permission): static + { + $this->directPermissions->removeElement($permission); + + return $this; + } + + /** + * Retourne l'union dedupliquee des codes de permissions effectives. + * + * Agrege les permissions venant des roles RBAC et les permissions directes. + * Utilisee par le PermissionVoter (ticket #345) et exposee via /api/me + * apres l'evolution du MeProvider (aussi ticket #345). + * + * Ne PAS appeler dans getRoles() : voir commentaire sur cette derniere + * methode pour le piege de chargement au refresh JWT. + * + * @return list + */ + public function getEffectivePermissions(): array + { + $codes = []; + + foreach ($this->roles as $role) { + foreach ($role->getPermissions() as $permission) { + $codes[$permission->getCode()] = true; + } + } + + foreach ($this->directPermissions as $permission) { + $codes[$permission->getCode()] = true; + } + + $keys = array_keys($codes); + sort($keys); + + return $keys; + } + public function getPassword(): ?string { return $this->password; diff --git a/src/Module/Core/Infrastructure/Console/CreateUserCommand.php b/src/Module/Core/Infrastructure/Console/CreateUserCommand.php index ba647e2..7133adc 100644 --- a/src/Module/Core/Infrastructure/Console/CreateUserCommand.php +++ b/src/Module/Core/Infrastructure/Console/CreateUserCommand.php @@ -49,7 +49,8 @@ class CreateUserCommand extends Command $user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword)); if ($input->getOption('admin')) { - $user->setRoles(['ROLE_ADMIN']); + // TODO Task 6 : attacher l'entite Role "admin" en plus du flag is_admin. + $user->setIsAdmin(true); } $this->userRepository->save($user); diff --git a/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php b/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php index f58a6bf..0ba45fa 100644 --- a/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php +++ b/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php @@ -17,21 +17,20 @@ class AppFixtures extends Fixture public function load(ObjectManager $manager): void { + // TODO Task 6 : cette fixture sera refactoree pour attacher les entites Role RBAC. $admin = new User(); $admin->setUsername('admin'); - $admin->setRoles(['ROLE_ADMIN']); + $admin->setIsAdmin(true); $admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin')); $manager->persist($admin); $alice = new User(); $alice->setUsername('alice'); - $alice->setRoles(['ROLE_USER']); $alice->setPassword($this->passwordHasher->hashPassword($alice, 'alice')); $manager->persist($alice); $bob = new User(); $bob->setUsername('bob'); - $bob->setRoles(['ROLE_USER']); $bob->setPassword($this->passwordHasher->hashPassword($bob, 'bob')); $manager->persist($bob); diff --git a/tests/Module/Core/Domain/Entity/UserTest.php b/tests/Module/Core/Domain/Entity/UserTest.php new file mode 100644 index 0000000..5c3b362 --- /dev/null +++ b/tests/Module/Core/Domain/Entity/UserTest.php @@ -0,0 +1,132 @@ +getRoles()); + } + + public function testGetRolesIncludesRoleAdminWhenIsAdminTrue(): void + { + $user = new User(); + $user->setIsAdmin(true); + + self::assertSame(['ROLE_USER', 'ROLE_ADMIN'], $user->getRoles()); + } + + public function testIsAdminDefaultsToFalse(): void + { + $user = new User(); + + self::assertFalse($user->isAdmin()); + } + + public function testGetEffectivePermissionsIsEmptyByDefault(): void + { + $user = new User(); + + self::assertSame([], $user->getEffectivePermissions()); + } + + public function testGetEffectivePermissionsUnionsRolesAndDirects(): void + { + $perm1 = new Permission('core.users.view', 'View users', 'core'); + $perm2 = new Permission('core.users.edit', 'Edit users', 'core'); + $perm3 = new Permission('core.users.delete', 'Delete users', 'core'); + + $role = new Role('manager', 'Manager'); + $role->addPermission($perm1); + $role->addPermission($perm2); + + $user = new User(); + $user->addRbacRole($role); + $user->addDirectPermission($perm3); + + self::assertSame( + ['core.users.delete', 'core.users.edit', 'core.users.view'], + $user->getEffectivePermissions(), + ); + } + + public function testGetEffectivePermissionsDeduplicatesAcrossRolesAndDirects(): void + { + $perm = new Permission('core.users.view', 'View users', 'core'); + + $role = new Role('viewer', 'Viewer'); + $role->addPermission($perm); + + $user = new User(); + $user->addRbacRole($role); + $user->addDirectPermission($perm); + + $result = $user->getEffectivePermissions(); + + self::assertCount(1, $result); + self::assertSame(['core.users.view'], $result); + } + + public function testAddRbacRoleIsIdempotent(): void + { + $role = new Role('manager', 'Manager'); + $user = new User(); + + $user->addRbacRole($role); + $user->addRbacRole($role); + + self::assertSame(1, $user->getRbacRoles()->count()); + } + + public function testAddDirectPermissionIsIdempotent(): void + { + $perm = new Permission('core.users.view', 'View users', 'core'); + $user = new User(); + + $user->addDirectPermission($perm); + $user->addDirectPermission($perm); + + self::assertSame(1, $user->getDirectPermissions()->count()); + } + + public function testRemoveRbacRole(): void + { + $role = new Role('manager', 'Manager'); + $user = new User(); + + $user->addRbacRole($role); + $user->removeRbacRole($role); + + self::assertSame(0, $user->getRbacRoles()->count()); + } + + public function testGetEffectivePermissionsOutputIsSorted(): void + { + $permZ = new Permission('core.z.action', 'Z', 'core'); + $permA = new Permission('core.a.action', 'A', 'core'); + $permM = new Permission('core.m.action', 'M', 'core'); + + $user = new User(); + $user->addDirectPermission($permZ); + $user->addDirectPermission($permA); + $user->addDirectPermission($permM); + + self::assertSame( + ['core.a.action', 'core.m.action', 'core.z.action'], + $user->getEffectivePermissions(), + ); + } +} -- 2.39.5 From 3b1f18b0e08704c528f115483803988a4d9f00d1 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 14 Apr 2026 16:56:50 +0200 Subject: [PATCH 06/55] feat(core) : RBAC Task 4 - CoreModule::permissions() + SyncPermissionsCommand - CoreModule declare 4 permissions initiales (users.view/manage, roles.manage, permissions.view) - Nouvelle commande app:sync-permissions : * scan des *Module::permissions() via config/modules.php * validation stricte : cles [code, label], prefixe module, non-vides * upsert transactionnel non-destructif * revival des permissions orphelines qui reapparaissent * marquage orphan pour les permissions disparues du code * un seul flush() final (evite le flush-par-save de la repo save()) Ticket #343 - 4/7 : scanner et synchroniseur de permissions RBAC. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Module/Core/CoreModule.php | 27 +++ .../Console/SyncPermissionsCommand.php | 210 ++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php diff --git a/src/Module/Core/CoreModule.php b/src/Module/Core/CoreModule.php index 80fa614..d6d22a9 100644 --- a/src/Module/Core/CoreModule.php +++ b/src/Module/Core/CoreModule.php @@ -9,4 +9,31 @@ final class CoreModule public const string ID = 'core'; public const string LABEL = 'Core'; public const bool REQUIRED = true; + + /** + * Liste declarative des permissions RBAC exposees par le module Core. + * + * Consommee par la commande `app:sync-permissions` (SyncPermissionsCommand) + * qui se charge d'upserter ces entrees dans la table `permission`, de + * reactiver les codes precedemment marques orphelins et de marquer comme + * orphelins ceux qui ont disparu du code source. + * + * La cle `module` est auto-injectee par le sync command a partir de + * `self::ID`, il est donc inutile de la repeter dans chaque entree. + * + * Convention de nommage des codes : `module.resource[.sub].action` en + * snake_case, le prefixe module devant correspondre exactement a + * `self::ID` (verifie par la commande de synchronisation). + * + * @return array + */ + public static function permissions(): array + { + return [ + ['code' => 'core.users.view', 'label' => 'Voir les utilisateurs'], + ['code' => 'core.users.manage', 'label' => 'Gerer les utilisateurs (creer, editer, supprimer)'], + ['code' => 'core.roles.manage', 'label' => 'Gerer les roles et permissions'], + ['code' => 'core.permissions.view', 'label' => 'Voir la liste des permissions'], + ]; + } } diff --git a/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php b/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php new file mode 100644 index 0000000..a326b9b --- /dev/null +++ b/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php @@ -0,0 +1,210 @@ +collectDesiredPermissions(); + } catch (InvalidArgumentException $e) { + $io->error($e->getMessage()); + + return Command::FAILURE; + } + + // Etape 2 : upsert transactionnel non destructif. + $this->em->beginTransaction(); + + try { + // Indexation des permissions existantes par code pour un acces O(1). + $existingByCode = []; + foreach ($this->permissionRepository->findAll() as $permission) { + $existingByCode[$permission->getCode()] = $permission; + } + + $added = 0; + $updated = 0; + $orphans = 0; + + // Upsert : chaque entree desiree est creee, revivee ou mise a jour. + foreach ($desiredPermissions as $code => $entry) { + $label = $entry['label']; + $module = $entry['module']; + + if (isset($existingByCode[$code])) { + $existing = $existingByCode[$code]; + + if ($existing->isOrphan()) { + // Revival : le code reapparait dans le source, on + // rafraichit ses metadonnees et on retire le flag. + $existing->revive($label, $module); + ++$updated; + } elseif ($existing->getLabel() !== $label || $existing->getModule() !== $module) { + // Mise a jour des metadonnees sans toucher au flag orphan. + $existing->updateMetadata($label, $module); + ++$updated; + } + // Sinon : strictement identique, no-op. + } else { + // Creation : on persiste directement via l'EM pour ne + // pas declencher un flush par appel (cf. save() repo). + $permission = new Permission($code, $label, $module); + $this->em->persist($permission); + ++$added; + } + } + + // Etape 3 : marquage orphelin des permissions absentes du source. + foreach ($existingByCode as $code => $existing) { + if (isset($desiredPermissions[$code])) { + continue; + } + + if (!$existing->isOrphan()) { + $existing->markOrphan(); + ++$orphans; + } + } + + // Un unique flush regroupe toutes les mutations de la transaction. + $this->em->flush(); + $this->em->commit(); + } catch (Throwable $e) { + $this->em->rollback(); + $io->error(sprintf('Echec de la synchronisation des permissions : %s', $e->getMessage())); + + return Command::FAILURE; + } + + $totalInDb = count($this->permissionRepository->findAll()); + + $io->success('Synchronisation des permissions RBAC terminee.'); + $io->table( + ['Indicateur', 'Valeur'], + [ + ['Permissions ajoutees', (string) $added], + ['Permissions mises a jour ou revivees', (string) $updated], + ['Permissions marquees orphelines', (string) $orphans], + ['Total en base apres sync', (string) $totalInDb], + ], + ); + + return Command::SUCCESS; + } + + /** + * Parcourt la liste des modules actifs declares dans `config/modules.php`, + * extrait leurs permissions statiques, valide strictement chaque entree + * puis renvoie une map indexee par code. + * + * Regles de validation appliquees : + * - chaque entree doit posseder exactement les cles `code` et `label` + * - le `code` doit etre prefixe par `::ID . '.'` + * - `code` et `label` ne peuvent pas etre des chaines vides + * + * Les modules ne definissant pas de methode statique `permissions()` sont + * ignores silencieusement (compat ascendante pour les modules legacy). + * + * @return array + */ + private function collectDesiredPermissions(): array + { + /** @var array $moduleClasses */ + $moduleClasses = require $this->projectDir.'/config/modules.php'; + + $desired = []; + + foreach ($moduleClasses as $moduleClass) { + if (!method_exists($moduleClass, 'permissions')) { + continue; + } + + /** @var array> $entries */ + $entries = $moduleClass::permissions(); + $moduleId = $moduleClass::ID; + + foreach ($entries as $entry) { + $keys = array_keys($entry); + sort($keys); + if (['code', 'label'] !== $keys) { + throw new InvalidArgumentException(sprintf( + 'Permission malformee declaree par %s : chaque entree doit contenir exactement les cles [code, label], recu [%s].', + $moduleClass, + implode(', ', array_keys($entry)), + )); + } + + $code = $entry['code']; + $label = $entry['label']; + + if ('' === $code) { + throw new InvalidArgumentException(sprintf( + 'Permission invalide declaree par %s : le code ne peut pas etre vide.', + $moduleClass, + )); + } + if ('' === $label) { + throw new InvalidArgumentException(sprintf( + 'Permission invalide declaree par %s (code "%s") : le libelle ne peut pas etre vide.', + $moduleClass, + $code, + )); + } + + $expectedPrefix = $moduleId.'.'; + if (!str_starts_with($code, $expectedPrefix)) { + throw new InvalidArgumentException(sprintf( + 'Permission invalide declaree par %s : le code "%s" doit etre prefixe par "%s" (ID du module).', + $moduleClass, + $code, + $expectedPrefix, + )); + } + + $desired[$code] = [ + 'code' => $code, + 'label' => $label, + 'module' => $moduleId, + ]; + } + } + + return $desired; + } +} -- 2.39.5 From d68aa0456ab2bb9086415c9788b969e276216d0a Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 14 Apr 2026 17:02:26 +0200 Subject: [PATCH 07/55] feat(core) : RBAC Task 5 - migration Doctrine RBAC + data-migration JSON roles - Nouvelles tables permission, role, role_permission, user_role, user_permission - Ajout user.is_admin (BOOLEAN, default false) - Seed des roles systeme admin et user via SQL brut (autonome, pas besoin de fixtures pour cette etape) - Migration des donnees : is_admin reflete ROLE_ADMIN du JSON roles, puis rattachement user_role selon admin/user - Drop user.roles en dernier (apres la migration de donnees) - down() recree la colonne roles et la rehydrate depuis is_admin Ticket #343 - 5/7 : persistance + migration donnees safe. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Migrations/Version20260414150034.php | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 src/Module/Core/Infrastructure/Doctrine/Migrations/Version20260414150034.php diff --git a/src/Module/Core/Infrastructure/Doctrine/Migrations/Version20260414150034.php b/src/Module/Core/Infrastructure/Doctrine/Migrations/Version20260414150034.php new file mode 100644 index 0000000..9129390 --- /dev/null +++ b/src/Module/Core/Infrastructure/Doctrine/Migrations/Version20260414150034.php @@ -0,0 +1,223 @@ + ADD is_admin + * -> seed des roles systeme -> migration des donnees -> DROP roles. + * + * Dependance avec Task 6 (fixtures) : + * Les fixtures applicatives reposent sur l'existence des roles systeme + * 'admin' et 'user' seedes ici par SQL brut. Cette migration est donc + * auto-suffisante et n'a pas besoin que les fixtures soient executees. + */ +final class Version20260414150034 extends AbstractMigration +{ + public function getDescription(): string + { + return 'RBAC : tables permission/role + jointures + is_admin + migration des donnees depuis user.roles'; + } + + public function up(Schema $schema): void + { + // 1) Creation des tables RBAC (permission, role, jointures). + $this->addSql(<<<'SQL' + CREATE TABLE permission ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + code VARCHAR(255) NOT NULL, + label VARCHAR(255) NOT NULL, + module VARCHAR(100) NOT NULL, + orphan BOOLEAN DEFAULT false NOT NULL, + PRIMARY KEY (id) + ) + SQL); + $this->addSql('CREATE INDEX idx_permission_module ON permission (module)'); + $this->addSql('CREATE INDEX idx_permission_orphan ON permission (orphan)'); + $this->addSql('CREATE UNIQUE INDEX uniq_permission_code ON permission (code)'); + $this->addSql(<<<'SQL' + CREATE TABLE "role" ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + code VARCHAR(100) NOT NULL, + label VARCHAR(255) NOT NULL, + description TEXT DEFAULT NULL, + is_system BOOLEAN DEFAULT false NOT NULL, + PRIMARY KEY (id) + ) + SQL); + $this->addSql('CREATE INDEX idx_role_is_system ON "role" (is_system)'); + $this->addSql('CREATE UNIQUE INDEX uniq_role_code ON "role" (code)'); + $this->addSql(<<<'SQL' + CREATE TABLE role_permission ( + role_id INT NOT NULL, + permission_id INT NOT NULL, + PRIMARY KEY (role_id, permission_id) + ) + SQL); + $this->addSql('CREATE INDEX IDX_6F7DF886D60322AC ON role_permission (role_id)'); + $this->addSql('CREATE INDEX IDX_6F7DF886FED90CCA ON role_permission (permission_id)'); + $this->addSql(<<<'SQL' + CREATE TABLE user_role ( + user_id INT NOT NULL, + role_id INT NOT NULL, + PRIMARY KEY (user_id, role_id) + ) + SQL); + $this->addSql('CREATE INDEX IDX_2DE8C6A3A76ED395 ON user_role (user_id)'); + $this->addSql('CREATE INDEX IDX_2DE8C6A3D60322AC ON user_role (role_id)'); + $this->addSql(<<<'SQL' + CREATE TABLE user_permission ( + user_id INT NOT NULL, + permission_id INT NOT NULL, + PRIMARY KEY (user_id, permission_id) + ) + SQL); + $this->addSql('CREATE INDEX IDX_472E5446A76ED395 ON user_permission (user_id)'); + $this->addSql('CREATE INDEX IDX_472E5446FED90CCA ON user_permission (permission_id)'); + $this->addSql(<<<'SQL' + ALTER TABLE + role_permission + ADD + CONSTRAINT FK_6F7DF886D60322AC FOREIGN KEY (role_id) REFERENCES "role" (id) ON DELETE CASCADE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE + role_permission + ADD + CONSTRAINT FK_6F7DF886FED90CCA FOREIGN KEY (permission_id) REFERENCES permission (id) ON DELETE CASCADE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE + user_role + ADD + CONSTRAINT FK_2DE8C6A3A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE + user_role + ADD + CONSTRAINT FK_2DE8C6A3D60322AC FOREIGN KEY (role_id) REFERENCES "role" (id) ON DELETE CASCADE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE + user_permission + ADD + CONSTRAINT FK_472E5446A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE + user_permission + ADD + CONSTRAINT FK_472E5446FED90CCA FOREIGN KEY (permission_id) REFERENCES permission (id) ON DELETE CASCADE + SQL); + + // 2) Ajout de la colonne is_admin sur "user" (avant la data-migration + // qui en a besoin pour marquer les super-admins). + $this->addSql('ALTER TABLE "user" ADD is_admin BOOLEAN DEFAULT false NOT NULL'); + + // 3) Seed des roles systeme avant toute migration de donnees utilisateurs. + // Les codes sont centralises dans App\Module\Core\Domain\Security\SystemRoles + // mais dupliques ici volontairement pour que la migration reste auto-suffisante. + $this->addSql("INSERT INTO \"role\" (code, label, description, is_system) VALUES ('admin', 'Administrateur', 'Role administrateur - bypass complet via is_admin', TRUE)"); + $this->addSql("INSERT INTO \"role\" (code, label, description, is_system) VALUES ('user', 'Utilisateur', 'Role de base sans permission specifique', TRUE)"); + + // 4) Bascule is_admin a TRUE pour tout user dont le JSON roles contient ROLE_ADMIN. + $this->addSql(<<<'SQL' + UPDATE "user" u + SET is_admin = TRUE + WHERE EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code + WHERE role_code = 'ROLE_ADMIN' + ) + SQL); + + // 5) Rattachement des admins au role systeme 'admin'. + $this->addSql(<<<'SQL' + INSERT INTO user_role (user_id, role_id) + SELECT u.id, r.id + FROM "user" u + CROSS JOIN "role" r + WHERE r.code = 'admin' + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code + WHERE role_code = 'ROLE_ADMIN' + ) + ON CONFLICT DO NOTHING + SQL); + + // 6) Rattachement des autres users (y compris roles vide ou NULL) au role 'user'. + $this->addSql(<<<'SQL' + INSERT INTO user_role (user_id, role_id) + SELECT u.id, r.id + FROM "user" u + CROSS JOIN "role" r + WHERE r.code = 'user' + AND NOT EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code + WHERE role_code = 'ROLE_ADMIN' + ) + ON CONFLICT DO NOTHING + SQL); + + // 7) Drop de la colonne "user".roles (DOIT etre la derniere instruction, + // apres la migration des donnees qui la lit). + $this->addSql('ALTER TABLE "user" DROP roles'); + } + + public function down(Schema $schema): void + { + // 1) Recreation de la colonne roles (avec un defaut pour permettre le + // backfill). Le NOT NULL est conserve comme dans le schema d'origine. + $this->addSql('ALTER TABLE "user" ADD roles JSON NOT NULL DEFAULT \'[]\''); + + // 2) Rehydratation du JSON roles depuis is_admin avant de perdre la colonne. + $this->addSql(<<<'SQL' + UPDATE "user" + SET roles = CASE + WHEN is_admin THEN '["ROLE_ADMIN"]'::json + ELSE '["ROLE_USER"]'::json + END + SQL); + + // 3) Drop des FK puis des tables de jointure (enfants d'abord). + $this->addSql('ALTER TABLE role_permission DROP CONSTRAINT FK_6F7DF886D60322AC'); + $this->addSql('ALTER TABLE role_permission DROP CONSTRAINT FK_6F7DF886FED90CCA'); + $this->addSql('ALTER TABLE user_role DROP CONSTRAINT FK_2DE8C6A3A76ED395'); + $this->addSql('ALTER TABLE user_role DROP CONSTRAINT FK_2DE8C6A3D60322AC'); + $this->addSql('ALTER TABLE user_permission DROP CONSTRAINT FK_472E5446A76ED395'); + $this->addSql('ALTER TABLE user_permission DROP CONSTRAINT FK_472E5446FED90CCA'); + $this->addSql('DROP TABLE user_permission'); + $this->addSql('DROP TABLE user_role'); + $this->addSql('DROP TABLE role_permission'); + + // 4) Drop des tables parentes permission et role. + $this->addSql('DROP TABLE permission'); + $this->addSql('DROP TABLE "role"'); + + // 5) Drop de is_admin, la colonne roles ayant ete rehydratee en amont. + $this->addSql('ALTER TABLE "user" DROP is_admin'); + } +} -- 2.39.5 From aafe08b6adb3fa2a6789c65ba2d220374a279353 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 14 Apr 2026 17:12:09 +0200 Subject: [PATCH 08/55] feat(core) : RBAC Task 6 - fixtures et CreateUserCommand branches sur les roles systeme - AppFixtures : rattachement des users aux entites Role via RoleRepositoryInterface. Re-seed idempotent des roles systeme dans ensureSystemRole() pour compenser le purger Doctrine qui vide la table role avant load(), afin que "make db-reset && make fixtures" reste un workflow one-shot. - CreateUserCommand : flag --admin attache au role systeme admin + is_admin, sinon au role user. Gestion d'erreur explicite si les roles systeme sont absents (FAILURE + message pointant vers la migration). - CreateUserCommand devient final, descriptions traduites en francais. Ticket #343 - 6/7 : fixtures et command alignes sur le RBAC relationnel. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Console/CreateUserCommand.php | 40 ++++++++++++++----- .../DataFixtures/AppFixtures.php | 38 +++++++++++++++++- 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/src/Module/Core/Infrastructure/Console/CreateUserCommand.php b/src/Module/Core/Infrastructure/Console/CreateUserCommand.php index 7133adc..f13afeb 100644 --- a/src/Module/Core/Infrastructure/Console/CreateUserCommand.php +++ b/src/Module/Core/Infrastructure/Console/CreateUserCommand.php @@ -5,7 +5,9 @@ declare(strict_types=1); namespace App\Module\Core\Infrastructure\Console; use App\Module\Core\Domain\Entity\User; +use App\Module\Core\Domain\Repository\RoleRepositoryInterface; use App\Module\Core\Domain\Repository\UserRepositoryInterface; +use App\Module\Core\Domain\Security\SystemRoles; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -17,13 +19,14 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; #[AsCommand( name: 'app:create-user', - description: 'Create a new user', + description: 'Cree un utilisateur rattache au role systeme admin ou user.', )] -class CreateUserCommand extends Command +final class CreateUserCommand extends Command { public function __construct( private readonly UserRepositoryInterface $userRepository, private readonly UserPasswordHasherInterface $passwordHasher, + private readonly RoleRepositoryInterface $roleRepository, ) { parent::__construct(); } @@ -31,9 +34,9 @@ class CreateUserCommand extends Command protected function configure(): void { $this - ->addArgument('username', InputArgument::REQUIRED, 'Username') - ->addArgument('password', InputArgument::REQUIRED, 'Plain password') - ->addOption('admin', null, InputOption::VALUE_NONE, 'Grant ROLE_ADMIN') + ->addArgument('username', InputArgument::REQUIRED, 'Nom d\'utilisateur') + ->addArgument('password', InputArgument::REQUIRED, 'Mot de passe en clair') + ->addOption('admin', null, InputOption::VALUE_NONE, 'Rattache au role systeme admin + active is_admin') ; } @@ -43,19 +46,34 @@ class CreateUserCommand extends Command $username = $input->getArgument('username'); $plainPassword = $input->getArgument('password'); + $isAdmin = (bool) $input->getOption('admin'); + + $roleCode = $isAdmin ? SystemRoles::ADMIN_CODE : SystemRoles::USER_CODE; + $role = $this->roleRepository->findByCode($roleCode); + + if (null === $role) { + $io->error(sprintf( + 'Le role systeme "%s" est introuvable. Lance "bin/console doctrine:migrations:migrate" pour le seeder.', + $roleCode, + )); + + return Command::FAILURE; + } $user = new User(); $user->setUsername($username); $user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword)); - - if ($input->getOption('admin')) { - // TODO Task 6 : attacher l'entite Role "admin" en plus du flag is_admin. - $user->setIsAdmin(true); - } + $user->setIsAdmin($isAdmin); + $user->addRbacRole($role); $this->userRepository->save($user); - $io->success(sprintf('User "%s" created%s.', $username, $input->getOption('admin') ? ' with ROLE_ADMIN' : '')); + $io->success(sprintf( + 'Utilisateur "%s" cree, rattache au role systeme "%s"%s.', + $username, + $roleCode, + $isAdmin ? ' (bypass is_admin actif)' : '', + )); return Command::SUCCESS; } diff --git a/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php b/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php index 0ba45fa..bde7cc8 100644 --- a/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php +++ b/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php @@ -4,36 +4,72 @@ declare(strict_types=1); namespace App\Module\Core\Infrastructure\DataFixtures; +use App\Module\Core\Domain\Entity\Role; use App\Module\Core\Domain\Entity\User; +use App\Module\Core\Domain\Repository\RoleRepositoryInterface; +use App\Module\Core\Domain\Security\SystemRoles; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; +/** + * Fixtures de base du module Core : 3 utilisateurs (1 admin + 2 standards) + * rattaches aux roles systeme RBAC seedes par la migration Version20260414150034. + * + * Note : le purger Doctrine execute avant load() supprime l'ensemble des + * entites managees, ce qui inclut la table role. On re-seede donc les roles + * systeme de maniere idempotente avant de rattacher les utilisateurs, afin + * que le workflow "make db-reset && make fixtures" reste one-shot. + */ class AppFixtures extends Fixture { public function __construct( private readonly UserPasswordHasherInterface $passwordHasher, + private readonly RoleRepositoryInterface $roleRepository, ) {} public function load(ObjectManager $manager): void { - // TODO Task 6 : cette fixture sera refactoree pour attacher les entites Role RBAC. + $adminRole = $this->ensureSystemRole($manager, SystemRoles::ADMIN_CODE, 'Administrateur'); + $userRole = $this->ensureSystemRole($manager, SystemRoles::USER_CODE, 'Utilisateur'); + $admin = new User(); $admin->setUsername('admin'); $admin->setIsAdmin(true); $admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin')); + $admin->addRbacRole($adminRole); $manager->persist($admin); $alice = new User(); $alice->setUsername('alice'); $alice->setPassword($this->passwordHasher->hashPassword($alice, 'alice')); + $alice->addRbacRole($userRole); $manager->persist($alice); $bob = new User(); $bob->setUsername('bob'); $bob->setPassword($this->passwordHasher->hashPassword($bob, 'bob')); + $bob->addRbacRole($userRole); $manager->persist($bob); $manager->flush(); } + + /** + * Retourne le role systeme correspondant au code donne, en le creant + * s'il n'existe pas encore (le purger Doctrine a pu vider la table role). + */ + private function ensureSystemRole(ObjectManager $manager, string $code, string $label): Role + { + $role = $this->roleRepository->findByCode($code); + + if (null !== $role) { + return $role; + } + + $role = new Role($code, $label, isSystem: true); + $manager->persist($role); + + return $role; + } } -- 2.39.5 From 0a496f34e0844758edd7ca2c816ac4d98edcc4cc Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 14 Apr 2026 17:15:23 +0200 Subject: [PATCH 09/55] fix(core) : RBAC Task 6 polish - descriptions des roles systeme coherentes ensureSystemRole() recopie desormais la description depuis la migration RBAC pour que les chemins prod (migration) et dev (fixtures) produisent un etat identique. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../DataFixtures/AppFixtures.php | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php b/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php index bde7cc8..855bba5 100644 --- a/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php +++ b/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php @@ -30,8 +30,18 @@ class AppFixtures extends Fixture public function load(ObjectManager $manager): void { - $adminRole = $this->ensureSystemRole($manager, SystemRoles::ADMIN_CODE, 'Administrateur'); - $userRole = $this->ensureSystemRole($manager, SystemRoles::USER_CODE, 'Utilisateur'); + $adminRole = $this->ensureSystemRole( + $manager, + SystemRoles::ADMIN_CODE, + 'Administrateur', + 'Role administrateur - bypass complet via is_admin', + ); + $userRole = $this->ensureSystemRole( + $manager, + SystemRoles::USER_CODE, + 'Utilisateur', + 'Role de base sans permission specifique', + ); $admin = new User(); $admin->setUsername('admin'); @@ -58,16 +68,24 @@ class AppFixtures extends Fixture /** * Retourne le role systeme correspondant au code donne, en le creant * s'il n'existe pas encore (le purger Doctrine a pu vider la table role). + * + * La description est recopiee depuis la migration RBAC pour que les + * deux chemins (migration prod, fixtures dev) produisent un etat + * identique. */ - private function ensureSystemRole(ObjectManager $manager, string $code, string $label): Role - { + private function ensureSystemRole( + ObjectManager $manager, + string $code, + string $label, + string $description, + ): Role { $role = $this->roleRepository->findByCode($code); if (null !== $role) { return $role; } - $role = new Role($code, $label, isSystem: true); + $role = new Role($code, $label, isSystem: true, description: $description); $manager->persist($role); return $role; -- 2.39.5 From eb0b49a7ef2e3f0fa5e4373c95915830df703989 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 14 Apr 2026 17:21:43 +0200 Subject: [PATCH 10/55] fix(core) : RBAC migration deplacee vers le namespace DoctrineMigrations racine Bug decouvert a l'execution de 'make db-reset' sur base vide : Doctrine Migrations 3.x avec plusieurs 'migrations_paths' execute les migrations dans l'ordre (namespace, version) et non (version, namespace). Le Version20260414150034 sous 'App\Module\Core\...' passait donc avant Version20260407095546 sous 'DoctrineMigrations', provoquant un "relation user does not exist". Deplacement du fichier vers 'migrations/' (namespace DoctrineMigrations). Le chemin modulaire reste configure pour les futurs modules, mais la migration RBAC d'initialisation vit a la racine pour que 'make db-reset' fonctionne en one-shot. Smoke test end-to-end valide : - db-reset + fixtures : admin (is_admin=t, role admin), alice/bob (is_admin=f, role user) - app:sync-permissions : 4 permissions Core ajoutees, idempotent au 2e run - User::getRoles() : ['ROLE_USER', 'ROLE_ADMIN'] pour admin, ['ROLE_USER'] pour alice/bob - User::getEffectivePermissions() : union triee des permissions via roles Ticket #343 - 7/7 : smoke test end-to-end OK. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Migrations => migrations}/Version20260414150034.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename {src/Module/Core/Infrastructure/Doctrine/Migrations => migrations}/Version20260414150034.php (99%) diff --git a/src/Module/Core/Infrastructure/Doctrine/Migrations/Version20260414150034.php b/migrations/Version20260414150034.php similarity index 99% rename from src/Module/Core/Infrastructure/Doctrine/Migrations/Version20260414150034.php rename to migrations/Version20260414150034.php index 9129390..04b5869 100644 --- a/src/Module/Core/Infrastructure/Doctrine/Migrations/Version20260414150034.php +++ b/migrations/Version20260414150034.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Module\Core\Infrastructure\Doctrine\Migrations; +namespace DoctrineMigrations; use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; -- 2.39.5 From 7ccc91386202656411507d0d38100e6f765cec79 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 14 Apr 2026 17:25:26 +0200 Subject: [PATCH 11/55] docs : exception CLAUDE.md pour les migrations multi-namespace Documente le bug Doctrine Migrations 3.x (tri par FQCN au lieu de version timestamp avec plusieurs migrations_paths) et la regle provisoire : migrations d'init au namespace racine, namespace modulaire reserve aux migrations applicatives. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 030a208..4c80457 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,6 +140,7 @@ Le code du module Commercial n'est pas touche. - `config/modules.php` = seule source de verite pour l'activation - `config/sidebar.php` = seule source de verite pour l'organisation de la sidebar (chaque item reference son module owner via la cle `module`) - Migrations par module dans `src/Module/{Module}/Infrastructure/Doctrine/Migrations/` +- **Exception connue** : avec plusieurs `migrations_paths` configures, Doctrine Migrations 3.x trie les migrations par FQCN alphabetique et non par version timestamp → ordre d'execution incorrect entre namespaces sur une base vide. Tant que ce n'est pas resolu (via un `MigrationsComparator` custom ou un upgrade), les migrations d'initialisation critiques (setup user, RBAC, etc.) vivent au namespace racine `DoctrineMigrations` dans `migrations/`. Le namespace modulaire reste configure pour les futures migrations applicatives (qui dependent d'un schema deja cree). **Frontend :** - Chaque module est un layer Nuxt auto-detecte (`modules/*/nuxt.config.ts` minimal) -- 2.39.5 From d8bda517f97eed31e746f9ed12c3b168156c7bfa Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 08:12:17 +0200 Subject: [PATCH 12/55] docs : ajoute note delegation Codex pour taches mecaniques --- CLAUDE.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 4c80457..3559259 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -266,3 +266,12 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash` - User admin : `admin` / `admin` (ROLE_ADMIN) - Users internes : `alice` / `alice`, `bob` / `bob` (ROLE_USER) + +## Delegation Codex + +Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification. + +- **Codex** = junior dev rapide et pas cher (executions mecaniques) +- **Claude** = senior dev qui verifie et reflechit (design, review, decisions) + +C'est le meilleur ratio qualite/credits. -- 2.39.5 From 0fc4e1651b19ee73ea54cc8c5368cdd9f93abf8d Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 08:15:43 +0200 Subject: [PATCH 13/55] fix(core) : retire user:write des champs RBAC sensibles du User isAdmin, roles et directPermissions ne doivent pas etre modifiables via PATCH /api/users/{id}. L exposition en ecriture sera traitee par un processor dedie dans le ticket #344 (spec section 2 OUT). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Module/Core/Domain/Entity/User.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Module/Core/Domain/Entity/User.php b/src/Module/Core/Domain/Entity/User.php index d06efdd..b571488 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -55,7 +55,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface private ?string $username = null; #[ORM\Column(name: 'is_admin', options: ['default' => false])] - #[Groups(['me:read', 'user:list', 'user:write'])] + #[Groups(['me:read', 'user:list'])] private bool $isAdmin = false; /** @@ -70,7 +70,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface */ #[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')] #[ORM\JoinTable(name: 'user_role')] - #[Groups(['me:read', 'user:list', 'user:write'])] + #[Groups(['me:read', 'user:list'])] private Collection $roles; /** @@ -83,7 +83,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface */ #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')] #[ORM\JoinTable(name: 'user_permission')] - #[Groups(['me:read', 'user:list', 'user:write'])] + #[Groups(['me:read', 'user:list'])] private Collection $directPermissions; #[ORM\Column] -- 2.39.5 From 1cf550721b75ff0ffc0ea85b1b45cbc02dce6a56 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 10:31:10 +0200 Subject: [PATCH 14/55] docs(rbac) : spec ticket #344 - API CRUD roles & permissions --- docs/rbac/ticket-344-spec.md | 275 +++++++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 docs/rbac/ticket-344-spec.md diff --git a/docs/rbac/ticket-344-spec.md b/docs/rbac/ticket-344-spec.md new file mode 100644 index 0000000..80db2e6 --- /dev/null +++ b/docs/rbac/ticket-344-spec.md @@ -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. -- 2.39.5 From fdb7aded82b53f6696cea5b92214b5b5342ffe7a Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 11:03:22 +0200 Subject: [PATCH 15/55] feat(core) : RBAC #344 - API Platform Permission en lecture seule - Expose l'entite Permission via ApiResource (GetCollection + Get uniquement) - Serialisation limitee au groupe permission:read (id, code, label, module, orphan) - Securite temporaire is_granted('ROLE_ADMIN'), a remplacer par is_granted('core.permissions.view') au ticket #345 - Filtres : SearchFilter exact sur module, BooleanFilter sur orphan - Configure api_platform.mapping.paths pour que le compile pass AP decouvre les ApiResource/ApiFilter declares dans src/Module/Core/Domain/Entity - Ajoute symfony/browser-kit et symfony/http-client en dev pour les tests fonctionnels API Platform, plus KERNEL_CLASS dans phpunit.dist.xml - Tests fonctionnels PermissionApiTest : collection, get item, filtres module et orphan, 405 sur POST, 401 non authentifie, 403 non-admin --- composer.json | 5 +- composer.lock | 492 ++++++++++++------- config/packages/api_platform.yaml | 7 + phpunit.dist.xml | 1 + src/Module/Core/Domain/Entity/Permission.php | 28 ++ tests/Module/Core/Api/PermissionApiTest.php | 185 +++++++ 6 files changed, 541 insertions(+), 177 deletions(-) create mode 100644 tests/Module/Core/Api/PermissionApiTest.php diff --git a/composer.json b/composer.json index de7bace..a3a9b4f 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,6 @@ "symfony/expression-language": "8.0.*", "symfony/flex": "^2", "symfony/framework-bundle": "8.0.*", - "symfony/http-client": "8.0.*", "symfony/mime": "8.0.*", "symfony/monolog-bundle": "^4.0", "symfony/property-access": "8.0.*", @@ -90,6 +89,8 @@ "require-dev": { "doctrine/doctrine-fixtures-bundle": "^4.3", "friendsofphp/php-cs-fixer": "^3.94", - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.0", + "symfony/browser-kit": "8.0.*", + "symfony/http-client": "8.0.*" } } diff --git a/composer.lock b/composer.lock index 7eded7a..43d0aeb 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bfd26e903d79f710cfe95452c05f2a25", + "content-hash": "75f8e672f2a401290886fbcf01befd3f", "packages": [ { "name": "api-platform/doctrine-common", @@ -4988,180 +4988,6 @@ ], "time": "2026-03-30T15:14:47+00:00" }, - { - "name": "symfony/http-client", - "version": "v8.0.8", - "source": { - "type": "git", - "url": "https://github.com/symfony/http-client.git", - "reference": "356e43d6994ae9d7761fd404d40f78691deabe0e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e", - "reference": "356e43d6994ae9d7761fd404d40f78691deabe0e", - "shasum": "" - }, - "require": { - "php": ">=8.4", - "psr/log": "^1|^2|^3", - "symfony/http-client-contracts": "~3.4.4|^3.5.2", - "symfony/service-contracts": "^2.5|^3" - }, - "conflict": { - "amphp/amp": "<3", - "php-http/discovery": "<1.15" - }, - "provide": { - "php-http/async-client-implementation": "*", - "php-http/client-implementation": "*", - "psr/http-client-implementation": "1.0", - "symfony/http-client-implementation": "3.0" - }, - "require-dev": { - "amphp/http-client": "^5.3.2", - "amphp/http-tunnel": "^2.0", - "guzzlehttp/promises": "^1.4|^2.0", - "nyholm/psr7": "^1.0", - "php-http/httplug": "^1.0|^2.0", - "psr/http-client": "^1.0", - "symfony/cache": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/messenger": "^7.4|^8.0", - "symfony/process": "^7.4|^8.0", - "symfony/rate-limiter": "^7.4|^8.0", - "symfony/stopwatch": "^7.4|^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\HttpClient\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", - "homepage": "https://symfony.com", - "keywords": [ - "http" - ], - "support": { - "source": "https://github.com/symfony/http-client/tree/v8.0.8" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-03-30T15:14:47+00:00" - }, - { - "name": "symfony/http-client-contracts", - "version": "v3.6.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "75d7043853a42837e68111812f4d964b01e5101c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", - "reference": "75d7043853a42837e68111812f4d964b01e5101c", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.6-dev" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Contracts\\HttpClient\\": "" - }, - "exclude-from-classmap": [ - "/Test/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Generic abstractions related to HTTP clients", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-04-29T11:18:49+00:00" - }, { "name": "symfony/http-foundation", "version": "v8.0.8", @@ -11018,6 +10844,322 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/browser-kit", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/browser-kit.git", + "reference": "f5a28fca785416cf489dd579011e74c831100cc3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/f5a28fca785416cf489dd579011e74c831100cc3", + "reference": "f5a28fca785416cf489dd579011e74c831100cc3", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/dom-crawler": "^7.4|^8.0" + }, + "require-dev": { + "symfony/css-selector": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\BrowserKit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/browser-kit/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/dom-crawler", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "284ace90732b445b027728b5e0eec6418a17a364" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/284ace90732b445b027728b5e0eec6418a17a364", + "reference": "284ace90732b445b027728b5e0eec6418a17a364", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.0" + }, + "require-dev": { + "symfony/css-selector": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/http-client", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "356e43d6994ae9d7761fd404d40f78691deabe0e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e", + "reference": "356e43d6994ae9d7761fd404d40f78691deabe0e", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/log": "^1|^2|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<3", + "php-http/discovery": "<1.15" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^5.3.2", + "amphp/http-tunnel": "^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "75d7043853a42837e68111812f4d964b01e5101c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", + "reference": "75d7043853a42837e68111812f4d964b01e5101c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-29T11:18:49+00:00" + }, { "name": "symfony/process", "version": "v8.0.8", diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 4f32b21..7558fb2 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -1,6 +1,13 @@ api_platform: title: Coltura API version: 1.0.0 + # Scan des modules pour decouvrir les classes ApiResource et ApiFilter. + # Sans ces paths, le compile pass d'API Platform ne declare pas les + # services de filtres annotes (les filtres etaient silencieusement + # ignores sur Permission — cf. ticket #344). + mapping: + paths: + - '%kernel.project_dir%/src/Module/Core/Domain/Entity' formats: jsonld: ['application/ld+json'] json: ['application/json'] diff --git a/phpunit.dist.xml b/phpunit.dist.xml index 22bd879..eb794bd 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -15,6 +15,7 @@ + diff --git a/src/Module/Core/Domain/Entity/Permission.php b/src/Module/Core/Domain/Entity/Permission.php index e5e92a0..83a3b06 100644 --- a/src/Module/Core/Domain/Entity/Permission.php +++ b/src/Module/Core/Domain/Entity/Permission.php @@ -4,10 +4,33 @@ declare(strict_types=1); namespace App\Module\Core\Domain\Entity; +use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; +use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository; use Doctrine\ORM\Mapping as ORM; use InvalidArgumentException; +use Symfony\Component\Serializer\Attribute\Groups; +#[ApiResource( + operations: [ + new GetCollection( + normalizationContext: ['groups' => ['permission:read']], + // TODO ticket #345 : remplacer par is_granted('core.permissions.view') + security: "is_granted('ROLE_ADMIN')", + ), + new Get( + normalizationContext: ['groups' => ['permission:read']], + // TODO ticket #345 : remplacer par is_granted('core.permissions.view') + security: "is_granted('ROLE_ADMIN')", + ), + ], +)] +#[ApiFilter(SearchFilter::class, properties: ['module' => 'exact'])] +#[ApiFilter(BooleanFilter::class, properties: ['orphan'])] #[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)] #[ORM\Table(name: 'permission')] #[ORM\UniqueConstraint(name: 'uniq_permission_code', columns: ['code'])] @@ -18,18 +41,23 @@ class Permission #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] + #[Groups(['permission:read'])] private ?int $id = null; #[ORM\Column(length: 255)] + #[Groups(['permission:read'])] private string $code; #[ORM\Column(length: 255)] + #[Groups(['permission:read'])] private string $label; #[ORM\Column(length: 100)] + #[Groups(['permission:read'])] private string $module; #[ORM\Column(options: ['default' => false])] + #[Groups(['permission:read'])] private bool $orphan = false; /** diff --git a/tests/Module/Core/Api/PermissionApiTest.php b/tests/Module/Core/Api/PermissionApiTest.php new file mode 100644 index 0000000..0e7fbe2 --- /dev/null +++ b/tests/Module/Core/Api/PermissionApiTest.php @@ -0,0 +1,185 @@ +em = self::getContainer()->get('doctrine')->getManager(); + + // Nettoyage defensif au cas ou un run precedent aurait laisse des restes. + $this->cleanupTestPermissions(); + + // Donnees de test : deux permissions "core" dont une orpheline, + // plus une permission d'un autre module pour verifier le filtre. + $p1 = new Permission('test.core.users.view', 'View users (test)', 'core'); + $p2 = new Permission('test.core.users.manage', 'Manage users (test)', 'core'); + $p3 = new Permission('test.commercial.clients.view', 'View clients (test)', 'commercial'); + $p2->markOrphan(); + + $this->em->persist($p1); + $this->em->persist($p2); + $this->em->persist($p3); + $this->em->flush(); + $this->em->clear(); + } + + protected function tearDown(): void + { + $this->cleanupTestPermissions(); + parent::tearDown(); + } + + public function testGetCollectionAsAdminReturns200(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/permissions'); + + self::assertResponseIsSuccessful(); + $data = $response->toArray(); + self::assertArrayHasKey('member', $data); + self::assertGreaterThanOrEqual(3, $data['totalItems']); + } + + public function testCollectionFilterByModule(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/permissions', [ + 'query' => ['module' => 'core'], + ]); + + self::assertResponseIsSuccessful(); + $data = $response->toArray(); + foreach ($data['member'] as $item) { + self::assertSame('core', $item['module']); + } + // Doit contenir au moins nos deux permissions core de test. + $codes = array_column($data['member'], 'code'); + self::assertContains('test.core.users.view', $codes); + self::assertContains('test.core.users.manage', $codes); + self::assertNotContains('test.commercial.clients.view', $codes); + } + + public function testCollectionFilterByOrphanFalse(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/permissions', [ + 'query' => ['orphan' => 'false'], + ]); + + self::assertResponseIsSuccessful(); + $data = $response->toArray(); + foreach ($data['member'] as $item) { + self::assertFalse($item['orphan']); + } + $codes = array_column($data['member'], 'code'); + self::assertContains('test.core.users.view', $codes); + self::assertNotContains('test.core.users.manage', $codes); + } + + public function testGetItemAsAdminReturnsAllReadFields(): void + { + /** @var null|Permission $permission */ + $permission = $this->em->getRepository(Permission::class) + ->findOneBy(['code' => 'test.core.users.view']) + ; + self::assertNotNull($permission); + + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/permissions/'.$permission->getId()); + + self::assertResponseIsSuccessful(); + $data = $response->toArray(); + self::assertSame($permission->getId(), $data['id']); + self::assertSame('test.core.users.view', $data['code']); + self::assertSame('View users (test)', $data['label']); + self::assertSame('core', $data['module']); + self::assertFalse($data['orphan']); + } + + public function testPostIsMethodNotAllowed(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $client->request('POST', '/api/permissions', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['code' => 'test.foo.bar.baz', 'label' => 'Foo', 'module' => 'foo'], + ]); + + self::assertResponseStatusCodeSame(405); + } + + public function testUnauthenticatedReturns401(): void + { + $client = self::createClient(); + $client->request('GET', '/api/permissions'); + + self::assertResponseStatusCodeSame(401); + } + + public function testNonAdminReturns403(): void + { + $client = $this->authenticatedClient('alice', 'alice'); + $client->request('GET', '/api/permissions'); + + self::assertResponseStatusCodeSame(403); + } + + private function cleanupTestPermissions(): void + { + $this->em->createQuery( + 'DELETE FROM '.Permission::class.' p WHERE p.code LIKE :prefix' + )->setParameter('prefix', self::TEST_CODE_PREFIX.'%')->execute(); + } + + /** + * Cree un client authentifie via /login_check. La configuration du projet + * pose le JWT dans un cookie HTTP-only `BEARER` (cf. lexik_jwt_authentication.yaml) + * et retire le token du body de reponse ; le client BrowserKit persiste + * automatiquement le cookie pour les requetes suivantes. + */ + private function authenticatedClient(string $username, string $password): Client + { + $client = self::createClient(); + $response = $client->request('POST', '/login_check', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['username' => $username, 'password' => $password], + ]); + + self::assertContains( + $response->getStatusCode(), + [200, 204], + 'Login failed for '.$username.': '.$response->getStatusCode(), + ); + + return $client; + } +} -- 2.39.5 From f79f061131387d22a1b685a61d09062da3451cee Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 11:15:41 +0200 Subject: [PATCH 16/55] fix(test) : RBAC #344 - corrige EM stale et ajoute cas orphan=true --- config/packages/api_platform.yaml | 3 +- tests/Module/Core/Api/PermissionApiTest.php | 59 +++++++++++++++++---- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 7558fb2..fc3d6a9 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -1,7 +1,8 @@ api_platform: title: Coltura API version: 1.0.0 - # Scan des modules pour decouvrir les classes ApiResource et ApiFilter. + # Scan du module Core pour decouvrir les classes ApiResource et ApiFilter. + # Ajouter un chemin par module lors de l'ajout d'entites ApiResource dans d'autres modules. # Sans ces paths, le compile pass d'API Platform ne declare pas les # services de filtres annotes (les filtres etaient silencieusement # ignores sur Permission — cf. ticket #344). diff --git a/tests/Module/Core/Api/PermissionApiTest.php b/tests/Module/Core/Api/PermissionApiTest.php index 0e7fbe2..9658bf7 100644 --- a/tests/Module/Core/Api/PermissionApiTest.php +++ b/tests/Module/Core/Api/PermissionApiTest.php @@ -27,14 +27,16 @@ final class PermissionApiTest extends ApiTestCase // eviter la deprecation emise a la creation du client de test. protected static ?bool $alwaysBootKernel = true; - private EntityManagerInterface $em; - protected function setUp(): void { parent::setUp(); + // On boote le kernel une fois pour pouvoir seeder les fixtures. + // ATTENTION : ne pas stocker l'EntityManager dans une propriete, + // chaque createClient() dans les tests rebootera le kernel et + // invalidera tout EM capture ici (cf. $alwaysBootKernel = true). self::bootKernel(); - $this->em = self::getContainer()->get('doctrine')->getManager(); + $em = $this->getEm(); // Nettoyage defensif au cas ou un run precedent aurait laisse des restes. $this->cleanupTestPermissions(); @@ -46,11 +48,11 @@ final class PermissionApiTest extends ApiTestCase $p3 = new Permission('test.commercial.clients.view', 'View clients (test)', 'commercial'); $p2->markOrphan(); - $this->em->persist($p1); - $this->em->persist($p2); - $this->em->persist($p3); - $this->em->flush(); - $this->em->clear(); + $em->persist($p1); + $em->persist($p2); + $em->persist($p3); + $em->flush(); + $em->clear(); } protected function tearDown(): void @@ -66,6 +68,9 @@ final class PermissionApiTest extends ApiTestCase self::assertResponseIsSuccessful(); $data = $response->toArray(); + // API Platform 4 emet du JSON-LD 1.1 avec un @context qui utilise un + // @vocab : les cles sortent donc non prefixees (`member`, `totalItems`) + // au lieu des anciennes `hydra:member` / `hydra:totalItems`. self::assertArrayHasKey('member', $data); self::assertGreaterThanOrEqual(3, $data['totalItems']); } @@ -89,6 +94,26 @@ final class PermissionApiTest extends ApiTestCase self::assertNotContains('test.commercial.clients.view', $codes); } + public function testCollectionFilterByOrphanTrue(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/permissions', [ + 'query' => ['orphan' => 'true'], + ]); + + self::assertResponseIsSuccessful(); + $data = $response->toArray(); + foreach ($data['member'] as $item) { + self::assertTrue($item['orphan']); + } + $codes = array_column($data['member'], 'code'); + // La permission marquee orpheline dans setUp() doit remonter... + self::assertContains('test.core.users.manage', $codes); + // ...et celles non orphelines doivent etre exclues. + self::assertNotContains('test.core.users.view', $codes); + self::assertNotContains('test.commercial.clients.view', $codes); + } + public function testCollectionFilterByOrphanFalse(): void { $client = $this->authenticatedClient('admin', 'admin'); @@ -109,7 +134,7 @@ final class PermissionApiTest extends ApiTestCase public function testGetItemAsAdminReturnsAllReadFields(): void { /** @var null|Permission $permission */ - $permission = $this->em->getRepository(Permission::class) + $permission = $this->getEm()->getRepository(Permission::class) ->findOneBy(['code' => 'test.core.users.view']) ; self::assertNotNull($permission); @@ -153,9 +178,23 @@ final class PermissionApiTest extends ApiTestCase self::assertResponseStatusCodeSame(403); } + /** + * Recupere l'EntityManager depuis le container courant. A utiliser a + * chaque appel : apres un createClient(), le kernel est reboote et tout + * EM precedemment capture est invalide. + */ + private function getEm(): EntityManagerInterface + { + if (!self::$kernel) { + self::bootKernel(); + } + + return self::getContainer()->get('doctrine')->getManager(); + } + private function cleanupTestPermissions(): void { - $this->em->createQuery( + $this->getEm()->createQuery( 'DELETE FROM '.Permission::class.' p WHERE p.code LIKE :prefix' )->setParameter('prefix', self::TEST_CODE_PREFIX.'%')->execute(); } -- 2.39.5 From 7be0260b2958fdfa50fc00ca13d663b3329dc589 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 11:41:21 +0200 Subject: [PATCH 17/55] feat(core) : RBAC #344 - API Platform Role CRUD nominal + validators --- src/Module/Core/Domain/Entity/Role.php | 64 +++++ tests/Module/Core/Api/RoleApiTest.php | 319 +++++++++++++++++++++++++ 2 files changed, 383 insertions(+) create mode 100644 tests/Module/Core/Api/RoleApiTest.php diff --git a/src/Module/Core/Domain/Entity/Role.php b/src/Module/Core/Domain/Entity/Role.php index 7583454..f5a2a31 100644 --- a/src/Module/Core/Domain/Entity/Role.php +++ b/src/Module/Core/Domain/Entity/Role.php @@ -4,12 +4,24 @@ declare(strict_types=1); namespace App\Module\Core\Domain\Entity; +use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; use App\Module\Core\Domain\Exception\SystemRoleDeletionException; use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Serializer\Attribute\SerializedName; +use Symfony\Component\Validator\Constraints as Assert; /** * Role RBAC : groupe nomme de permissions assignable a un utilisateur. @@ -18,27 +30,72 @@ use Doctrine\ORM\Mapping as ORM; * "personnalise" (cree par un administrateur). Seuls les roles personnalises * peuvent etre supprimes. */ +#[ApiResource( + operations: [ + new GetCollection( + normalizationContext: ['groups' => ['role:read']], + // TODO ticket #345 : remplacer par is_granted('core.roles.manage') + security: "is_granted('ROLE_ADMIN')", + ), + new Get( + normalizationContext: ['groups' => ['role:read']], + // TODO ticket #345 : remplacer par is_granted('core.roles.manage') + security: "is_granted('ROLE_ADMIN')", + ), + new Post( + normalizationContext: ['groups' => ['role:read']], + denormalizationContext: ['groups' => ['role:write']], + // TODO ticket #345 : remplacer par is_granted('core.roles.manage') + security: "is_granted('ROLE_ADMIN')", + ), + new Patch( + normalizationContext: ['groups' => ['role:read']], + denormalizationContext: ['groups' => ['role:write']], + // TODO ticket #345 : remplacer par is_granted('core.roles.manage') + security: "is_granted('ROLE_ADMIN')", + ), + new Delete( + // TODO ticket #345 : remplacer par is_granted('core.roles.manage') + security: "is_granted('ROLE_ADMIN')", + ), + ], + normalizationContext: ['groups' => ['role:read']], + denormalizationContext: ['groups' => ['role:write']], +)] +#[ApiFilter(BooleanFilter::class, properties: ['isSystem'])] #[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'])] +#[UniqueEntity(fields: ['code'], message: 'Un role avec ce code existe deja.')] class Role { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] + #[Groups(['role:read'])] private ?int $id = null; #[ORM\Column(length: 100)] + #[Groups(['role:read', 'role:write'])] + #[Assert\NotBlank] + #[Assert\Regex(pattern: '/^[a-z][a-z0-9_]*$/', message: 'Le code doit etre en snake_case et commencer par une lettre minuscule.')] private string $code; #[ORM\Column(length: 255)] + #[Groups(['role:read', 'role:write'])] + #[Assert\NotBlank] private string $label; #[ORM\Column(type: Types::TEXT, nullable: true)] + #[Groups(['role:read', 'role:write'])] private ?string $description = null; + // Volontairement exclu du groupe `role:write` : un client ne doit jamais + // pouvoir positionner ce flag via l'API. Seules les fixtures et migrations + // creent les roles systeme. #[ORM\Column(name: 'is_system', options: ['default' => false])] + #[Groups(['role:read'])] private bool $isSystem = false; /** @var Collection */ @@ -53,6 +110,7 @@ class Role // projection cachee (ticket a ouvrir a ce moment-la). #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')] #[ORM\JoinTable(name: 'role_permission')] + #[Groups(['role:read', 'role:write'])] private Collection $permissions; public function __construct(string $code, string $label, bool $isSystem = false, ?string $description = null) @@ -84,6 +142,12 @@ class Role return $this->description; } + // Le getter est annote directement car la convention Symfony PropertyInfo + // strip le prefixe `is` et exposerait le champ sous le nom `system`. On + // pose donc un SerializedName explicite pour garantir la sortie JSON-LD + // sous `isSystem`, nom attendu par les clients de l'API. + #[Groups(['role:read'])] + #[SerializedName('isSystem')] public function isSystem(): bool { return $this->isSystem; diff --git a/tests/Module/Core/Api/RoleApiTest.php b/tests/Module/Core/Api/RoleApiTest.php new file mode 100644 index 0000000..c5683de --- /dev/null +++ b/tests/Module/Core/Api/RoleApiTest.php @@ -0,0 +1,319 @@ +getEm(); + + // Nettoyage defensif au cas ou un run precedent aurait laisse des restes. + $this->cleanupTestData(); + + // Permissions de test reutilisables (notamment pour le PATCH). + $p1 = new Permission('test.core.roles.view', 'View roles (test)', 'core'); + $p2 = new Permission('test.core.roles.manage', 'Manage roles (test)', 'core'); + $em->persist($p1); + $em->persist($p2); + + // Role custom existant : utilise pour les GET / PATCH / DELETE. + $editor = new Role('test_editor', 'Editeur (test)', false, 'Role de test editeur'); + $em->persist($editor); + + // Deuxieme role custom : pour enrichir les collections. + $viewer = new Role('test_viewer', 'Visualisateur (test)', false); + $em->persist($viewer); + + $em->flush(); + $em->clear(); + } + + protected function tearDown(): void + { + $this->cleanupTestData(); + parent::tearDown(); + } + + public function testPostCreatesCustomRoleAsAdmin(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('POST', '/api/roles', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'code' => 'test_new_editor', + 'label' => 'Nouvel editeur', + 'description' => 'Role de test', + ], + ]); + + self::assertResponseStatusCodeSame(201); + $data = $response->toArray(); + self::assertSame('test_new_editor', $data['code']); + self::assertSame('Nouvel editeur', $data['label']); + self::assertFalse($data['isSystem']); + + // Verification cote base : le role existe et isSystem = false. + $persisted = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => 'test_new_editor']); + self::assertNotNull($persisted); + self::assertFalse($persisted->isSystem()); + } + + public function testPostWithDuplicateCodeReturns422(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $client->request('POST', '/api/roles', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + // `admin` est un role systeme charge par les fixtures. + 'code' => 'admin', + 'label' => 'Tentative de doublon', + ], + ]); + + self::assertResponseStatusCodeSame(422); + } + + public function testPostWithInvalidCodeReturns422(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $client->request('POST', '/api/roles', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + // Majuscules interdites par la regex snake_case. + 'code' => 'BadCode', + 'label' => 'Code invalide', + ], + ]); + + self::assertResponseStatusCodeSame(422); + } + + public function testPostWithIsSystemTrueIgnoresItAndPersistsFalse(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('POST', '/api/roles', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'code' => 'test_sneaky', + 'label' => 'Tentative systeme', + 'isSystem' => true, + ], + ]); + + self::assertResponseStatusCodeSame(201); + $data = $response->toArray(); + self::assertFalse($data['isSystem']); + + $persisted = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => 'test_sneaky']); + self::assertNotNull($persisted); + self::assertFalse($persisted->isSystem()); + } + + public function testGetCollectionAsAdminReturnsRoles(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/roles'); + + self::assertResponseIsSuccessful(); + $data = $response->toArray(); + self::assertArrayHasKey('member', $data); + // Au moins admin systeme + user systeme + test_editor + test_viewer. + self::assertGreaterThanOrEqual(2, $data['totalItems']); + $codes = array_column($data['member'], 'code'); + self::assertContains('test_editor', $codes); + } + + public function testGetCollectionFilterByIsSystemTrue(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/roles', [ + 'query' => ['isSystem' => 'true'], + ]); + + self::assertResponseIsSuccessful(); + $data = $response->toArray(); + foreach ($data['member'] as $item) { + self::assertTrue($item['isSystem']); + } + $codes = array_column($data['member'], 'code'); + self::assertNotContains('test_editor', $codes); + self::assertNotContains('test_viewer', $codes); + } + + public function testGetItemReturnsAllReadFields(): void + { + $role = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => 'test_editor']); + self::assertNotNull($role); + + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/roles/'.$role->getId()); + + self::assertResponseIsSuccessful(); + $data = $response->toArray(); + self::assertSame('test_editor', $data['code']); + self::assertSame('Editeur (test)', $data['label']); + self::assertSame('Role de test editeur', $data['description']); + self::assertFalse($data['isSystem']); + self::assertArrayHasKey('permissions', $data); + self::assertIsArray($data['permissions']); + } + + public function testPatchCustomRoleUpdatesLabelAndAddsPermission(): void + { + $em = $this->getEm(); + $role = $em->getRepository(Role::class)->findOneBy(['code' => 'test_editor']); + self::assertNotNull($role); + $permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'test.core.roles.view']); + self::assertNotNull($permission); + + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('PATCH', '/api/roles/'.$role->getId(), [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => [ + 'label' => 'Editeur modifie', + 'permissions' => ['/api/permissions/'.$permission->getId()], + ], + ]); + + self::assertResponseIsSuccessful(); + $data = $response->toArray(); + self::assertSame('Editeur modifie', $data['label']); + self::assertCount(1, $data['permissions']); + + // Verification cote base. + $em->clear(); + + /** @var Role $reloaded */ + $reloaded = $em->getRepository(Role::class)->findOneBy(['code' => 'test_editor']); + self::assertSame('Editeur modifie', $reloaded->getLabel()); + self::assertCount(1, $reloaded->getPermissions()); + } + + public function testDeleteCustomRoleReturns204(): void + { + $role = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => 'test_viewer']); + self::assertNotNull($role); + $id = $role->getId(); + + $client = $this->authenticatedClient('admin', 'admin'); + $client->request('DELETE', '/api/roles/'.$id); + + self::assertResponseStatusCodeSame(204); + + $em = $this->getEm(); + $em->clear(); + self::assertNull($em->getRepository(Role::class)->find($id)); + } + + public function testUnauthenticatedGetCollectionReturns401(): void + { + $client = self::createClient(); + $client->request('GET', '/api/roles'); + + self::assertResponseStatusCodeSame(401); + } + + public function testNonAdminGetCollectionReturns403(): void + { + $client = $this->authenticatedClient('alice', 'alice'); + $client->request('GET', '/api/roles'); + + self::assertResponseStatusCodeSame(403); + } + + /** + * Recupere l'EntityManager depuis le container courant. A utiliser a + * chaque appel : apres un createClient(), le kernel est reboote et tout + * EM precedemment capture est invalide. + */ + private function getEm(): EntityManagerInterface + { + if (!self::$kernel) { + self::bootKernel(); + } + + return self::getContainer()->get('doctrine')->getManager(); + } + + /** + * Purge les donnees de test (roles et permissions prefixees `test.`). + * Ne touche JAMAIS aux roles systeme `admin` et `user` charges par les + * fixtures. + */ + private function cleanupTestData(): void + { + $em = $this->getEm(); + + // Ordre important : role_permission lie aux deux, on vide les roles + // custom d'abord (la jointure est cascade supprimee par Doctrine lors + // du remove() du cote proprietaire). En DQL bulk on passe par les + // entites, Doctrine genere les DELETE de la table de jointure. + $em->createQuery( + 'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix' + )->setParameter('prefix', self::TEST_ROLE_PREFIX.'%')->execute(); + + $em->createQuery( + 'DELETE FROM '.Permission::class.' p WHERE p.code LIKE :prefix' + )->setParameter('prefix', self::TEST_PERMISSION_PREFIX.'%')->execute(); + } + + /** + * Cree un client authentifie via /login_check (cookie BEARER pose par + * lexik_jwt_authentication et persiste automatiquement par BrowserKit). + */ + private function authenticatedClient(string $username, string $password): Client + { + $client = self::createClient(); + $response = $client->request('POST', '/login_check', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['username' => $username, 'password' => $password], + ]); + + self::assertContains( + $response->getStatusCode(), + [200, 204], + 'Login failed for '.$username.': '.$response->getStatusCode(), + ); + + return $client; + } +} -- 2.39.5 From efc12c8bdb448204adbb472a2bd6bbb863b21482 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 11:53:01 +0200 Subject: [PATCH 18/55] fix(test) : RBAC #344 - role test cleanup + SystemRoles constant + assertion seuil --- tests/Module/Core/Api/RoleApiTest.php | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/Module/Core/Api/RoleApiTest.php b/tests/Module/Core/Api/RoleApiTest.php index c5683de..a0b0fa4 100644 --- a/tests/Module/Core/Api/RoleApiTest.php +++ b/tests/Module/Core/Api/RoleApiTest.php @@ -8,6 +8,7 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Symfony\Bundle\Test\Client; use App\Module\Core\Domain\Entity\Permission; use App\Module\Core\Domain\Entity\Role; +use App\Module\Core\Domain\Security\SystemRoles; use Doctrine\ORM\EntityManagerInterface; /** @@ -104,7 +105,7 @@ final class RoleApiTest extends ApiTestCase 'headers' => ['Content-Type' => 'application/ld+json'], 'json' => [ // `admin` est un role systeme charge par les fixtures. - 'code' => 'admin', + 'code' => SystemRoles::ADMIN_CODE, 'label' => 'Tentative de doublon', ], ]); @@ -157,7 +158,7 @@ final class RoleApiTest extends ApiTestCase $data = $response->toArray(); self::assertArrayHasKey('member', $data); // Au moins admin systeme + user systeme + test_editor + test_viewer. - self::assertGreaterThanOrEqual(2, $data['totalItems']); + self::assertGreaterThanOrEqual(4, $data['totalItems']); $codes = array_column($data['member'], 'code'); self::assertContains('test_editor', $codes); } @@ -283,10 +284,12 @@ final class RoleApiTest extends ApiTestCase { $em = $this->getEm(); - // Ordre important : role_permission lie aux deux, on vide les roles - // custom d'abord (la jointure est cascade supprimee par Doctrine lors - // du remove() du cote proprietaire). En DQL bulk on passe par les - // entites, Doctrine genere les DELETE de la table de jointure. + // Le cascade FK de la migration #343 (ON DELETE CASCADE sur + // role_permission.role_id et permission_id) nettoie automatiquement + // role_permission lors du DELETE SQL emis par Doctrine, meme via DQL + // bulk delete : le cascade est applique au niveau FK par PostgreSQL, + // pas par l'Unit of Work Doctrine. Verifie par comptage avant/apres + // runs successifs de la suite (stable a la ligne de base systeme). $em->createQuery( 'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix' )->setParameter('prefix', self::TEST_ROLE_PREFIX.'%')->execute(); -- 2.39.5 From d527fbe2d1a59151a402aaaaaf37a6cddec4e220 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 11:58:37 +0200 Subject: [PATCH 19/55] feat(core) : RBAC #344 - RoleProcessor + gardes systeme et code immuable --- src/Module/Core/Domain/Entity/Role.php | 17 ++ .../State/Processor/RoleProcessor.php | 78 +++++++ tests/Module/Core/Api/RoleApiTest.php | 71 ++++++ .../State/Processor/RoleProcessorTest.php | 212 ++++++++++++++++++ 4 files changed, 378 insertions(+) create mode 100644 src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php create mode 100644 tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessorTest.php diff --git a/src/Module/Core/Domain/Entity/Role.php b/src/Module/Core/Domain/Entity/Role.php index f5a2a31..14d20cd 100644 --- a/src/Module/Core/Domain/Entity/Role.php +++ b/src/Module/Core/Domain/Entity/Role.php @@ -13,6 +13,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use App\Module\Core\Domain\Exception\SystemRoleDeletionException; +use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\RoleProcessor; use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; @@ -47,16 +48,19 @@ use Symfony\Component\Validator\Constraints as Assert; denormalizationContext: ['groups' => ['role:write']], // TODO ticket #345 : remplacer par is_granted('core.roles.manage') security: "is_granted('ROLE_ADMIN')", + processor: RoleProcessor::class, ), new Patch( normalizationContext: ['groups' => ['role:read']], denormalizationContext: ['groups' => ['role:write']], // TODO ticket #345 : remplacer par is_granted('core.roles.manage') security: "is_granted('ROLE_ADMIN')", + processor: RoleProcessor::class, ), new Delete( // TODO ticket #345 : remplacer par is_granted('core.roles.manage') security: "is_granted('ROLE_ADMIN')", + processor: RoleProcessor::class, ), ], normalizationContext: ['groups' => ['role:read']], @@ -159,6 +163,19 @@ class Role return $this->permissions; } + /** + * Setter expose uniquement a la denormalisation API Platform pour + * permettre au RoleProcessor de detecter une tentative de modification + * du code (garde "code immuable"). Le code reste en pratique fige apres + * creation : le processor refuse toute modification via 400. + */ + public function setCode(string $code): static + { + $this->code = $code; + + return $this; + } + /** * Met a jour le libelle affichable du role. Le code reste immuable pour * garantir la stabilite des references cote fixtures et migrations. diff --git a/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php b/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php new file mode 100644 index 0000000..70a8ba3 --- /dev/null +++ b/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php @@ -0,0 +1,78 @@ + + */ +final class RoleProcessor implements ProcessorInterface +{ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] + private readonly ProcessorInterface $removeProcessor, + private readonly EntityManagerInterface $entityManager, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof Role) { + // Securite : si le provider n'a pas fourni un Role, on delegue + // quand meme au processor approprie pour ne pas etouffer + // silencieusement un bug de configuration. + return $operation instanceof DeleteOperationInterface + ? $this->removeProcessor->process($data, $operation, $uriVariables, $context) + : $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof DeleteOperationInterface) { + try { + $data->ensureDeletable(); + } catch (SystemRoleDeletionException $e) { + // Traduction HTTP : le domaine reste pur, l'API renvoie 403. + throw new AccessDeniedHttpException($e->getMessage(), $e); + } + + return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } + + // Ecriture (POST/PATCH) : verifier l'immuabilite du `code`. + // L'UnitOfWork n'expose un etat d'origine que pour les entites deja + // managees (PATCH). Pour un POST (entite nouvelle), `getOriginalEntityData` + // retourne un tableau vide : aucune comparaison necessaire. + $originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data); + + if (isset($originalData['code']) && $originalData['code'] !== $data->getCode()) { + throw new BadRequestHttpException("Le code d'un role est immuable apres creation."); + } + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } +} diff --git a/tests/Module/Core/Api/RoleApiTest.php b/tests/Module/Core/Api/RoleApiTest.php index a0b0fa4..e204652 100644 --- a/tests/Module/Core/Api/RoleApiTest.php +++ b/tests/Module/Core/Api/RoleApiTest.php @@ -245,6 +245,77 @@ final class RoleApiTest extends ApiTestCase self::assertNull($em->getRepository(Role::class)->find($id)); } + public function testDeleteSystemRoleReturns403(): void + { + $role = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => SystemRoles::ADMIN_CODE]); + self::assertNotNull($role); + + $client = $this->authenticatedClient('admin', 'admin'); + $client->request('DELETE', '/api/roles/'.$role->getId()); + + self::assertResponseStatusCodeSame(403); + + // Le role systeme doit toujours exister. + $em = $this->getEm(); + $em->clear(); + self::assertNotNull($em->getRepository(Role::class)->findOneBy(['code' => SystemRoles::ADMIN_CODE])); + } + + public function testPatchSystemRoleLabelReturns200(): void + { + $em = $this->getEm(); + $role = $em->getRepository(Role::class)->findOneBy(['code' => SystemRoles::ADMIN_CODE]); + self::assertNotNull($role); + $originalLabel = $role->getLabel(); + $roleId = $role->getId(); + + $client = $this->authenticatedClient('admin', 'admin'); + + try { + $response = $client->request('PATCH', '/api/roles/'.$roleId, [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['label' => 'Administrateur (modifie test)'], + ]); + + self::assertResponseIsSuccessful(); + $data = $response->toArray(); + self::assertSame('Administrateur (modifie test)', $data['label']); + self::assertSame(SystemRoles::ADMIN_CODE, $data['code']); + self::assertTrue($data['isSystem']); + } finally { + // Restauration defensive du label original pour ne pas polluer + // les tests suivants (les fixtures systeme sont partagees). + $em = $this->getEm(); + + /** @var null|Role $reloaded */ + $reloaded = $em->getRepository(Role::class)->findOneBy(['code' => SystemRoles::ADMIN_CODE]); + if (null !== $reloaded && $reloaded->getLabel() !== $originalLabel) { + $reloaded->setLabel($originalLabel); + $em->flush(); + } + } + } + + public function testPatchRoleCodeChangeReturns400(): void + { + $role = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => 'test_editor']); + self::assertNotNull($role); + + $client = $this->authenticatedClient('admin', 'admin'); + $client->request('PATCH', '/api/roles/'.$role->getId(), [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['code' => 'test_editor_renamed'], + ]); + + self::assertResponseStatusCodeSame(400); + + // Verification cote base : le code d'origine n'a pas bouge. + $em = $this->getEm(); + $em->clear(); + self::assertNotNull($em->getRepository(Role::class)->findOneBy(['code' => 'test_editor'])); + self::assertNull($em->getRepository(Role::class)->findOneBy(['code' => 'test_editor_renamed'])); + } + public function testUnauthenticatedGetCollectionReturns401(): void { $client = self::createClient(); diff --git a/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessorTest.php b/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessorTest.php new file mode 100644 index 0000000..dc890b9 --- /dev/null +++ b/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessorTest.php @@ -0,0 +1,212 @@ +persistProcessor = $this->createMock(ProcessorInterface::class); + $this->removeProcessor = $this->createMock(ProcessorInterface::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->unitOfWork = $this->createMock(UnitOfWork::class); + + $this->entityManager->method('getUnitOfWork')->willReturn($this->unitOfWork); + + $this->processor = new RoleProcessor( + $this->persistProcessor, + $this->removeProcessor, + $this->entityManager, + ); + } + + public function testDeleteCustomRoleDelegatesToRemoveProcessor(): void + { + $role = new Role('editor', 'Editor', false); + + $this->removeProcessor + ->expects(self::once()) + ->method('process') + ->with($role) + ->willReturn(null) + ; + + $this->persistProcessor->expects(self::never())->method('process'); + + $result = $this->processor->process($role, new Delete()); + + self::assertNull($result); + } + + public function testDeleteSystemRoleThrowsAccessDeniedHttpException(): void + { + $role = new Role('admin', 'Admin', true); + + $this->removeProcessor->expects(self::never())->method('process'); + $this->persistProcessor->expects(self::never())->method('process'); + + $this->expectException(AccessDeniedHttpException::class); + + $this->processor->process($role, new Delete()); + } + + public function testPostCreatesCustomRoleDelegatesToPersistProcessor(): void + { + $role = new Role('editor', 'Editor', false); + + // Entite nouvelle : l'UnitOfWork n'a pas d'etat d'origine. + $this->unitOfWork + ->expects(self::once()) + ->method('getOriginalEntityData') + ->with($role) + ->willReturn([]) + ; + + $this->persistProcessor + ->expects(self::once()) + ->method('process') + ->with($role) + ->willReturn($role) + ; + + $this->removeProcessor->expects(self::never())->method('process'); + + $result = $this->processor->process($role, new Post()); + + self::assertSame($role, $result); + } + + public function testPatchWithChangedCodeThrowsBadRequestHttpException(): void + { + // L'entite arrive avec le nouveau code deja applique par le denormalizer. + $role = new Role('editor_renamed', 'Editor', false); + $this->setRoleId($role, 42); + + $this->unitOfWork + ->expects(self::once()) + ->method('getOriginalEntityData') + ->with($role) + ->willReturn([ + 'id' => 42, + 'code' => 'editor', + 'label' => 'Editor', + 'isSystem' => false, + ]) + ; + + $this->persistProcessor->expects(self::never())->method('process'); + $this->removeProcessor->expects(self::never())->method('process'); + + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage("Le code d'un role est immuable apres creation."); + + $this->processor->process($role, new Patch()); + } + + public function testPatchWithUnchangedCodeDelegatesToPersistProcessor(): void + { + $role = new Role('editor', 'Editor modifie', false, 'desc'); + $this->setRoleId($role, 42); + + $this->unitOfWork + ->expects(self::once()) + ->method('getOriginalEntityData') + ->with($role) + ->willReturn([ + 'id' => 42, + 'code' => 'editor', + 'label' => 'Editor', + 'isSystem' => false, + ]) + ; + + $this->persistProcessor + ->expects(self::once()) + ->method('process') + ->with($role) + ->willReturn($role) + ; + + $this->removeProcessor->expects(self::never())->method('process'); + + $result = $this->processor->process($role, new Patch()); + + self::assertSame($role, $result); + } + + public function testPatchSystemRoleLabelDelegatesToPersistProcessor(): void + { + // Regle uniforme : un role systeme peut voir son label modifie tant + // que son code reste inchange. Seul le DELETE est bloque. + $role = new Role('admin', 'Administrateur', true); + $this->setRoleId($role, 1); + + $this->unitOfWork + ->expects(self::once()) + ->method('getOriginalEntityData') + ->with($role) + ->willReturn([ + 'id' => 1, + 'code' => 'admin', + 'label' => 'Admin', + 'isSystem' => true, + ]) + ; + + $this->persistProcessor + ->expects(self::once()) + ->method('process') + ->with($role) + ->willReturn($role) + ; + + $this->removeProcessor->expects(self::never())->method('process'); + + $result = $this->processor->process($role, new Patch()); + + self::assertSame($role, $result); + } + + /** + * Positionne l'id d'un Role via reflection pour simuler une entite deja + * persistee (les mocks d'UnitOfWork n'alimentent pas l'id tout seul). + */ + private function setRoleId(Role $role, int $id): void + { + $refl = new ReflectionClass($role); + $prop = $refl->getProperty('id'); + $prop->setAccessible(true); + $prop->setValue($role, $id); + } +} -- 2.39.5 From 87aa1d0b04de7c1b9a22b758402877aa9ba80842 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 12:05:26 +0200 Subject: [PATCH 20/55] test(core) : RBAC #344 - renforce docblock setCode + assertion message exception --- src/Module/Core/Domain/Entity/Role.php | 4 ++++ .../ApiPlatform/State/Processor/RoleProcessorTest.php | 1 + 2 files changed, 5 insertions(+) diff --git a/src/Module/Core/Domain/Entity/Role.php b/src/Module/Core/Domain/Entity/Role.php index 14d20cd..ae98b38 100644 --- a/src/Module/Core/Domain/Entity/Role.php +++ b/src/Module/Core/Domain/Entity/Role.php @@ -168,6 +168,10 @@ class Role * permettre au RoleProcessor de detecter une tentative de modification * du code (garde "code immuable"). Le code reste en pratique fige apres * creation : le processor refuse toute modification via 400. + * + * @internal Ne PAS appeler depuis le domaine, les fixtures ou les commandes. + * Hors contexte API Platform, cette methode modifie silencieusement + * le code sans aucun garde. */ public function setCode(string $code): static { diff --git a/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessorTest.php b/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessorTest.php index dc890b9..c90fe5a 100644 --- a/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessorTest.php +++ b/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessorTest.php @@ -77,6 +77,7 @@ final class RoleProcessorTest extends TestCase $this->persistProcessor->expects(self::never())->method('process'); $this->expectException(AccessDeniedHttpException::class); + $this->expectExceptionMessage('Le role systeme "admin" ne peut pas etre supprime.'); $this->processor->process($role, new Delete()); } -- 2.39.5 From 168a47f2b8adfa6f8f681739b3a0fccebffa81eb Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 12:14:20 +0200 Subject: [PATCH 21/55] refactor(test) : RBAC #344 - AbstractApiTestCase pour mutualiser auth JWT Extrait l'helper authenticatedClient(), $alwaysBootKernel et getEm() dans une classe de base commune aux tests fonctionnels API Platform du module Core. Supprime la duplication entre PermissionApiTest et RoleApiTest (flaggee en code review de la Task 2). Prepare le terrain pour le nouveau UserRbacApiTest introduit avec la Task 4. --- tests/Module/Core/Api/AbstractApiTestCase.php | 66 +++++++++++++++++++ tests/Module/Core/Api/PermissionApiTest.php | 45 +------------ tests/Module/Core/Api/RoleApiTest.php | 44 +------------ 3 files changed, 68 insertions(+), 87 deletions(-) create mode 100644 tests/Module/Core/Api/AbstractApiTestCase.php diff --git a/tests/Module/Core/Api/AbstractApiTestCase.php b/tests/Module/Core/Api/AbstractApiTestCase.php new file mode 100644 index 0000000..3a15d6b --- /dev/null +++ b/tests/Module/Core/Api/AbstractApiTestCase.php @@ -0,0 +1,66 @@ +get('doctrine')->getManager(); + } + + /** + * Cree un client authentifie via /login_check. La configuration du projet + * pose le JWT dans un cookie HTTP-only `BEARER` (cf. lexik_jwt_authentication.yaml) + * et retire le token du body de reponse ; le client BrowserKit persiste + * automatiquement le cookie pour les requetes suivantes. + */ + protected function authenticatedClient(string $username, string $password): Client + { + $client = self::createClient(); + $response = $client->request('POST', '/login_check', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['username' => $username, 'password' => $password], + ]); + + self::assertContains( + $response->getStatusCode(), + [200, 204], + 'Login failed for '.$username.': '.$response->getStatusCode(), + ); + + return $client; + } +} diff --git a/tests/Module/Core/Api/PermissionApiTest.php b/tests/Module/Core/Api/PermissionApiTest.php index 9658bf7..f097b95 100644 --- a/tests/Module/Core/Api/PermissionApiTest.php +++ b/tests/Module/Core/Api/PermissionApiTest.php @@ -4,10 +4,7 @@ declare(strict_types=1); namespace App\Tests\Module\Core\Api; -use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; -use ApiPlatform\Symfony\Bundle\Test\Client; use App\Module\Core\Domain\Entity\Permission; -use Doctrine\ORM\EntityManagerInterface; /** * Tests fonctionnels de l'exposition API Platform de l'entite Permission. @@ -20,12 +17,9 @@ use Doctrine\ORM\EntityManagerInterface; * * @internal */ -final class PermissionApiTest extends ApiTestCase +final class PermissionApiTest extends AbstractApiTestCase { private const TEST_CODE_PREFIX = 'test.'; - // Bascule explicite sur le nouveau comportement API Platform 5 pour - // eviter la deprecation emise a la creation du client de test. - protected static ?bool $alwaysBootKernel = true; protected function setUp(): void { @@ -178,47 +172,10 @@ final class PermissionApiTest extends ApiTestCase self::assertResponseStatusCodeSame(403); } - /** - * Recupere l'EntityManager depuis le container courant. A utiliser a - * chaque appel : apres un createClient(), le kernel est reboote et tout - * EM precedemment capture est invalide. - */ - private function getEm(): EntityManagerInterface - { - if (!self::$kernel) { - self::bootKernel(); - } - - return self::getContainer()->get('doctrine')->getManager(); - } - private function cleanupTestPermissions(): void { $this->getEm()->createQuery( 'DELETE FROM '.Permission::class.' p WHERE p.code LIKE :prefix' )->setParameter('prefix', self::TEST_CODE_PREFIX.'%')->execute(); } - - /** - * Cree un client authentifie via /login_check. La configuration du projet - * pose le JWT dans un cookie HTTP-only `BEARER` (cf. lexik_jwt_authentication.yaml) - * et retire le token du body de reponse ; le client BrowserKit persiste - * automatiquement le cookie pour les requetes suivantes. - */ - private function authenticatedClient(string $username, string $password): Client - { - $client = self::createClient(); - $response = $client->request('POST', '/login_check', [ - 'headers' => ['Content-Type' => 'application/json'], - 'json' => ['username' => $username, 'password' => $password], - ]); - - self::assertContains( - $response->getStatusCode(), - [200, 204], - 'Login failed for '.$username.': '.$response->getStatusCode(), - ); - - return $client; - } } diff --git a/tests/Module/Core/Api/RoleApiTest.php b/tests/Module/Core/Api/RoleApiTest.php index e204652..0fde030 100644 --- a/tests/Module/Core/Api/RoleApiTest.php +++ b/tests/Module/Core/Api/RoleApiTest.php @@ -4,12 +4,9 @@ declare(strict_types=1); namespace App\Tests\Module\Core\Api; -use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; -use ApiPlatform\Symfony\Bundle\Test\Client; use App\Module\Core\Domain\Entity\Permission; use App\Module\Core\Domain\Entity\Role; use App\Module\Core\Domain\Security\SystemRoles; -use Doctrine\ORM\EntityManagerInterface; /** * Tests fonctionnels de l'exposition API Platform de l'entite Role (CRUD nominal). @@ -24,7 +21,7 @@ use Doctrine\ORM\EntityManagerInterface; * * @internal */ -final class RoleApiTest extends ApiTestCase +final class RoleApiTest extends AbstractApiTestCase { // Prefixe pour les roles de test : `test_` (underscore) parce que les // codes de role doivent matcher `/^[a-z][a-z0-9_]*$/` (pas de point @@ -36,10 +33,6 @@ final class RoleApiTest extends ApiTestCase // module.resource.action validee dans le constructeur Permission). private const TEST_PERMISSION_PREFIX = 'test.'; - // Bascule explicite sur le nouveau comportement API Platform 5 pour - // eviter la deprecation emise a la creation du client de test. - protected static ?bool $alwaysBootKernel = true; - protected function setUp(): void { parent::setUp(); @@ -332,20 +325,6 @@ final class RoleApiTest extends ApiTestCase self::assertResponseStatusCodeSame(403); } - /** - * Recupere l'EntityManager depuis le container courant. A utiliser a - * chaque appel : apres un createClient(), le kernel est reboote et tout - * EM precedemment capture est invalide. - */ - private function getEm(): EntityManagerInterface - { - if (!self::$kernel) { - self::bootKernel(); - } - - return self::getContainer()->get('doctrine')->getManager(); - } - /** * Purge les donnees de test (roles et permissions prefixees `test.`). * Ne touche JAMAIS aux roles systeme `admin` et `user` charges par les @@ -369,25 +348,4 @@ final class RoleApiTest extends ApiTestCase 'DELETE FROM '.Permission::class.' p WHERE p.code LIKE :prefix' )->setParameter('prefix', self::TEST_PERMISSION_PREFIX.'%')->execute(); } - - /** - * Cree un client authentifie via /login_check (cookie BEARER pose par - * lexik_jwt_authentication et persiste automatiquement par BrowserKit). - */ - private function authenticatedClient(string $username, string $password): Client - { - $client = self::createClient(); - $response = $client->request('POST', '/login_check', [ - 'headers' => ['Content-Type' => 'application/json'], - 'json' => ['username' => $username, 'password' => $password], - ]); - - self::assertContains( - $response->getStatusCode(), - [200, 204], - 'Login failed for '.$username.': '.$response->getStatusCode(), - ); - - return $client; - } } -- 2.39.5 From 3c7dc88fe7297f7e81a5fa8a4bd47f6dc94dc27d Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 14:17:18 +0200 Subject: [PATCH 22/55] feat(core) : RBAC #344 - UserRbacProcessor + endpoint /users/{id}/rbac Ajoute une operation Patch dediee `PATCH /api/users/{id}/rbac` (nom `user_rbac_patch`) qui accepte exclusivement les champs RBAC isAdmin, roles et directPermissions via le groupe user:rbac:write. L'endpoint est separe volontairement du Patch profil existant pour isoler la modification des droits de celle des donnees profil (decision 0fc4e16). UserRbacProcessor delegue au PersistProcessor Doctrine decore et applique une garde auto-suicide : un admin ne peut pas retirer ses propres droits administrateur (compare l'etat entrant a l'etat UnitOfWork). La garde 'dernier admin' globale est reportee au ticket #345. La propriete Doctrine $roles est renommee $rbacRoles pour eviter la collision avec UserInterface::getRoles() (qui renvoie list) lors de la normalization API Platform. La cle JSON reste `roles` grace a SerializedName, le contrat API est inchange. Tests : 6 unitaires (UserRbacProcessorTest) + 8 fonctionnels (UserRbacApiTest) couvrant promotion admin, remplacement des collections roles/directPermissions, 401/403, filtrage du groupe denormalization (`username` ignore), preservation de isAdmin sur le Patch profil, et garde auto-suicide. --- src/Module/Core/Domain/Entity/User.php | 46 ++- .../State/Processor/UserRbacProcessor.php | 71 +++++ tests/Module/Core/Api/UserRbacApiTest.php | 271 ++++++++++++++++++ .../State/Processor/UserRbacProcessorTest.php | 216 ++++++++++++++ 4 files changed, 589 insertions(+), 15 deletions(-) create mode 100644 src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php create mode 100644 tests/Module/Core/Api/UserRbacApiTest.php create mode 100644 tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessorTest.php diff --git a/src/Module/Core/Domain/Entity/User.php b/src/Module/Core/Domain/Entity/User.php index b571488..a62dbd5 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserPasswordHasherProcessor; +use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor; use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider; use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository; use DateTimeImmutable; @@ -20,6 +21,7 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Serializer\Attribute\SerializedName; #[ApiResource( operations: [ @@ -36,6 +38,15 @@ use Symfony\Component\Serializer\Attribute\Groups; ), new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class), new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class), + new Patch( + name: 'user_rbac_patch', + uriTemplate: '/users/{id}/rbac', + // TODO ticket #345 : remplacer par is_granted('core.users.manage') + security: "is_granted('ROLE_ADMIN')", + normalizationContext: ['groups' => ['user:list']], + denormalizationContext: ['groups' => ['user:rbac:write']], + processor: UserRbacProcessor::class, + ), new Delete(security: "is_granted('ROLE_ADMIN')"), ], denormalizationContext: ['groups' => ['user:write']], @@ -55,7 +66,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface private ?string $username = null; #[ORM\Column(name: 'is_admin', options: ['default' => false])] - #[Groups(['me:read', 'user:list'])] + #[Groups(['me:read', 'user:list', 'user:rbac:write'])] private bool $isAdmin = false; /** @@ -70,20 +81,25 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface */ #[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')] #[ORM\JoinTable(name: 'user_role')] - #[Groups(['me:read', 'user:list'])] - private Collection $roles; + #[Groups(['me:read', 'user:list', 'user:rbac:write'])] + // La propriete s'appelle `rbacRoles` cote PHP pour ne pas entrer en + // collision avec UserInterface::getRoles() (qui renvoie list) ; + // on reexpose la cle JSON sous `roles` via SerializedName pour rester + // conforme au contrat API documente dans le ticket #344. + #[SerializedName('roles')] + private Collection $rbacRoles; /** * Les permissions directes accordees hors des roles. * - * Meme justification EAGER que pour $roles : garantie que + * Meme justification EAGER que pour $rbacRoles : garantie que * getEffectivePermissions() fonctionne dans tous les contextes de chargement. * * @var Collection */ #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')] #[ORM\JoinTable(name: 'user_permission')] - #[Groups(['me:read', 'user:list'])] + #[Groups(['me:read', 'user:list', 'user:rbac:write'])] private Collection $directPermissions; #[ORM\Column] @@ -98,7 +114,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface public function __construct() { $this->createdAt = new DateTimeImmutable(); - $this->roles = new ArrayCollection(); + $this->rbacRoles = new ArrayCollection(); $this->directPermissions = new ArrayCollection(); } @@ -131,10 +147,10 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface * ROLE_ADMIN est ajoute si l'utilisateur porte le flag is_admin — c'est le * SEUL levier technique de bypass RBAC (cf. section 11 du spec). * - * Important : ne JAMAIS iterer $this->roles (la Collection de Role) ici. - * Cette methode peut etre appelee pendant un refresh JWT, moment ou la - * Collection peut ne pas etre hydratee. On se contente d'un calcul base - * sur un scalaire. + * Important : ne JAMAIS iterer $this->rbacRoles (la Collection de Role) + * ici. Cette methode peut etre appelee pendant un refresh JWT, moment ou + * la Collection peut ne pas etre hydratee. On se contente d'un calcul + * base sur un scalaire. * * @return list */ @@ -170,13 +186,13 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface */ public function getRbacRoles(): Collection { - return $this->roles; + return $this->rbacRoles; } public function addRbacRole(Role $role): static { - if (!$this->roles->contains($role)) { - $this->roles->add($role); + if (!$this->rbacRoles->contains($role)) { + $this->rbacRoles->add($role); } return $this; @@ -184,7 +200,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface public function removeRbacRole(Role $role): static { - $this->roles->removeElement($role); + $this->rbacRoles->removeElement($role); return $this; } @@ -229,7 +245,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface { $codes = []; - foreach ($this->roles as $role) { + foreach ($this->rbacRoles as $role) { foreach ($role->getPermissions() as $permission) { $codes[$permission->getCode()] = true; } diff --git a/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php b/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php new file mode 100644 index 0000000..d97f846 --- /dev/null +++ b/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php @@ -0,0 +1,71 @@ + + */ +final class UserRbacProcessor implements ProcessorInterface +{ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + private readonly EntityManagerInterface $entityManager, + private readonly Security $security, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof User) { + // Securite : si le provider n'a pas fourni un User, on delegue + // quand meme pour ne pas etouffer un bug de configuration. + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + $currentUser = $this->security->getUser(); + + // Garde auto-suicide : l'user courant ne peut pas retirer son propre + // flag admin. On ne compare que si la cible == l'user courant. + if ($currentUser instanceof User + && null !== $currentUser->getId() + && $currentUser->getId() === $data->getId() + ) { + $originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data); + $wasAdmin = $originalData['isAdmin'] ?? null; + + if (true === $wasAdmin && false === $data->isAdmin()) { + throw new BadRequestHttpException( + 'Vous ne pouvez pas retirer vos propres droits administrateur.' + ); + } + } + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } +} diff --git a/tests/Module/Core/Api/UserRbacApiTest.php b/tests/Module/Core/Api/UserRbacApiTest.php new file mode 100644 index 0000000..9984e54 --- /dev/null +++ b/tests/Module/Core/Api/UserRbacApiTest.php @@ -0,0 +1,271 @@ +getEm(); + + $this->cleanupTestData(); + + /** @var UserPasswordHasherInterface $hasher */ + $hasher = self::getContainer()->get(UserPasswordHasherInterface::class); + + // User cible standard (non admin). + $target = new User(); + $target->setUsername('test_target'); + $target->setIsAdmin(false); + $target->setPassword($hasher->hashPassword($target, 'secret')); + $em->persist($target); + + // User admin dedie pour le cas d'auto-suicide (pas l'admin fixture). + $selfAdmin = new User(); + $selfAdmin->setUsername('test_self_admin'); + $selfAdmin->setIsAdmin(true); + $selfAdmin->setPassword($hasher->hashPassword($selfAdmin, 'secret')); + $em->persist($selfAdmin); + + // Role custom pour tester le remplacement de la collection roles. + $role = new Role('test_editor', 'Editeur (test)', false); + $em->persist($role); + + // Permission custom pour tester directPermissions. + $permission = new Permission('test.core.users.view', 'View users (test)', 'core'); + $em->persist($permission); + + $em->flush(); + $em->clear(); + } + + protected function tearDown(): void + { + $this->cleanupTestData(); + parent::tearDown(); + } + + public function testPatchRbacPromotesUserToAdmin(): void + { + $target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']); + self::assertNotNull($target); + + $client = $this->authenticatedClient('admin', 'admin'); + $client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['isAdmin' => true], + ]); + + self::assertResponseIsSuccessful(); + + $em = $this->getEm(); + $em->clear(); + + /** @var User $reloaded */ + $reloaded = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']); + self::assertTrue($reloaded->isAdmin()); + } + + public function testPatchRbacReplacesRolesCollection(): void + { + $em = $this->getEm(); + $target = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']); + $role = $em->getRepository(Role::class)->findOneBy(['code' => 'test_editor']); + self::assertNotNull($target); + self::assertNotNull($role); + + $client = $this->authenticatedClient('admin', 'admin'); + $client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['roles' => ['/api/roles/'.$role->getId()]], + ]); + + self::assertResponseIsSuccessful(); + + $em = $this->getEm(); + $em->clear(); + + /** @var User $reloaded */ + $reloaded = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']); + self::assertCount(1, $reloaded->getRbacRoles()); + self::assertSame('test_editor', $reloaded->getRbacRoles()->first()->getCode()); + } + + public function testPatchRbacReplacesDirectPermissionsCollection(): void + { + $em = $this->getEm(); + $target = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']); + $permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'test.core.users.view']); + self::assertNotNull($target); + self::assertNotNull($permission); + + $client = $this->authenticatedClient('admin', 'admin'); + $client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['directPermissions' => ['/api/permissions/'.$permission->getId()]], + ]); + + self::assertResponseIsSuccessful(); + + $em = $this->getEm(); + $em->clear(); + + /** @var User $reloaded */ + $reloaded = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']); + self::assertCount(1, $reloaded->getDirectPermissions()); + self::assertSame('test.core.users.view', $reloaded->getDirectPermissions()->first()->getCode()); + } + + public function testPatchRbacAsStandardUserReturns403(): void + { + $target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']); + self::assertNotNull($target); + + $client = $this->authenticatedClient('alice', 'alice'); + $client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['isAdmin' => true], + ]); + + self::assertResponseStatusCodeSame(403); + } + + public function testPatchRbacUnauthenticatedReturns401(): void + { + $target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']); + self::assertNotNull($target); + + $client = self::createClient(); + $client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['isAdmin' => true], + ]); + + self::assertResponseStatusCodeSame(401); + } + + public function testPatchRbacIgnoresUsernameField(): void + { + $target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']); + self::assertNotNull($target); + $targetId = $target->getId(); + + $client = $this->authenticatedClient('admin', 'admin'); + $client->request('PATCH', '/api/users/'.$targetId.'/rbac', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => [ + 'username' => 'test_target_renamed', + 'isAdmin' => true, + ], + ]); + + self::assertResponseIsSuccessful(); + + $em = $this->getEm(); + $em->clear(); + + /** @var User $reloaded */ + $reloaded = $em->getRepository(User::class)->find($targetId); + // `username` n'est pas dans `user:rbac:write` : ignore en denormalization. + self::assertSame('test_target', $reloaded->getUsername()); + // `isAdmin` est bien applique. + self::assertTrue($reloaded->isAdmin()); + } + + public function testPatchProfileEndpointDoesNotModifyIsAdmin(): void + { + // Confirme la decision 0fc4e16 : `isAdmin` n'est plus dans `user:write`, + // donc `PATCH /api/users/{id}` sans `/rbac` ne peut plus promouvoir. + $target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']); + self::assertNotNull($target); + $targetId = $target->getId(); + self::assertFalse($target->isAdmin()); + + $client = $this->authenticatedClient('admin', 'admin'); + $client->request('PATCH', '/api/users/'.$targetId, [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['isAdmin' => true], + ]); + + // Peu importe le code : le champ ne doit tout simplement pas bouger. + $em = $this->getEm(); + $em->clear(); + + /** @var User $reloaded */ + $reloaded = $em->getRepository(User::class)->find($targetId); + self::assertFalse($reloaded->isAdmin()); + } + + public function testPatchRbacSelfRemovingAdminReturns400(): void + { + // On utilise le user admin dedie (test_self_admin) pour ne pas + // corrompre l'admin fixture en cas de bug. + $em = $this->getEm(); + $selfAdmin = $em->getRepository(User::class)->findOneBy(['username' => 'test_self_admin']); + self::assertNotNull($selfAdmin); + $selfAdminId = $selfAdmin->getId(); + + $client = $this->authenticatedClient('test_self_admin', 'secret'); + $client->request('PATCH', '/api/users/'.$selfAdminId.'/rbac', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['isAdmin' => false], + ]); + + self::assertResponseStatusCodeSame(400); + + $em = $this->getEm(); + $em->clear(); + + /** @var User $reloaded */ + $reloaded = $em->getRepository(User::class)->find($selfAdminId); + self::assertTrue($reloaded->isAdmin()); + } + + private function cleanupTestData(): void + { + $em = $this->getEm(); + + // Ordre important : delier les collections avant de supprimer les + // entites referencees pour que les FK cascade s'appliquent via le + // schema PostgreSQL. + $em->createQuery( + 'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix' + )->setParameter('prefix', self::TEST_USER_PREFIX.'%')->execute(); + + $em->createQuery( + 'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix' + )->setParameter('prefix', self::TEST_ROLE_PREFIX.'%')->execute(); + + $em->createQuery( + 'DELETE FROM '.Permission::class.' p WHERE p.code LIKE :prefix' + )->setParameter('prefix', self::TEST_PERMISSION_PREFIX.'%')->execute(); + } +} diff --git a/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessorTest.php b/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessorTest.php new file mode 100644 index 0000000..6dce00b --- /dev/null +++ b/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessorTest.php @@ -0,0 +1,216 @@ +persistProcessor = $this->createMock(ProcessorInterface::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->unitOfWork = $this->createMock(UnitOfWork::class); + $this->security = $this->createMock(Security::class); + + $this->entityManager->method('getUnitOfWork')->willReturn($this->unitOfWork); + + $this->processor = new UserRbacProcessor( + $this->persistProcessor, + $this->entityManager, + $this->security, + ); + } + + public function testPatchPromotesUserToAdminDelegatesToPersistProcessor(): void + { + $target = $this->buildUser(42, 'alice', false); + $target->setIsAdmin(true); + + $currentAdmin = $this->buildUser(1, 'admin', true); + $this->security->method('getUser')->willReturn($currentAdmin); + + // Cible != user courant : pas de lecture d'UnitOfWork necessaire. + $this->unitOfWork->expects(self::never())->method('getOriginalEntityData'); + + $this->persistProcessor + ->expects(self::once()) + ->method('process') + ->with($target) + ->willReturn($target) + ; + + $result = $this->processor->process($target, new Patch()); + + self::assertSame($target, $result); + } + + public function testPatchUpdatesRolesCollectionDelegatesToPersistProcessor(): void + { + $target = $this->buildUser(42, 'alice', false); + $target->addRbacRole(new Role('editor', 'Editor', false)); + + $currentAdmin = $this->buildUser(1, 'admin', true); + $this->security->method('getUser')->willReturn($currentAdmin); + + $this->persistProcessor + ->expects(self::once()) + ->method('process') + ->with($target) + ->willReturn($target) + ; + + $result = $this->processor->process($target, new Patch()); + + self::assertSame($target, $result); + self::assertCount(1, $result->getRbacRoles()); + } + + public function testPatchUpdatesDirectPermissionsCollectionDelegatesToPersistProcessor(): void + { + $target = $this->buildUser(42, 'alice', false); + $target->addDirectPermission(new Permission('core.users.view', 'View', 'core')); + + $currentAdmin = $this->buildUser(1, 'admin', true); + $this->security->method('getUser')->willReturn($currentAdmin); + + $this->persistProcessor + ->expects(self::once()) + ->method('process') + ->with($target) + ->willReturn($target) + ; + + $result = $this->processor->process($target, new Patch()); + + self::assertSame($target, $result); + self::assertCount(1, $result->getDirectPermissions()); + } + + public function testPatchSelfRemovingAdminThrowsBadRequestHttpException(): void + { + // Meme identifiant : l'user courant PATCH sa propre ressource. + $self = $this->buildUser(1, 'admin', false); + + $this->security->method('getUser')->willReturn($self); + + $this->unitOfWork + ->expects(self::once()) + ->method('getOriginalEntityData') + ->with($self) + ->willReturn([ + 'id' => 1, + 'username' => 'admin', + 'isAdmin' => true, + ]) + ; + + $this->persistProcessor->expects(self::never())->method('process'); + + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('Vous ne pouvez pas retirer vos propres droits administrateur.'); + + $this->processor->process($self, new Patch()); + } + + public function testPatchAdminDemotingAnotherUserIsAllowed(): void + { + // Un admin qui retire isAdmin a quelqu'un d'autre : autorise. + $target = $this->buildUser(42, 'alice', false); + $current = $this->buildUser(1, 'admin', true); + + $this->security->method('getUser')->willReturn($current); + + // Cible != user courant : pas de verification d'auto-suicide. + $this->unitOfWork->expects(self::never())->method('getOriginalEntityData'); + + $this->persistProcessor + ->expects(self::once()) + ->method('process') + ->with($target) + ->willReturn($target) + ; + + $result = $this->processor->process($target, new Patch()); + + self::assertSame($target, $result); + } + + public function testPatchSelfKeepingAdminIsAllowed(): void + { + // L'user courant se PATCH lui-meme mais garde isAdmin = true : + // aucun auto-suicide, on delegue au PersistProcessor. + $self = $this->buildUser(1, 'admin', true); + + $this->security->method('getUser')->willReturn($self); + + $this->unitOfWork + ->expects(self::once()) + ->method('getOriginalEntityData') + ->with($self) + ->willReturn([ + 'id' => 1, + 'username' => 'admin', + 'isAdmin' => true, + ]) + ; + + $this->persistProcessor + ->expects(self::once()) + ->method('process') + ->with($self) + ->willReturn($self) + ; + + $result = $this->processor->process($self, new Patch()); + + self::assertSame($self, $result); + } + + /** + * Construit un User avec un id force via reflection (les mocks + * d'UnitOfWork n'alimentent pas l'id tout seul). + */ + private function buildUser(int $id, string $username, bool $isAdmin): User + { + $user = new User(); + $user->setUsername($username); + $user->setIsAdmin($isAdmin); + + $refl = new ReflectionClass($user); + $prop = $refl->getProperty('id'); + $prop->setAccessible(true); + $prop->setValue($user, $id); + + return $user; + } +} -- 2.39.5 From 534bdbccdd92c8096a191f305f268e2cc2a11ac5 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 14:28:02 +0200 Subject: [PATCH 23/55] refactor(core) : RBAC #344 - polish review - narrow rbac read group + fail-fast processors --- src/Module/Core/Domain/Entity/User.php | 12 +++++++----- .../ApiPlatform/State/Processor/RoleProcessor.php | 15 +++++++++------ .../State/Processor/UserRbacProcessor.php | 12 +++++++++--- .../State/Processor/RoleProcessorTest.php | 15 +++++++++++++++ .../State/Processor/UserRbacProcessorTest.php | 14 ++++++++++++++ 5 files changed, 54 insertions(+), 14 deletions(-) diff --git a/src/Module/Core/Domain/Entity/User.php b/src/Module/Core/Domain/Entity/User.php index a62dbd5..0426121 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -43,7 +43,7 @@ use Symfony\Component\Serializer\Attribute\SerializedName; uriTemplate: '/users/{id}/rbac', // TODO ticket #345 : remplacer par is_granted('core.users.manage') security: "is_granted('ROLE_ADMIN')", - normalizationContext: ['groups' => ['user:list']], + normalizationContext: ['groups' => ['user:rbac:read']], denormalizationContext: ['groups' => ['user:rbac:write']], processor: UserRbacProcessor::class, ), @@ -58,7 +58,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['me:read', 'user:list'])] + #[Groups(['me:read', 'user:list', 'user:rbac:read'])] private ?int $id = null; #[ORM\Column(length: 180, unique: true)] @@ -66,7 +66,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface private ?string $username = null; #[ORM\Column(name: 'is_admin', options: ['default' => false])] - #[Groups(['me:read', 'user:list', 'user:rbac:write'])] + #[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])] private bool $isAdmin = false; /** @@ -81,7 +81,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface */ #[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')] #[ORM\JoinTable(name: 'user_role')] - #[Groups(['me:read', 'user:list', 'user:rbac:write'])] + #[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])] // La propriete s'appelle `rbacRoles` cote PHP pour ne pas entrer en // collision avec UserInterface::getRoles() (qui renvoie list) ; // on reexpose la cle JSON sous `roles` via SerializedName pour rester @@ -99,7 +99,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface */ #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')] #[ORM\JoinTable(name: 'user_permission')] - #[Groups(['me:read', 'user:list', 'user:rbac:write'])] + #[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])] private Collection $directPermissions; #[ORM\Column] @@ -152,6 +152,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface * la Collection peut ne pas etre hydratee. On se contente d'un calcul * base sur un scalaire. * + * @see getRbacRoles() pour la collection RBAC metier (exposee en JSON sous la cle "roles"). + * * @return list */ public function getRoles(): array diff --git a/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php b/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php index 70a8ba3..cb1be0d 100644 --- a/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php +++ b/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php @@ -10,6 +10,7 @@ use ApiPlatform\State\ProcessorInterface; use App\Module\Core\Domain\Entity\Role; use App\Module\Core\Domain\Exception\SystemRoleDeletionException; use Doctrine\ORM\EntityManagerInterface; +use LogicException; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -44,12 +45,14 @@ final class RoleProcessor implements ProcessorInterface public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed { if (!$data instanceof Role) { - // Securite : si le provider n'a pas fourni un Role, on delegue - // quand meme au processor approprie pour ne pas etouffer - // silencieusement un bug de configuration. - return $operation instanceof DeleteOperationInterface - ? $this->removeProcessor->process($data, $operation, $uriVariables, $context) - : $this->persistProcessor->process($data, $operation, $uriVariables, $context); + // Ce processor est wire exclusivement sur les operations Role. + // Si on arrive ici avec autre chose, c'est une misconfiguration + // qu'il faut faire remonter fort. + throw new LogicException(sprintf( + 'RoleProcessor attend une instance de %s, %s recu.', + Role::class, + get_debug_type($data), + )); } if ($operation instanceof DeleteOperationInterface) { diff --git a/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php b/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php index d97f846..4215d74 100644 --- a/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php +++ b/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php @@ -8,6 +8,7 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\Module\Core\Domain\Entity\User; use Doctrine\ORM\EntityManagerInterface; +use LogicException; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -43,9 +44,14 @@ final class UserRbacProcessor implements ProcessorInterface public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed { if (!$data instanceof User) { - // Securite : si le provider n'a pas fourni un User, on delegue - // quand meme pour ne pas etouffer un bug de configuration. - return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + // Ce processor est wire exclusivement sur l'operation user_rbac_patch + // qui cible User. Si on arrive ici avec autre chose, c'est une + // misconfiguration qu'il faut faire remonter fort. + throw new LogicException(sprintf( + 'UserRbacProcessor attend une instance de %s, %s recu.', + User::class, + get_debug_type($data), + )); } $currentUser = $this->security->getUser(); diff --git a/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessorTest.php b/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessorTest.php index c90fe5a..d124e91 100644 --- a/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessorTest.php +++ b/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessorTest.php @@ -12,10 +12,12 @@ use App\Module\Core\Domain\Entity\Role; use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\RoleProcessor; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\UnitOfWork; +use LogicException; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use ReflectionClass; +use stdClass; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -199,6 +201,19 @@ final class RoleProcessorTest extends TestCase self::assertSame($role, $result); } + public function testProcessNonRoleDataThrowsLogicException(): void + { + // Garde-fou contre une misconfiguration : ce processor est wire + // exclusivement sur les operations Role. + $this->persistProcessor->expects(self::never())->method('process'); + $this->removeProcessor->expects(self::never())->method('process'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('RoleProcessor attend une instance de'); + + $this->processor->process(new stdClass(), new Patch()); + } + /** * Positionne l'id d'un Role via reflection pour simuler une entite deja * persistee (les mocks d'UnitOfWork n'alimentent pas l'id tout seul). diff --git a/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessorTest.php b/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessorTest.php index 6dce00b..e30dbcb 100644 --- a/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessorTest.php +++ b/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessorTest.php @@ -12,10 +12,12 @@ use App\Module\Core\Domain\Entity\User; use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\UnitOfWork; +use LogicException; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use ReflectionClass; +use stdClass; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -196,6 +198,18 @@ final class UserRbacProcessorTest extends TestCase self::assertSame($self, $result); } + public function testProcessNonUserDataThrowsLogicException(): void + { + // Garde-fou contre une misconfiguration : ce processor est wire + // exclusivement sur l'operation user_rbac_patch (cible User). + $this->persistProcessor->expects(self::never())->method('process'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('UserRbacProcessor attend une instance de'); + + $this->processor->process(new stdClass(), new Patch()); + } + /** * Construit un User avec un id force via reflection (les mocks * d'UnitOfWork n'alimentent pas l'id tout seul). -- 2.39.5 From 0ccbc70f27d39757034fa17a84a6458034d719b7 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 14:53:49 +0200 Subject: [PATCH 24/55] fix(core) : RBAC #344 - ferme leak user list + test cascade delete role --- src/Module/Core/Domain/Entity/User.php | 2 + tests/Module/Core/Api/RoleApiTest.php | 51 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/Module/Core/Domain/Entity/User.php b/src/Module/Core/Domain/Entity/User.php index 0426121..96f52a0 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -31,9 +31,11 @@ use Symfony\Component\Serializer\Attribute\SerializedName; normalizationContext: ['groups' => ['me:read']], ), new Get( + security: "is_granted('ROLE_ADMIN')", // TODO ticket #345 : remplacer par is_granted('core.users.view') normalizationContext: ['groups' => ['user:list']], ), new GetCollection( + security: "is_granted('ROLE_ADMIN')", // TODO ticket #345 : remplacer par is_granted('core.users.view') normalizationContext: ['groups' => ['user:list']], ), new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class), diff --git a/tests/Module/Core/Api/RoleApiTest.php b/tests/Module/Core/Api/RoleApiTest.php index 0fde030..205ad94 100644 --- a/tests/Module/Core/Api/RoleApiTest.php +++ b/tests/Module/Core/Api/RoleApiTest.php @@ -6,6 +6,7 @@ namespace App\Tests\Module\Core\Api; use App\Module\Core\Domain\Entity\Permission; use App\Module\Core\Domain\Entity\Role; +use App\Module\Core\Domain\Entity\User; use App\Module\Core\Domain\Security\SystemRoles; /** @@ -238,6 +239,48 @@ final class RoleApiTest extends AbstractApiTestCase self::assertNull($em->getRepository(Role::class)->find($id)); } + public function testDeleteCustomRoleAttachedToUserDoesNotDeleteUser(): void + { + // Scenario spec #344 sections 7 & 11 : supprimer un role custom rattache + // a un user doit laisser le user en base (la FK user_role est nettoyee + // par ON DELETE CASCADE, mais jamais le user lui-meme). + $em = $this->getEm(); + + // Creer un user de test dedie et lui rattacher le role custom `test_editor`. + $testUser = new User(); + $testUser->setUsername('test_cascade_user'); + // Le hashage du password est hors scope du test mais la colonne est NOT NULL. + $testUser->setPassword('not-hashed-ok-for-test'); + + /** @var Role $editor */ + $editor = $em->getRepository(Role::class)->findOneBy(['code' => 'test_editor']); + self::assertNotNull($editor); + $testUser->addRbacRole($editor); + + $em->persist($testUser); + $em->flush(); + $userId = $testUser->getId(); + $editorId = $editor->getId(); + $em->clear(); + + // DELETE du role editor via l'API. + $client = $this->authenticatedClient('admin', 'admin'); + $client->request('DELETE', '/api/roles/'.$editorId); + self::assertResponseStatusCodeSame(204); + + // Verification : l'user existe toujours et sa collection de roles est vide. + $em = $this->getEm(); + + /** @var null|User $refreshed */ + $refreshed = $em->getRepository(User::class)->find($userId); + self::assertNotNull($refreshed, 'L\'user ne doit PAS etre supprime par le cascade.'); + self::assertCount(0, $refreshed->getRbacRoles(), 'La relation user_role doit etre nettoyee par le cascade.'); + + // Cleanup explicite : cleanupTestData() ne purge pas les users. + $em->remove($refreshed); + $em->flush(); + } + public function testDeleteSystemRoleReturns403(): void { $role = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => SystemRoles::ADMIN_CODE]); @@ -340,6 +383,14 @@ final class RoleApiTest extends AbstractApiTestCase // bulk delete : le cascade est applique au niveau FK par PostgreSQL, // pas par l'Unit of Work Doctrine. Verifie par comptage avant/apres // runs successifs de la suite (stable a la ligne de base systeme). + // Purge defensive des users de test crees par certains scenarios + // (ex: testDeleteCustomRoleAttachedToUserDoesNotDeleteUser). Doit etre + // fait AVANT la suppression des roles pour que le cascade FK ne soit + // pas sollicite en ordre inverse. + $em->createQuery( + 'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix' + )->setParameter('prefix', self::TEST_ROLE_PREFIX.'%')->execute(); + $em->createQuery( 'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix' )->setParameter('prefix', self::TEST_ROLE_PREFIX.'%')->execute(); -- 2.39.5 From fd4ed25c63feda949d13f9835c59754b46451bf2 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 15:28:51 +0200 Subject: [PATCH 25/55] docs(core) : RBAC #345 - spec voter + usePermissions --- docs/rbac/ticket-345-spec.md | 574 +++++++++++++++++++++++++++++++++++ 1 file changed, 574 insertions(+) create mode 100644 docs/rbac/ticket-345-spec.md diff --git a/docs/rbac/ticket-345-spec.md b/docs/rbac/ticket-345-spec.md new file mode 100644 index 0000000..1816d10 --- /dev/null +++ b/docs/rbac/ticket-345-spec.md @@ -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` 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`. -- 2.39.5 From b7aa445cefc8e4cce1587c842706c051feed1f65 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 15:42:42 +0200 Subject: [PATCH 26/55] feat(core) : RBAC #345 - add core.roles.view permission --- src/Module/Core/CoreModule.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Module/Core/CoreModule.php b/src/Module/Core/CoreModule.php index d6d22a9..e8a4e30 100644 --- a/src/Module/Core/CoreModule.php +++ b/src/Module/Core/CoreModule.php @@ -32,8 +32,9 @@ final class CoreModule 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 la liste des permissions'], + ['code' => 'core.permissions.view', 'label' => 'Voir le catalogue des permissions'], ]; } } -- 2.39.5 From 4325b1d8a043779f68a4f2263b0b6f122fcd9cc7 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 15:45:55 +0200 Subject: [PATCH 27/55] feat(core) : RBAC #345 - AdminHeadcountGuard domain service --- .../LastAdminProtectionException.php | 26 ++++ .../Repository/UserRepositoryInterface.php | 8 ++ .../Domain/Security/AdminHeadcountGuard.php | 64 +++++++++ .../Doctrine/DoctrineUserRepository.php | 16 +++ .../Security/AdminHeadcountGuardTest.php | 127 ++++++++++++++++++ 5 files changed, 241 insertions(+) create mode 100644 src/Module/Core/Domain/Exception/LastAdminProtectionException.php create mode 100644 src/Module/Core/Domain/Security/AdminHeadcountGuard.php create mode 100644 tests/Module/Core/Domain/Security/AdminHeadcountGuardTest.php diff --git a/src/Module/Core/Domain/Exception/LastAdminProtectionException.php b/src/Module/Core/Domain/Exception/LastAdminProtectionException.php new file mode 100644 index 0000000..7f41a9b --- /dev/null +++ b/src/Module/Core/Domain/Exception/LastAdminProtectionException.php @@ -0,0 +1,26 @@ +checkAdminHeadcount(); + } + + /** + * Verifie qu'il restera au moins un admin apres la suppression de $user. + * + * Meme principe que ensureAtLeastOneAdminRemainsAfterDemotion() : $user + * est accepte pour la symetrie du contrat et les evolutions futures, + * mais le comptage ne depend pas de son identite. + */ + public function ensureAtLeastOneAdminRemainsAfterDeletion(User $user): void + { + $this->checkAdminHeadcount(); + } + + /** + * Compte les administrateurs et leve une exception si le seuil minimum est atteint. + * + * La verification est volontairement conservative (<=1) pour couvrir + * le cas defensif ou la base serait deja dans un etat incoherent (0 admin). + * + * @throws LastAdminProtectionException si le nombre d'admins est inferieur ou egal a 1 + */ + private function checkAdminHeadcount(): void + { + if ($this->userRepository->countAdmins() <= 1) { + throw new LastAdminProtectionException(); + } + } +} diff --git a/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php b/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php index 279dade..ce01e15 100644 --- a/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php +++ b/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php @@ -34,4 +34,20 @@ class DoctrineUserRepository extends ServiceEntityRepository implements UserRepo $this->getEntityManager()->persist($user); $this->getEntityManager()->flush(); } + + /** + * Compte les utilisateurs ayant le flag isAdmin a true. + * + * Utilise par AdminHeadcountGuard pour verifier que l'instance conserve + * toujours au moins un administrateur apres une demote ou une suppression. + */ + public function countAdmins(): int + { + return (int) $this->createQueryBuilder('u') + ->select('COUNT(u.id)') + ->where('u.isAdmin = true') + ->getQuery() + ->getSingleScalarResult() + ; + } } diff --git a/tests/Module/Core/Domain/Security/AdminHeadcountGuardTest.php b/tests/Module/Core/Domain/Security/AdminHeadcountGuardTest.php new file mode 100644 index 0000000..7278aac --- /dev/null +++ b/tests/Module/Core/Domain/Security/AdminHeadcountGuardTest.php @@ -0,0 +1,127 @@ +createMock(UserRepositoryInterface::class); + $repo->method('countAdmins')->willReturn(2); + + $guard = new AdminHeadcountGuard($repo); + $user = new User(); + $user->setUsername('alice'); + + // Aucune exception ne doit etre levee + $guard->ensureAtLeastOneAdminRemainsAfterDemotion($user); + $this->addToAssertionCount(1); + } + + /** + * Bloque la demote quand il ne reste exactement qu'un admin. + */ + public function testBlocksDemotionWhenExactlyOneAdmin(): void + { + $repo = $this->createMock(UserRepositoryInterface::class); + $repo->method('countAdmins')->willReturn(1); + + $guard = new AdminHeadcountGuard($repo); + $user = new User(); + $user->setUsername('alice'); + + $this->expectException(LastAdminProtectionException::class); + $guard->ensureAtLeastOneAdminRemainsAfterDemotion($user); + } + + /** + * Bloque la demote de facon defensive si le compteur est a 0 (etat incoherent). + */ + public function testBlocksDemotionDefensivelyWhenZeroAdmin(): void + { + $repo = $this->createMock(UserRepositoryInterface::class); + $repo->method('countAdmins')->willReturn(0); + + $guard = new AdminHeadcountGuard($repo); + $user = new User(); + $user->setUsername('alice'); + + $this->expectException(LastAdminProtectionException::class); + $guard->ensureAtLeastOneAdminRemainsAfterDemotion($user); + } + + // --------------------------------------------------------------- + // Deletion (suppression de l'utilisateur) + // --------------------------------------------------------------- + + /** + * Autorise la suppression quand il reste plus d'un admin (cas nominal). + */ + public function testAllowsDeletionWhenMoreThanOneAdmin(): void + { + $repo = $this->createMock(UserRepositoryInterface::class); + $repo->method('countAdmins')->willReturn(2); + + $guard = new AdminHeadcountGuard($repo); + $user = new User(); + $user->setUsername('bob'); + + // Aucune exception ne doit etre levee + $guard->ensureAtLeastOneAdminRemainsAfterDeletion($user); + $this->addToAssertionCount(1); + } + + /** + * Bloque la suppression quand il ne reste exactement qu'un admin. + */ + public function testBlocksDeletionWhenExactlyOneAdmin(): void + { + $repo = $this->createMock(UserRepositoryInterface::class); + $repo->method('countAdmins')->willReturn(1); + + $guard = new AdminHeadcountGuard($repo); + $user = new User(); + $user->setUsername('bob'); + + $this->expectException(LastAdminProtectionException::class); + $guard->ensureAtLeastOneAdminRemainsAfterDeletion($user); + } + + /** + * Bloque la suppression de facon defensive si le compteur est a 0 (etat incoherent). + */ + public function testBlocksDeletionDefensivelyWhenZeroAdmin(): void + { + $repo = $this->createMock(UserRepositoryInterface::class); + $repo->method('countAdmins')->willReturn(0); + + $guard = new AdminHeadcountGuard($repo); + $user = new User(); + $user->setUsername('bob'); + + $this->expectException(LastAdminProtectionException::class); + $guard->ensureAtLeastOneAdminRemainsAfterDeletion($user); + } +} -- 2.39.5 From ab2f11d40d1e5e3e37c361491e62024da1426b89 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 15:51:23 +0200 Subject: [PATCH 28/55] feat(core) : RBAC #345 - PermissionVoter symfony --- .../Security/PermissionVoter.php | 66 ++++++ .../Security/PermissionVoterTest.php | 221 ++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 src/Module/Core/Infrastructure/Security/PermissionVoter.php create mode 100644 tests/Module/Core/Infrastructure/Security/PermissionVoterTest.php diff --git a/src/Module/Core/Infrastructure/Security/PermissionVoter.php b/src/Module/Core/Infrastructure/Security/PermissionVoter.php new file mode 100644 index 0000000..808aeae --- /dev/null +++ b/src/Module/Core/Infrastructure/Security/PermissionVoter.php @@ -0,0 +1,66 @@ + + */ +final class PermissionVoter extends Voter +{ + /** + * Regex de reconnaissance des codes de permission. + * + * Contraintes : + * - Premier caractere alphabetique minuscule (pas de chiffre, pas de ROLE_). + * - Au moins un point de separation (ecarte les attributs atomiques + * type ROLE_ADMIN ou IS_AUTHENTICATED_FULLY). + * - Segments en snake_case minuscule coherents avec les permissions + * declarees par les *Module::permissions() et validees par app:sync-permissions. + */ + private const string PERMISSION_CODE_PATTERN = '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/'; + + protected function supports(string $attribute, mixed $subject): bool + { + return (bool) preg_match(self::PERMISSION_CODE_PATTERN, $attribute); + } + + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool + { + $user = $token->getUser(); + + if (!$user instanceof User) { + // Token anonyme ou user d'un autre type : on refuse explicitement. + // Les voters core (AuthenticatedVoter) se chargent deja du cas + // "pas authentifie du tout". + return false; + } + + if ($user->isAdmin()) { + // Bypass total : decision architecturale #343 section 11. + // Cette regle est dupliquee cote front dans usePermissions() + // et les deux doivent bouger ensemble si elle evolue un jour. + return true; + } + + return in_array($attribute, $user->getEffectivePermissions(), true); + } +} diff --git a/tests/Module/Core/Infrastructure/Security/PermissionVoterTest.php b/tests/Module/Core/Infrastructure/Security/PermissionVoterTest.php new file mode 100644 index 0000000..ded5a66 --- /dev/null +++ b/tests/Module/Core/Infrastructure/Security/PermissionVoterTest.php @@ -0,0 +1,221 @@ +voter = new PermissionVoter(); + } + + // --------------------------------------------------------------- + // Abstention : attributs non-RBAC + // --------------------------------------------------------------- + + /** + * Le voter s'abstient sur ROLE_ADMIN : commence par une majuscule, + * ne correspond pas au pattern snake_case minuscule avec point. + */ + public function testAbstainsOnRoleAdminAttribute(): void + { + $user = $this->buildUser(username: 'alice', isAdmin: false); + $token = new UsernamePasswordToken($user, 'main', $user->getRoles()); + + $result = $this->voter->vote($token, null, ['ROLE_ADMIN']); + + $this->assertSame(VoterInterface::ACCESS_ABSTAIN, $result); + } + + /** + * Le voter s'abstient sur IS_AUTHENTICATED_FULLY : contient des majuscules, + * pas de point de separation conforme au pattern RBAC. + */ + public function testAbstainsOnIsAuthenticatedAttribute(): void + { + $user = $this->buildUser(username: 'alice', isAdmin: false); + $token = new UsernamePasswordToken($user, 'main', $user->getRoles()); + + $result = $this->voter->vote($token, null, ['IS_AUTHENTICATED_FULLY']); + + $this->assertSame(VoterInterface::ACCESS_ABSTAIN, $result); + } + + /** + * Le voter s'abstient sur des attributs malformes : sans point ou avec + * majuscules. + */ + #[DataProvider('malformedAttributeProvider')] + public function testAbstainsOnMalformedAttribute(string $attribute): void + { + $user = $this->buildUser(username: 'alice', isAdmin: false); + $token = new UsernamePasswordToken($user, 'main', $user->getRoles()); + + $result = $this->voter->vote($token, null, [$attribute]); + + $this->assertSame( + VoterInterface::ACCESS_ABSTAIN, + $result, + sprintf('Le voter aurait du s\'abstenir pour l\'attribut "%s".', $attribute), + ); + } + + /** + * @return array + */ + public static function malformedAttributeProvider(): array + { + return [ + 'sans point' => ['nodot'], + 'majuscule milieu' => ['HAS.UPPERCASE'], + 'commence chiffre' => ['1core.users.view'], + 'chaine vide' => [''], + ]; + } + + // --------------------------------------------------------------- + // Refus : utilisateur non reconnu + // --------------------------------------------------------------- + + /** + * Refuse l'acces quand le token ne porte pas une instance de User metier + * (ex: InMemoryUser de Symfony). + */ + public function testDeniesWhenUserIsNotAUserEntity(): void + { + $inMemoryUser = new InMemoryUser('anonymous', null, ['ROLE_USER']); + $token = new UsernamePasswordToken($inMemoryUser, 'main', $inMemoryUser->getRoles()); + + $result = $this->voter->vote($token, null, ['core.users.view']); + + $this->assertSame(VoterInterface::ACCESS_DENIED, $result); + } + + // --------------------------------------------------------------- + // Bypass admin + // --------------------------------------------------------------- + + /** + * Accorde l'acces systematiquement a un administrateur, meme sans aucune + * permission explicite assignee. + */ + public function testGrantsForAdminBypass(): void + { + // Admin sans role ni permission directe : le bypass doit suffire. + $user = $this->buildUser(username: 'admin', isAdmin: true); + $token = new UsernamePasswordToken($user, 'main', $user->getRoles()); + + $result = $this->voter->vote($token, null, ['core.users.view']); + + $this->assertSame(VoterInterface::ACCESS_GRANTED, $result); + } + + // --------------------------------------------------------------- + // Permissions effectives via role + // --------------------------------------------------------------- + + /** + * Accorde l'acces quand l'utilisateur possede la permission exacte via un role. + */ + public function testGrantsWhenUserHasExactPermission(): void + { + $permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core'); + $role = new Role('viewer', 'Viewer'); + $role->addPermission($permission); + + $user = $this->buildUser(username: 'alice', isAdmin: false); + $user->addRbacRole($role); + + $token = new UsernamePasswordToken($user, 'main', $user->getRoles()); + + $result = $this->voter->vote($token, null, ['core.users.view']); + + $this->assertSame(VoterInterface::ACCESS_GRANTED, $result); + } + + /** + * Refuse l'acces quand l'utilisateur possede une permission differente de + * celle demandee. + */ + public function testDeniesWhenUserLacksPermission(): void + { + $permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core'); + $role = new Role('viewer', 'Viewer'); + $role->addPermission($permission); + + $user = $this->buildUser(username: 'alice', isAdmin: false); + $user->addRbacRole($role); + + $token = new UsernamePasswordToken($user, 'main', $user->getRoles()); + + // L'utilisateur a core.users.view mais pas core.roles.manage. + $result = $this->voter->vote($token, null, ['core.roles.manage']); + + $this->assertSame(VoterInterface::ACCESS_DENIED, $result); + } + + // --------------------------------------------------------------- + // Permissions directes (hors roles) + // --------------------------------------------------------------- + + /** + * Accorde l'acces via une permission directe (assignee sans passer par un role). + */ + public function testGrantsForDirectPermission(): void + { + $permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core'); + + $user = $this->buildUser(username: 'bob', isAdmin: false); + $user->addDirectPermission($permission); + + $token = new UsernamePasswordToken($user, 'main', $user->getRoles()); + + $result = $this->voter->vote($token, null, ['core.users.view']); + + $this->assertSame(VoterInterface::ACCESS_GRANTED, $result); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + /** + * Construit un User metier minimal sans persistance. + */ + private function buildUser(string $username, bool $isAdmin): User + { + $user = new User(); + $user->setUsername($username); + $user->setIsAdmin($isAdmin); + // Mot de passe factice pour satisfaire PasswordAuthenticatedUserInterface. + $user->setPassword('hashed_placeholder'); + + return $user; + } +} -- 2.39.5 From ba5eb804f20a9d93de0c1bcd2e1b70424a3de577 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 15:57:19 +0200 Subject: [PATCH 29/55] feat(core) : RBAC #345 - UserProcessor DELETE guard Introduit AdminHeadcountGuardInterface pour permettre le mock en tests unitaires, puis cree UserProcessor qui protege DELETE /api/users/{id} contre la suppression du dernier administrateur via la garde domaine. --- .../Domain/Security/AdminHeadcountGuard.php | 2 +- .../Security/AdminHeadcountGuardInterface.php | 31 +++++ .../State/Processor/UserProcessor.php | 62 +++++++++ .../State/Processor/UserProcessorTest.php | 130 ++++++++++++++++++ 4 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 src/Module/Core/Domain/Security/AdminHeadcountGuardInterface.php create mode 100644 src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessor.php create mode 100644 tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessorTest.php diff --git a/src/Module/Core/Domain/Security/AdminHeadcountGuard.php b/src/Module/Core/Domain/Security/AdminHeadcountGuard.php index 779f819..8978398 100644 --- a/src/Module/Core/Domain/Security/AdminHeadcountGuard.php +++ b/src/Module/Core/Domain/Security/AdminHeadcountGuard.php @@ -17,7 +17,7 @@ use App\Module\Core\Domain\Repository\UserRepositoryInterface; * Il compte les admins restants et leve LastAdminProtectionException si * le seuil minimum (1) serait franchi. */ -final class AdminHeadcountGuard +final class AdminHeadcountGuard implements AdminHeadcountGuardInterface { public function __construct(private readonly UserRepositoryInterface $userRepository) {} diff --git a/src/Module/Core/Domain/Security/AdminHeadcountGuardInterface.php b/src/Module/Core/Domain/Security/AdminHeadcountGuardInterface.php new file mode 100644 index 0000000..b45be17 --- /dev/null +++ b/src/Module/Core/Domain/Security/AdminHeadcountGuardInterface.php @@ -0,0 +1,31 @@ + + */ +final class UserProcessor implements ProcessorInterface +{ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] + private readonly ProcessorInterface $removeProcessor, + private readonly AdminHeadcountGuardInterface $adminHeadcountGuard, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof User) { + // Ce processor est wire exclusivement sur l'operation Delete de User. + // Si on arrive ici avec un autre type, c'est une misconfiguration. + throw new LogicException(sprintf( + 'UserProcessor attend une instance de %s, %s recu.', + User::class, + get_debug_type($data), + )); + } + + // Garde dernier admin global : on ne verifie que si on supprime + // effectivement un admin. La suppression d'un user standard n'a + // aucun impact sur le compteur d'administrateurs. + if ($data->isAdmin()) { + try { + $this->adminHeadcountGuard->ensureAtLeastOneAdminRemainsAfterDeletion($data); + } catch (LastAdminProtectionException $exception) { + throw new BadRequestHttpException($exception->getMessage(), $exception); + } + } + + return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } +} diff --git a/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessorTest.php b/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessorTest.php new file mode 100644 index 0000000..6836e4e --- /dev/null +++ b/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessorTest.php @@ -0,0 +1,130 @@ +removeProcessor = $this->createMock(ProcessorInterface::class); + $this->adminHeadcountGuard = $this->createMock(AdminHeadcountGuardInterface::class); + + $this->processor = new UserProcessor( + $this->removeProcessor, + $this->adminHeadcountGuard, + ); + } + + public function testDelegatesWhenUserIsNotAdmin(): void + { + $user = new User(); + $user->setUsername('alice'); + $user->setIsAdmin(false); + + // La garde ne doit jamais etre appellee pour un non-admin. + $this->adminHeadcountGuard + ->expects($this->never()) + ->method('ensureAtLeastOneAdminRemainsAfterDeletion') + ; + + $this->removeProcessor + ->expects($this->once()) + ->method('process') + ->with($user) + ->willReturn(null) + ; + + $result = $this->processor->process($user, new Delete()); + + self::assertNull($result); + } + + public function testDelegatesWhenAdminButNotLast(): void + { + $user = new User(); + $user->setUsername('admin'); + $user->setIsAdmin(true); + + // La garde est appelee et ne leve pas d'exception (il reste d'autres admins). + $this->adminHeadcountGuard + ->expects($this->once()) + ->method('ensureAtLeastOneAdminRemainsAfterDeletion') + ->with($user) + ; + + $this->removeProcessor + ->expects($this->once()) + ->method('process') + ->with($user) + ->willReturn(null) + ; + + $this->processor->process($user, new Delete()); + } + + public function testBlocksWhenDeletingLastAdmin(): void + { + $user = new User(); + $user->setUsername('admin'); + $user->setIsAdmin(true); + + $exceptionMessage = 'Impossible : au moins un administrateur doit rester sur l\'instance.'; + + $this->adminHeadcountGuard + ->expects($this->once()) + ->method('ensureAtLeastOneAdminRemainsAfterDeletion') + ->with($user) + ->willThrowException(new LastAdminProtectionException($exceptionMessage)) + ; + + // La suppression ne doit pas etre executee si la garde echoue. + $this->removeProcessor + ->expects($this->never()) + ->method('process') + ; + + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage($exceptionMessage); + + $this->processor->process($user, new Delete()); + } + + public function testFailFastOnInvalidDataType(): void + { + // Garde-fou contre une misconfiguration : ce processor est wire + // exclusivement sur l'operation Delete de User. + $this->adminHeadcountGuard->expects($this->never())->method('ensureAtLeastOneAdminRemainsAfterDeletion'); + $this->removeProcessor->expects($this->never())->method('process'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('UserProcessor attend une instance de'); + + $this->processor->process(new stdClass(), new Delete()); + } +} -- 2.39.5 From 80b63cd7d7e19270927aee858c5e09eada610b7b Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 16:00:34 +0200 Subject: [PATCH 30/55] feat(core) : RBAC #345 - UserRbacProcessor last admin guard --- .../State/Processor/UserRbacProcessor.php | 46 ++-- .../State/Processor/UserRbacProcessorTest.php | 201 ++++++++++++++++-- 2 files changed, 214 insertions(+), 33 deletions(-) diff --git a/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php b/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php index 4215d74..9f0029b 100644 --- a/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php +++ b/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php @@ -7,6 +7,8 @@ namespace App\Module\Core\Infrastructure\ApiPlatform\State\Processor; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\Module\Core\Domain\Entity\User; +use App\Module\Core\Domain\Exception\LastAdminProtectionException; +use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface; use Doctrine\ORM\EntityManagerInterface; use LogicException; use Symfony\Bundle\SecurityBundle\Security; @@ -21,14 +23,12 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; * ne touche JAMAIS au mot de passe — c'est une separation volontaire avec le * UserPasswordHasherProcessor qui gere le endpoint profil `/api/users/{id}`. * - * Gardes metier : + * Gardes metier (dans l'ordre d'execution) : * - Auto-suicide : un admin ne peut pas retirer son propre flag `isAdmin`. - * On compare l'etat entrant a l'etat d'origine via l'UnitOfWork Doctrine, - * en restreignant la verification au couple "user courant == user cible". - * - * TODO ticket #345 : garde "dernier admin" globale via inventaire des admins - * restants (empeche de retirer `isAdmin` au dernier admin de l'instance, meme - * si ce n'est pas sa propre operation). + * Cas particulier plus strict, avec message dedie. + * - Dernier admin global : impossible de retirer `isAdmin` si c'est le + * dernier administrateur de l'instance, meme par un tiers. Enforce via + * AdminHeadcountGuardInterface. * * @implements ProcessorInterface */ @@ -39,6 +39,7 @@ final class UserRbacProcessor implements ProcessorInterface private readonly ProcessorInterface $persistProcessor, private readonly EntityManagerInterface $entityManager, private readonly Security $security, + private readonly AdminHeadcountGuardInterface $adminHeadcountGuard, ) {} public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed @@ -56,19 +57,26 @@ final class UserRbacProcessor implements ProcessorInterface $currentUser = $this->security->getUser(); - // Garde auto-suicide : l'user courant ne peut pas retirer son propre - // flag admin. On ne compare que si la cible == l'user courant. - if ($currentUser instanceof User - && null !== $currentUser->getId() - && $currentUser->getId() === $data->getId() - ) { - $originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data); - $wasAdmin = $originalData['isAdmin'] ?? null; + // Calcul partage entre les deux gardes : l'user perdait-il le flag admin ? + $originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data); + $wasAdmin = $originalData['isAdmin'] ?? null; + $willLoseAdmin = true === $wasAdmin && false === $data->isAdmin(); - if (true === $wasAdmin && false === $data->isAdmin()) { - throw new BadRequestHttpException( - 'Vous ne pouvez pas retirer vos propres droits administrateur.' - ); + // Garde auto-suicide : cas particulier plus strict — l'user courant ne + // peut pas retirer son propre flag admin, meme si d'autres admins existent. + if ($willLoseAdmin && $currentUser instanceof User && $currentUser->getId() === $data->getId()) { + throw new BadRequestHttpException( + 'Vous ne pouvez pas retirer vos propres droits administrateur.' + ); + } + + // Garde dernier admin global : invariant general — impossible de retirer + // isAdmin si cela laisserait l'instance sans administrateur. + if ($willLoseAdmin) { + try { + $this->adminHeadcountGuard->ensureAtLeastOneAdminRemainsAfterDemotion($data); + } catch (LastAdminProtectionException $exception) { + throw new BadRequestHttpException($exception->getMessage(), $exception); } } diff --git a/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessorTest.php b/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessorTest.php index e30dbcb..cdf138b 100644 --- a/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessorTest.php +++ b/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessorTest.php @@ -9,6 +9,8 @@ use ApiPlatform\State\ProcessorInterface; use App\Module\Core\Domain\Entity\Permission; use App\Module\Core\Domain\Entity\Role; use App\Module\Core\Domain\Entity\User; +use App\Module\Core\Domain\Exception\LastAdminProtectionException; +use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface; use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\UnitOfWork; @@ -22,9 +24,9 @@ use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; /** - * Tests unitaires du UserRbacProcessor : couvre la garde "auto-suicide" et la - * delegation au PersistProcessor Doctrine decore pour les trois champs RBAC - * (isAdmin, roles, directPermissions). + * Tests unitaires du UserRbacProcessor : couvre la garde "auto-suicide", la + * garde "dernier admin global" et la delegation au PersistProcessor Doctrine + * decore pour les trois champs RBAC (isAdmin, roles, directPermissions). * * @internal */ @@ -35,14 +37,16 @@ final class UserRbacProcessorTest extends TestCase private EntityManagerInterface&MockObject $entityManager; private MockObject&UnitOfWork $unitOfWork; private MockObject&Security $security; + private AdminHeadcountGuardInterface&MockObject $adminHeadcountGuard; private UserRbacProcessor $processor; protected function setUp(): void { - $this->persistProcessor = $this->createMock(ProcessorInterface::class); - $this->entityManager = $this->createMock(EntityManagerInterface::class); - $this->unitOfWork = $this->createMock(UnitOfWork::class); - $this->security = $this->createMock(Security::class); + $this->persistProcessor = $this->createMock(ProcessorInterface::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->unitOfWork = $this->createMock(UnitOfWork::class); + $this->security = $this->createMock(Security::class); + $this->adminHeadcountGuard = $this->createMock(AdminHeadcountGuardInterface::class); $this->entityManager->method('getUnitOfWork')->willReturn($this->unitOfWork); @@ -50,19 +54,28 @@ final class UserRbacProcessorTest extends TestCase $this->persistProcessor, $this->entityManager, $this->security, + $this->adminHeadcountGuard, ); } public function testPatchPromotesUserToAdminDelegatesToPersistProcessor(): void { - $target = $this->buildUser(42, 'alice', false); - $target->setIsAdmin(true); + $target = $this->buildUser(42, 'alice', true); $currentAdmin = $this->buildUser(1, 'admin', true); $this->security->method('getUser')->willReturn($currentAdmin); - // Cible != user courant : pas de lecture d'UnitOfWork necessaire. - $this->unitOfWork->expects(self::never())->method('getOriginalEntityData'); + // La cible gagne isAdmin (false -> true) : willLoseAdmin = false, donc + // getOriginalEntityData est appele mais aucune garde ne bloque. + $this->unitOfWork + ->method('getOriginalEntityData') + ->with($target) + ->willReturn([ + 'id' => 42, + 'username' => 'alice', + 'isAdmin' => false, + ]) + ; $this->persistProcessor ->expects(self::once()) @@ -146,14 +159,30 @@ final class UserRbacProcessorTest extends TestCase public function testPatchAdminDemotingAnotherUserIsAllowed(): void { - // Un admin qui retire isAdmin a quelqu'un d'autre : autorise. + // Un admin qui retire isAdmin a quelqu'un d'autre : autorise si d'autres + // admins existent (guard ne leve pas d'exception). $target = $this->buildUser(42, 'alice', false); $current = $this->buildUser(1, 'admin', true); $this->security->method('getUser')->willReturn($current); - // Cible != user courant : pas de verification d'auto-suicide. - $this->unitOfWork->expects(self::never())->method('getOriginalEntityData'); + // La cible perd isAdmin (true -> false) : getOriginalEntityData est appele. + $this->unitOfWork + ->method('getOriginalEntityData') + ->with($target) + ->willReturn([ + 'id' => 42, + 'username' => 'alice', + 'isAdmin' => true, + ]) + ; + + // Le garde ne leve pas d'exception : d'autres admins existent. + $this->adminHeadcountGuard + ->expects(self::once()) + ->method('ensureAtLeastOneAdminRemainsAfterDemotion') + ->with($target) + ; $this->persistProcessor ->expects(self::once()) @@ -210,6 +239,150 @@ final class UserRbacProcessorTest extends TestCase $this->processor->process(new stdClass(), new Patch()); } + // ------------------------------------------------------------------------- + // Tests de la garde "dernier admin global" + // ------------------------------------------------------------------------- + + public function testBlocksDemotionWhenLastAdminGlobally(): void + { + // L'admin courant A tente de retirer isAdmin a l'admin B (le dernier). + $adminA = $this->buildUser(1, 'adminA', true); + $adminB = $this->buildUser(2, 'adminB', false); // isAdmin -> false dans le PATCH + + $this->security->method('getUser')->willReturn($adminA); + + $this->unitOfWork + ->method('getOriginalEntityData') + ->with($adminB) + ->willReturn([ + 'id' => 2, + 'username' => 'adminB', + 'isAdmin' => true, + ]) + ; + + // Le garde signale qu'il n'y aurait plus aucun admin. + $this->adminHeadcountGuard + ->expects(self::once()) + ->method('ensureAtLeastOneAdminRemainsAfterDemotion') + ->with($adminB) + ->willThrowException(new LastAdminProtectionException()) + ; + + $this->persistProcessor->expects(self::never())->method('process'); + + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('Impossible : au moins un administrateur doit rester sur l\'instance.'); + + $this->processor->process($adminB, new Patch()); + } + + public function testDelegatesDemotionWhenAdminsRemain(): void + { + // L'admin courant A retire isAdmin a l'admin B, mais d'autres admins existent. + $adminA = $this->buildUser(1, 'adminA', true); + $adminB = $this->buildUser(2, 'adminB', false); // isAdmin -> false dans le PATCH + + $this->security->method('getUser')->willReturn($adminA); + + $this->unitOfWork + ->method('getOriginalEntityData') + ->with($adminB) + ->willReturn([ + 'id' => 2, + 'username' => 'adminB', + 'isAdmin' => true, + ]) + ; + + // Le garde ne leve pas d'exception : il reste au moins un admin. + $this->adminHeadcountGuard + ->expects(self::once()) + ->method('ensureAtLeastOneAdminRemainsAfterDemotion') + ->with($adminB) + ; + + $this->persistProcessor + ->expects(self::once()) + ->method('process') + ->with($adminB) + ->willReturn($adminB) + ; + + $result = $this->processor->process($adminB, new Patch()); + + self::assertSame($adminB, $result); + } + + public function testDoesNotCallGuardWhenIsAdminUntouched(): void + { + // PATCH qui ne touche pas isAdmin (reste false) : la garde ne doit pas etre appelee. + $target = $this->buildUser(42, 'alice', false); + $current = $this->buildUser(1, 'admin', true); + + $this->security->method('getUser')->willReturn($current); + + $this->unitOfWork + ->method('getOriginalEntityData') + ->with($target) + ->willReturn([ + 'id' => 42, + 'username' => 'alice', + 'isAdmin' => false, + ]) + ; + + // isAdmin reste false : willLoseAdmin = false, garde jamais appelee. + $this->adminHeadcountGuard + ->expects(self::never()) + ->method('ensureAtLeastOneAdminRemainsAfterDemotion') + ; + + $this->persistProcessor + ->expects(self::once()) + ->method('process') + ->with($target) + ->willReturn($target) + ; + + $result = $this->processor->process($target, new Patch()); + + self::assertSame($target, $result); + } + + public function testAutoSuicideTakesPrecedenceOverLastAdminGlobal(): void + { + // L'unique admin tente de se retirer lui-meme son propre flag. + // La garde auto-suicide doit court-circuiter avant la garde dernier-admin. + $self = $this->buildUser(1, 'admin', false); // isAdmin -> false dans le PATCH + + $this->security->method('getUser')->willReturn($self); + + $this->unitOfWork + ->method('getOriginalEntityData') + ->with($self) + ->willReturn([ + 'id' => 1, + 'username' => 'admin', + 'isAdmin' => true, + ]) + ; + + // La garde dernier-admin ne doit jamais etre appelee : l'auto-suicide + // court-circuite avant. + $this->adminHeadcountGuard + ->expects(self::never()) + ->method('ensureAtLeastOneAdminRemainsAfterDemotion') + ; + + $this->persistProcessor->expects(self::never())->method('process'); + + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('Vous ne pouvez pas retirer vos propres droits administrateur.'); + + $this->processor->process($self, new Patch()); + } + /** * Construit un User avec un id force via reflection (les mocks * d'UnitOfWork n'alimentent pas l'id tout seul). -- 2.39.5 From b05c10097fcc41c7794f3f961202c8b54088720d Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 16:02:57 +0200 Subject: [PATCH 31/55] refactor(core) : RBAC #345 - replace ROLE_ADMIN placeholders with RBAC codes --- src/Module/Core/Domain/Entity/Permission.php | 6 ++---- src/Module/Core/Domain/Entity/Role.php | 15 +++++---------- src/Module/Core/Domain/Entity/User.php | 14 +++++++------- 3 files changed, 14 insertions(+), 21 deletions(-) diff --git a/src/Module/Core/Domain/Entity/Permission.php b/src/Module/Core/Domain/Entity/Permission.php index 83a3b06..7ef7278 100644 --- a/src/Module/Core/Domain/Entity/Permission.php +++ b/src/Module/Core/Domain/Entity/Permission.php @@ -19,13 +19,11 @@ use Symfony\Component\Serializer\Attribute\Groups; operations: [ new GetCollection( normalizationContext: ['groups' => ['permission:read']], - // TODO ticket #345 : remplacer par is_granted('core.permissions.view') - security: "is_granted('ROLE_ADMIN')", + security: "is_granted('core.permissions.view')", ), new Get( normalizationContext: ['groups' => ['permission:read']], - // TODO ticket #345 : remplacer par is_granted('core.permissions.view') - security: "is_granted('ROLE_ADMIN')", + security: "is_granted('core.permissions.view')", ), ], )] diff --git a/src/Module/Core/Domain/Entity/Role.php b/src/Module/Core/Domain/Entity/Role.php index ae98b38..1f84615 100644 --- a/src/Module/Core/Domain/Entity/Role.php +++ b/src/Module/Core/Domain/Entity/Role.php @@ -35,31 +35,26 @@ use Symfony\Component\Validator\Constraints as Assert; operations: [ new GetCollection( normalizationContext: ['groups' => ['role:read']], - // TODO ticket #345 : remplacer par is_granted('core.roles.manage') - security: "is_granted('ROLE_ADMIN')", + security: "is_granted('core.roles.view')", ), new Get( normalizationContext: ['groups' => ['role:read']], - // TODO ticket #345 : remplacer par is_granted('core.roles.manage') - security: "is_granted('ROLE_ADMIN')", + security: "is_granted('core.roles.view')", ), new Post( normalizationContext: ['groups' => ['role:read']], denormalizationContext: ['groups' => ['role:write']], - // TODO ticket #345 : remplacer par is_granted('core.roles.manage') - security: "is_granted('ROLE_ADMIN')", + security: "is_granted('core.roles.manage')", processor: RoleProcessor::class, ), new Patch( normalizationContext: ['groups' => ['role:read']], denormalizationContext: ['groups' => ['role:write']], - // TODO ticket #345 : remplacer par is_granted('core.roles.manage') - security: "is_granted('ROLE_ADMIN')", + security: "is_granted('core.roles.manage')", processor: RoleProcessor::class, ), new Delete( - // TODO ticket #345 : remplacer par is_granted('core.roles.manage') - security: "is_granted('ROLE_ADMIN')", + security: "is_granted('core.roles.manage')", processor: RoleProcessor::class, ), ], diff --git a/src/Module/Core/Domain/Entity/User.php b/src/Module/Core/Domain/Entity/User.php index 96f52a0..89cb073 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserPasswordHasherProcessor; +use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserProcessor; use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor; use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider; use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository; @@ -31,25 +32,24 @@ use Symfony\Component\Serializer\Attribute\SerializedName; normalizationContext: ['groups' => ['me:read']], ), new Get( - security: "is_granted('ROLE_ADMIN')", // TODO ticket #345 : remplacer par is_granted('core.users.view') + security: "is_granted('core.users.view')", normalizationContext: ['groups' => ['user:list']], ), new GetCollection( - security: "is_granted('ROLE_ADMIN')", // TODO ticket #345 : remplacer par is_granted('core.users.view') + security: "is_granted('core.users.view')", normalizationContext: ['groups' => ['user:list']], ), - new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class), - new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class), + new Post(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class), + new Patch(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class), new Patch( name: 'user_rbac_patch', uriTemplate: '/users/{id}/rbac', - // TODO ticket #345 : remplacer par is_granted('core.users.manage') - security: "is_granted('ROLE_ADMIN')", + security: "is_granted('core.users.manage')", normalizationContext: ['groups' => ['user:rbac:read']], denormalizationContext: ['groups' => ['user:rbac:write']], processor: UserRbacProcessor::class, ), - new Delete(security: "is_granted('ROLE_ADMIN')"), + new Delete(security: "is_granted('core.users.manage')", processor: UserProcessor::class), ], denormalizationContext: ['groups' => ['user:write']], )] -- 2.39.5 From d1e4402368d6b196374a41088a5647d8b04eeb5d Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 16:10:11 +0200 Subject: [PATCH 32/55] feat(core) : RBAC #345 - expose effectivePermissions via /api/me - Ajoute #[Groups(['me:read'])] sur getEffectivePermissions() dans User.php - Fixe la serialisation de isAdmin : le prefixe "is" etait strip par Symfony, expose desormais via le getter avec #[SerializedName('isAdmin')] + groups lecture, la propriete conserve uniquement le groupe d'ecriture user:rbac:write - Cree MeApiTest avec 4 tests fonctionnels (isAdmin admin, permissions vides user, 401 sans auth, effectivePermissions avec role portant une permission) --- src/Module/Core/Domain/Entity/User.php | 10 +- tests/Module/Core/Api/MeApiTest.php | 169 +++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 tests/Module/Core/Api/MeApiTest.php diff --git a/src/Module/Core/Domain/Entity/User.php b/src/Module/Core/Domain/Entity/User.php index 89cb073..77a8f12 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -68,7 +68,10 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface private ?string $username = null; #[ORM\Column(name: 'is_admin', options: ['default' => false])] - #[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])] + // Groupe d'ecriture uniquement sur la propriete pour la denormalisation PATCH /rbac. + // Les groupes de lecture sont declares sur le getter isAdmin() afin d'exposer + // la cle JSON "isAdmin" (Symfony strip le prefixe "is" sur les methodes sans SerializedName). + #[Groups(['user:rbac:write'])] private bool $isAdmin = false; /** @@ -169,6 +172,10 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $roles; } + // Groupes de lecture + nom serialise explicite pour eviter que Symfony + // ne strip le prefixe "is" et expose la cle "admin" au lieu de "isAdmin". + #[Groups(['me:read', 'user:list', 'user:rbac:read'])] + #[SerializedName('isAdmin')] public function isAdmin(): bool { return $this->isAdmin; @@ -245,6 +252,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface * * @return list */ + #[Groups(['me:read'])] public function getEffectivePermissions(): array { $codes = []; diff --git a/tests/Module/Core/Api/MeApiTest.php b/tests/Module/Core/Api/MeApiTest.php new file mode 100644 index 0000000..ad41657 --- /dev/null +++ b/tests/Module/Core/Api/MeApiTest.php @@ -0,0 +1,169 @@ +cleanupTestData(); + parent::tearDown(); + } + + /** + * L'admin (isAdmin=true, role systeme sans permission explicite) doit + * obtenir un payload /me avec isAdmin=true et effectivePermissions=[]. + */ + public function testMeEndpointReturnsIsAdminAndEffectivePermissionsForAdmin(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/me', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + self::assertResponseIsSuccessful(); + + $data = $response->toArray(); + + self::assertSame('admin', $data['username'], 'Le champ username doit etre "admin".'); + self::assertTrue($data['isAdmin'], 'isAdmin doit etre true pour l\'admin fixture.'); + self::assertArrayHasKey('effectivePermissions', $data, 'effectivePermissions doit etre present dans le payload.'); + self::assertIsArray($data['effectivePermissions'], 'effectivePermissions doit etre un tableau JSON.'); + // Le role systeme admin n'a pas de permissions explicites : tableau vide attendu. + self::assertSame([], $data['effectivePermissions'], 'effectivePermissions doit etre [] pour l\'admin sans permissions explicites.'); + } + + /** + * Un utilisateur standard (isAdmin=false, role user sans permission) doit + * obtenir isAdmin=false et effectivePermissions=[]. + */ + public function testMeEndpointReturnsEmptyPermissionsForStandardUser(): void + { + $client = $this->authenticatedClient('alice', 'alice'); + $response = $client->request('GET', '/api/me', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + self::assertResponseIsSuccessful(); + + $data = $response->toArray(); + + self::assertFalse($data['isAdmin'], 'isAdmin doit etre false pour alice.'); + self::assertArrayHasKey('effectivePermissions', $data, 'effectivePermissions doit etre present dans le payload.'); + self::assertSame([], $data['effectivePermissions'], 'effectivePermissions doit etre [] pour un user sans role avec permission.'); + } + + /** + * Une requete non authentifiee sur /api/me doit retourner 401. + */ + public function testMeEndpointRequiresAuthentication(): void + { + $client = self::createClient(); + $client->request('GET', '/api/me', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + self::assertResponseStatusCodeSame(401); + } + + /** + * Un user rattache a un role portant la permission `core.users.view` doit + * retrouver cette permission dans effectivePermissions, triee alphabetiquement. + */ + public function testMeEndpointReturnsEffectivePermissionsForUserWithRolePermissions(): void + { + // --- Preparation des donnees de test --- + self::bootKernel(); + $em = $this->getEm(); + + $this->cleanupTestData(); + + /** @var UserPasswordHasherInterface $hasher */ + $hasher = self::getContainer()->get(UserPasswordHasherInterface::class); + + $permission = new Permission('test.me.core.users.view', 'View users (test me)', 'core'); + $em->persist($permission); + + $role = new Role('test_me_viewer', 'Viewer (test me)', false); + $role->addPermission($permission); + $em->persist($role); + + $user = new User(); + $user->setUsername('test_me_viewer_user'); + $user->setIsAdmin(false); + $user->setPassword($hasher->hashPassword($user, 'secret')); + $user->addRbacRole($role); + $em->persist($user); + + $em->flush(); + $em->clear(); + + // --- Appel API --- + $client = $this->authenticatedClient('test_me_viewer_user', 'secret'); + $response = $client->request('GET', '/api/me', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + self::assertResponseIsSuccessful(); + + $data = $response->toArray(); + + self::assertArrayHasKey('effectivePermissions', $data, 'effectivePermissions doit etre present dans le payload.'); + self::assertContains( + 'test.me.core.users.view', + $data['effectivePermissions'], + 'effectivePermissions doit contenir le code de permission du role attribue.', + ); + + // Verifie le tri alphabetique (contrat spec section 9 ticket-343). + $sorted = $data['effectivePermissions']; + $copy = $sorted; + sort($copy); + self::assertSame($copy, $sorted, 'effectivePermissions doit etre trie alphabetiquement.'); + } + + /** + * Purge les entites de test creees par les methodes ci-dessus. + * Ordre : users d'abord (FK vers roles), puis roles, puis permissions. + */ + private function cleanupTestData(): void + { + $em = $this->getEm(); + + $em->createQuery( + 'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix' + )->setParameter('prefix', self::TEST_USER_PREFIX.'%')->execute(); + + $em->createQuery( + 'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix' + )->setParameter('prefix', self::TEST_ROLE_PREFIX.'%')->execute(); + + $em->createQuery( + 'DELETE FROM '.Permission::class.' p WHERE p.code LIKE :prefix' + )->setParameter('prefix', self::TEST_PERMISSION_PREFIX.'%')->execute(); + } +} -- 2.39.5 From 6df4316950f8ed57ea9962174bcf2f9c394f9fba Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 16:16:30 +0200 Subject: [PATCH 33/55] test(core) : RBAC #345 - functional coverage voter + last admin guard --- tests/Module/Core/Api/AbstractApiTestCase.php | 67 ++++++ tests/Module/Core/Api/PermissionApiTest.php | 64 +++++- tests/Module/Core/Api/RoleApiTest.php | 79 +++++++ tests/Module/Core/Api/UserApiTest.php | 195 ++++++++++++++++++ tests/Module/Core/Api/UserRbacApiTest.php | 34 +++ 5 files changed, 438 insertions(+), 1 deletion(-) create mode 100644 tests/Module/Core/Api/UserApiTest.php diff --git a/tests/Module/Core/Api/AbstractApiTestCase.php b/tests/Module/Core/Api/AbstractApiTestCase.php index 3a15d6b..c4993dc 100644 --- a/tests/Module/Core/Api/AbstractApiTestCase.php +++ b/tests/Module/Core/Api/AbstractApiTestCase.php @@ -6,7 +6,11 @@ namespace App\Tests\Module\Core\Api; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Symfony\Bundle\Test\Client; +use App\Module\Core\Domain\Entity\Permission; +use App\Module\Core\Domain\Entity\Role; +use App\Module\Core\Domain\Entity\User; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; /** * Classe de base pour les tests fonctionnels API Platform du module Core. @@ -18,6 +22,9 @@ use Doctrine\ORM\EntityManagerInterface; * (cookie BEARER HTTP-only pose par lexik_jwt_authentication). * - `getEm()` : recupere l'EntityManager depuis le container courant. * A rappeler apres chaque createClient() car le kernel est reboote. + * - `createUserWithPermission()` : cree un user non-admin jetable portant + * une permission specifique via un role custom. Utile pour prouver qu'un + * non-admin avec la permission obtient 200, et sans la permission 403. * * @internal */ @@ -63,4 +70,64 @@ abstract class AbstractApiTestCase extends ApiTestCase return $client; } + + /** + * Cree un utilisateur non-admin portant une permission specifique via un + * role custom jetable. A utiliser dans les tests fonctionnels qui doivent + * prouver qu'un non-admin avec la permission requise obtient 200, et + * sans la permission obtient 403. + * + * Le user et le role sont persistes avec un suffixe aleatoire pour eviter + * les collisions inter-tests. Le password est "testpass". + * + * Prerequis : la permission identifiee par $permissionCode doit exister en + * base (seeder via `app:sync-permissions`). Si elle est introuvable, le test + * echoue immediatement avec un message explicite. + * + * @param string $permissionCode Le code de la permission (ex: "core.users.view") + * + * @return array{username: string, password: string} Les identifiants pour authenticatedClient() + */ + protected function createUserWithPermission(string $permissionCode): array + { + if (!self::$kernel) { + self::bootKernel(); + } + + $em = $this->getEm(); + + /** @var null|Permission $permission */ + $permission = $em->getRepository(Permission::class)->findOneBy(['code' => $permissionCode]); + + self::assertNotNull( + $permission, + sprintf( + 'Permission "%s" introuvable en base. Assurez-vous que `app:sync-permissions` a ete execute.', + $permissionCode, + ), + ); + + $suffix = substr(bin2hex(random_bytes(4)), 0, 8); + $username = 'testuser_'.$suffix; + $password = 'testpass'; + + /** @var UserPasswordHasherInterface $hasher */ + $hasher = self::getContainer()->get(UserPasswordHasherInterface::class); + + $role = new Role('test_'.$suffix, 'Test Role '.$suffix, false); + $role->addPermission($permission); + $em->persist($role); + + $user = new User(); + $user->setUsername($username); + $user->setIsAdmin(false); + $user->setPassword($hasher->hashPassword($user, $password)); + $user->addRbacRole($role); + $em->persist($user); + + $em->flush(); + $em->clear(); + + return ['username' => $username, 'password' => $password]; + } } diff --git a/tests/Module/Core/Api/PermissionApiTest.php b/tests/Module/Core/Api/PermissionApiTest.php index f097b95..d9c6609 100644 --- a/tests/Module/Core/Api/PermissionApiTest.php +++ b/tests/Module/Core/Api/PermissionApiTest.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace App\Tests\Module\Core\Api; use App\Module\Core\Domain\Entity\Permission; +use App\Module\Core\Domain\Entity\Role; +use App\Module\Core\Domain\Entity\User; /** * Tests fonctionnels de l'exposition API Platform de l'entite Permission. @@ -172,9 +174,69 @@ final class PermissionApiTest extends AbstractApiTestCase self::assertResponseStatusCodeSame(403); } + // --- Tests voter RBAC : non-admin avec / sans permission --- + + public function testListPermissionsAsUserWithViewPermissionReturns200(): void + { + // Un non-admin portant core.permissions.view doit pouvoir lister. + $credentials = $this->createUserWithPermission('core.permissions.view'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + $client->request('GET', '/api/permissions'); + + self::assertResponseIsSuccessful(); + } + + public function testListPermissionsAsStandardUserReturns403(): void + { + // alice n'a aucune permission RBAC : acces refuse. + $client = $this->authenticatedClient('alice', 'alice'); + $client->request('GET', '/api/permissions'); + + self::assertResponseStatusCodeSame(403); + } + + public function testGetPermissionAsUserWithViewPermissionReturns200(): void + { + // Recupere l'id d'une permission existante pour construire l'URL GET item. + $permission = $this->getEm()->getRepository(Permission::class) + ->findOneBy(['code' => 'test.core.users.view']) + ; + self::assertNotNull($permission); + + $credentials = $this->createUserWithPermission('core.permissions.view'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + $client->request('GET', '/api/permissions/'.$permission->getId()); + + self::assertResponseIsSuccessful(); + } + + public function testGetPermissionAsStandardUserReturns403(): void + { + $permission = $this->getEm()->getRepository(Permission::class) + ->findOneBy(['code' => 'test.core.users.view']) + ; + self::assertNotNull($permission); + + $client = $this->authenticatedClient('alice', 'alice'); + $client->request('GET', '/api/permissions/'.$permission->getId()); + + self::assertResponseStatusCodeSame(403); + } + private function cleanupTestPermissions(): void { - $this->getEm()->createQuery( + $em = $this->getEm(); + + // Purge des users et roles jetables crees par createUserWithPermission(). + $em->createQuery( + 'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix' + )->setParameter('prefix', 'testuser_%')->execute(); + + $em->createQuery( + 'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix' + )->setParameter('prefix', 'test_%')->execute(); + + $em->createQuery( 'DELETE FROM '.Permission::class.' p WHERE p.code LIKE :prefix' )->setParameter('prefix', self::TEST_CODE_PREFIX.'%')->execute(); } diff --git a/tests/Module/Core/Api/RoleApiTest.php b/tests/Module/Core/Api/RoleApiTest.php index 205ad94..f11bfa3 100644 --- a/tests/Module/Core/Api/RoleApiTest.php +++ b/tests/Module/Core/Api/RoleApiTest.php @@ -368,6 +368,85 @@ final class RoleApiTest extends AbstractApiTestCase self::assertResponseStatusCodeSame(403); } + // --- Tests voter RBAC : non-admin avec / sans permission --- + + public function testListRolesAsUserWithViewPermissionReturns200(): void + { + // Un non-admin portant core.roles.view doit pouvoir lister les roles. + $credentials = $this->createUserWithPermission('core.roles.view'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + $client->request('GET', '/api/roles'); + + self::assertResponseIsSuccessful(); + } + + public function testListRolesAsUserWithOnlyManagePermissionReturns403(): void + { + // Un user avec uniquement core.roles.manage ne peut PAS lister (list/get + // exige core.roles.view, cf. spec section 3 ticket-345). + $credentials = $this->createUserWithPermission('core.roles.manage'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + $client->request('GET', '/api/roles'); + + self::assertResponseStatusCodeSame(403); + } + + public function testListRolesAsStandardUserReturns403(): void + { + $client = $this->authenticatedClient('alice', 'alice'); + $client->request('GET', '/api/roles'); + + self::assertResponseStatusCodeSame(403); + } + + public function testCreateRoleAsUserWithManagePermissionReturns201(): void + { + // Un non-admin portant core.roles.manage doit pouvoir creer un role. + $credentials = $this->createUserWithPermission('core.roles.manage'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + $response = $client->request('POST', '/api/roles', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'code' => 'test_created_by_manager', + 'label' => 'Role cree par manager (test)', + ], + ]); + + self::assertResponseStatusCodeSame(201); + $data = $response->toArray(); + self::assertSame('test_created_by_manager', $data['code']); + } + + public function testCreateRoleAsUserWithOnlyViewPermissionReturns403(): void + { + // Un user avec core.roles.view uniquement ne peut pas creer (POST exige .manage). + $credentials = $this->createUserWithPermission('core.roles.view'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + $client->request('POST', '/api/roles', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'code' => 'test_shouldnotcreate', + 'label' => 'Ne doit pas etre cree', + ], + ]); + + self::assertResponseStatusCodeSame(403); + } + + public function testCreateRoleAsStandardUserReturns403(): void + { + $client = $this->authenticatedClient('alice', 'alice'); + $client->request('POST', '/api/roles', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'code' => 'test_shouldnotcreate_alice', + 'label' => 'Ne doit pas etre cree', + ], + ]); + + self::assertResponseStatusCodeSame(403); + } + /** * Purge les donnees de test (roles et permissions prefixees `test.`). * Ne touche JAMAIS aux roles systeme `admin` et `user` charges par les diff --git a/tests/Module/Core/Api/UserApiTest.php b/tests/Module/Core/Api/UserApiTest.php new file mode 100644 index 0000000..5b80fbd --- /dev/null +++ b/tests/Module/Core/Api/UserApiTest.php @@ -0,0 +1,195 @@ +cleanupTestData(); + parent::tearDown(); + } + + // --- Tests lecture collection --- + + public function testListUsersAsAdminReturns200(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/users'); + + self::assertResponseIsSuccessful(); + $data = $response->toArray(); + self::assertArrayHasKey('member', $data); + // Au moins 3 users fixture. + self::assertGreaterThanOrEqual(3, $data['totalItems']); + } + + public function testListUsersAsUserWithViewPermissionReturns200(): void + { + // Un non-admin portant core.users.view doit pouvoir lister les users. + $credentials = $this->createUserWithPermission('core.users.view'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + $client->request('GET', '/api/users'); + + self::assertResponseIsSuccessful(); + } + + public function testListUsersAsStandardUserReturns403(): void + { + // alice n'a aucune permission RBAC : acces refuse. + $client = $this->authenticatedClient('alice', 'alice'); + $client->request('GET', '/api/users'); + + self::assertResponseStatusCodeSame(403); + } + + // --- Tests suppression --- + + public function testDeleteNonAdminUserAsAdminReturns204(): void + { + // Confirme que la suppression d'un user non-admin fonctionne. + $em = $this->getEm(); + + /** @var UserPasswordHasherInterface $hasher */ + $hasher = self::getContainer()->get(UserPasswordHasherInterface::class); + + $target = new User(); + $target->setUsername('test_deletable_user'); + $target->setIsAdmin(false); + $target->setPassword($hasher->hashPassword($target, 'secret')); + $em->persist($target); + $em->flush(); + $targetId = $target->getId(); + $em->clear(); + + $client = $this->authenticatedClient('admin', 'admin'); + $client->request('DELETE', '/api/users/'.$targetId); + + self::assertResponseStatusCodeSame(204); + + // Verification cote base : le user n'existe plus. + $em = $this->getEm(); + $em->clear(); + self::assertNull($em->getRepository(User::class)->find($targetId)); + } + + public function testDeleteSecondAdminReturns204(): void + { + // Quand il y a 2 admins, supprimer le second est autorise (garde non declenchee). + $em = $this->getEm(); + + /** @var UserPasswordHasherInterface $hasher */ + $hasher = self::getContainer()->get(UserPasswordHasherInterface::class); + + $secondAdmin = new User(); + $secondAdmin->setUsername('test_second_admin'); + $secondAdmin->setIsAdmin(true); + $secondAdmin->setPassword($hasher->hashPassword($secondAdmin, 'secret')); + $em->persist($secondAdmin); + $em->flush(); + $secondAdminId = $secondAdmin->getId(); + $em->clear(); + + // Auth en tant qu'admin fixture, supprime le second admin. + $client = $this->authenticatedClient('admin', 'admin'); + $client->request('DELETE', '/api/users/'.$secondAdminId); + + self::assertResponseStatusCodeSame(204); + + $em = $this->getEm(); + $em->clear(); + self::assertNull($em->getRepository(User::class)->find($secondAdminId)); + } + + public function testDeleteLastAdminReturns400(): void + { + // Scenario "dernier admin global" : un seul admin existe (fixture admin). + // Il tente de se supprimer lui-meme -> garde activee -> 400. + $em = $this->getEm(); + + /** @var null|User $fixtureAdmin */ + $fixtureAdmin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + self::assertNotNull($fixtureAdmin, 'L\'user admin fixture doit exister.'); + $fixtureAdminId = $fixtureAdmin->getId(); + + // Garantit qu'il n'y a qu'un seul admin au moment du test : + // s'assure que test_second_admin n'existe pas (tearDown le purge, mais + // soyons defensifs si un test precedent n'a pas nettoye). + $em->createQuery( + 'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix AND u.username != :admin' + )->setParameters(['prefix' => 'test_%', 'admin' => 'admin'])->execute(); + + // Auth en tant que l'admin fixture et tente l'auto-suppression. + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('DELETE', '/api/users/'.$fixtureAdminId); + + self::assertResponseStatusCodeSame(400); + + // Verification cote base : l'admin fixture doit toujours exister. + $em = $this->getEm(); + $em->clear(); + self::assertNotNull( + $em->getRepository(User::class)->find($fixtureAdminId), + 'Le dernier admin ne doit PAS etre supprime.', + ); + } + + public function testDeleteAsStandardUserReturns403(): void + { + $em = $this->getEm(); + + /** @var null|User $alice */ + $alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + self::assertNotNull($alice); + + /** @var null|User $bob */ + $bob = $em->getRepository(User::class)->findOneBy(['username' => 'bob']); + self::assertNotNull($bob); + + // alice sans permission ne peut pas supprimer bob. + $client = $this->authenticatedClient('alice', 'alice'); + $client->request('DELETE', '/api/users/'.$bob->getId()); + + self::assertResponseStatusCodeSame(403); + } + + /** + * Purge les entites de test creees par cette suite. + * Ne touche JAMAIS aux fixtures (admin / alice / bob). + */ + private function cleanupTestData(): void + { + $em = $this->getEm(); + + // Purge des users jetables crees par les tests (y compris testuser_ de createUserWithPermission). + $em->createQuery( + 'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix' + )->setParameter('prefix', self::TEST_USER_PREFIX.'%')->execute(); + + // Purge des roles jetables crees par createUserWithPermission. + $em->createQuery( + 'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix' + )->setParameter('prefix', self::TEST_ROLE_PREFIX.'%')->execute(); + } +} diff --git a/tests/Module/Core/Api/UserRbacApiTest.php b/tests/Module/Core/Api/UserRbacApiTest.php index 9984e54..5d825c1 100644 --- a/tests/Module/Core/Api/UserRbacApiTest.php +++ b/tests/Module/Core/Api/UserRbacApiTest.php @@ -224,6 +224,40 @@ final class UserRbacApiTest extends AbstractApiTestCase self::assertFalse($reloaded->isAdmin()); } + // --- Tests voter RBAC : non-admin avec / sans permission --- + + public function testPatchRbacAsUserWithManagePermissionReturns200(): void + { + // Un non-admin portant core.users.manage doit pouvoir appeler PATCH /rbac. + $target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']); + self::assertNotNull($target); + + $credentials = $this->createUserWithPermission('core.users.manage'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + $client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['isAdmin' => false], + ]); + + self::assertResponseIsSuccessful(); + } + + public function testPatchRbacAsUserWithOnlyViewPermissionReturns403(): void + { + // Un user avec core.users.view uniquement ne peut pas ecrire via /rbac. + $target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']); + self::assertNotNull($target); + + $credentials = $this->createUserWithPermission('core.users.view'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + $client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['isAdmin' => true], + ]); + + self::assertResponseStatusCodeSame(403); + } + public function testPatchRbacSelfRemovingAdminReturns400(): void { // On utilise le user admin dedie (test_self_admin) pour ne pas -- 2.39.5 From 45f40ed1b371cfbaedd567aa14f534791b3b6919 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 16:18:36 +0200 Subject: [PATCH 34/55] feat(frontend) : RBAC #345 - usePermissions composable Ajout de isAdmin et effectivePermissions dans UserData, creation du composable usePermissions() (can/canAny/canAll) avec bypass admin. --- frontend/shared/composables/usePermissions.ts | 38 +++++++++++++++++++ frontend/shared/types/user-data.ts | 4 ++ 2 files changed, 42 insertions(+) create mode 100644 frontend/shared/composables/usePermissions.ts diff --git a/frontend/shared/composables/usePermissions.ts b/frontend/shared/composables/usePermissions.ts new file mode 100644 index 0000000..063b50f --- /dev/null +++ b/frontend/shared/composables/usePermissions.ts @@ -0,0 +1,38 @@ +import { useAuthStore } from '~/shared/stores/auth' + +/** + * Composable d'autorisation cote front. + * + * Source de verite : `useAuthStore().user`, qui porte le payload /api/me + * incluant `isAdmin` et `effectivePermissions` (tableau trie sans doublons). + * + * Regle de bypass dupliquee avec `PermissionVoter` (back) : + * si `user.isAdmin === true`, toutes les permissions sont accordees. + * Cette duplication est volontaire pour offrir un feedback UI immediat + * sans aller-retour serveur. Si la regle de bypass change cote back + * (decision architecturale #343 section 11), ce composable DOIT evoluer + * en meme temps. + * + * Stateless : aucun ref module-level, tout passe par Pinia. Le reset est + * assure automatiquement par `authStore.logout()` qui efface `user`. + */ +export function usePermissions() { + const auth = useAuthStore() + + function can(code: string): boolean { + const user = auth.user + if (!user) return false + if (user.isAdmin) return true + return user.effectivePermissions.includes(code) + } + + function canAny(codes: string[]): boolean { + return codes.some(can) + } + + function canAll(codes: string[]): boolean { + return codes.every(can) + } + + return { can, canAny, canAll } +} diff --git a/frontend/shared/types/user-data.ts b/frontend/shared/types/user-data.ts index 25d5c2b..8fd024e 100644 --- a/frontend/shared/types/user-data.ts +++ b/frontend/shared/types/user-data.ts @@ -2,4 +2,8 @@ export interface UserData { id: number username: string roles: string[] + /** Vrai si l'utilisateur a le bypass admin total (voir ticket #343 section 11). */ + isAdmin: boolean + /** Codes de permission effectifs de l'utilisateur, tries alphabetiquement, sans doublon. */ + effectivePermissions: string[] } -- 2.39.5 From 91b2ae0c650da696337ba5ed4a3bd80b89a35d55 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 16:39:44 +0200 Subject: [PATCH 35/55] build(core) : RBAC #345 - sync permissions in db-reset --- makefile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/makefile b/makefile index 3c28f89..9e80969 100644 --- a/makefile +++ b/makefile @@ -82,6 +82,11 @@ migration-migrate: fixtures: $(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load +# Synchronise le catalogue de permissions RBAC avec les declarations +# des modules actifs (CoreModule::permissions() etc.). Idempotent. +sync-permissions: + $(SYMFONY_CONSOLE) --no-interaction app:sync-permissions + # Attention, supprime votre bdd local db-reset: $(DOCKER_COMPOSE) down -v @@ -90,6 +95,7 @@ db-reset: $(SYMFONY_CONSOLE) doctrine:database:create --if-not-exists $(MAKE) migration-migrate $(MAKE) fixtures + $(MAKE) sync-permissions # Restart la bdd db-restart: -- 2.39.5 From 6cc576f000ea93560519682071cdb32df4acdedf Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 17:15:27 +0200 Subject: [PATCH 36/55] test(frontend) : RBAC #345 - vitest setup + usePermissions unit tests --- frontend/package-lock.json | 2503 +++++++++++++++-- frontend/package.json | 7 +- .../__tests__/usePermissions.test.ts | 65 + frontend/vitest.config.ts | 15 + 4 files changed, 2406 insertions(+), 184 deletions(-) create mode 100644 frontend/shared/composables/__tests__/usePermissions.test.ts create mode 100644 frontend/vitest.config.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dd4d986..9fc364f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,17 @@ "pinia": "^3.0.4", "vue": "^3.5.29", "vue-router": "^4.6.4" + }, + "devDependencies": { + "@nuxt/eslint-config": "^1.9.0", + "@typescript-eslint/eslint-plugin": "^8.44.1", + "@typescript-eslint/parser": "^8.44.1", + "@vue/test-utils": "^2.4.6", + "eslint": "^9.36.0", + "eslint-plugin-vue": "^10.5.0", + "happy-dom": "^20.9.0", + "vitest": "^4.1.4", + "vue-eslint-parser": "^10.2.0" } }, "node_modules/@alloc/quick-lru": { @@ -72,6 +83,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -569,40 +581,43 @@ "integrity": "sha512-/B8YJGPzaYq1NbsQmwgP8EZqg40NpTw4ZB3suuI0TplbxKHeK94jeaawLmVhCv+YwUnOpiWEz9U6SeThku/8JQ==", "license": "MIT" }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } }, + "node_modules/@es-joy/jsdoccomment": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.86.0.tgz", + "integrity": "sha512-ukZmRQ81WiTpDWO6D/cTBM7XbrNtutHKvAVnZN/8pldAwLoJArGOvkNyxPTBGsPjsoaQBJxlH+tE2TNA/92Qgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.8", + "@typescript-eslint/types": "^8.58.0", + "comment-parser": "1.4.6", + "esquery": "^1.7.0", + "jsdoc-type-pratt-parser": "~7.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@es-joy/resolve.exports": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@es-joy/resolve.exports/-/resolve.exports-1.2.0.tgz", + "integrity": "sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1042,45 +1057,37 @@ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "license": "MIT", - "peer": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/config-array": { - "version": "0.23.4", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.4.tgz", - "integrity": "sha512-lf19F24LSMfF8weXvW5QEtnLqW70u7kgit5e9PSx0MsHAFclGd1T9ynvWEMDT1w5J4Qt54tomGeAhdoAku1Xow==", + "node_modules/@eslint/compat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.0.5.tgz", + "integrity": "sha512-IbHDbHJfkVNv6xjlET8AIVo/K1NQt7YT4Rp6ok/clyBGcpRx1l6gv0Rq3vBvYfPJIZt6ODf66Zq08FJNDpnzgg==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { - "@eslint/object-schema": "^3.0.4", - "debug": "^4.3.1", - "minimatch": "^10.2.4" + "@eslint/core": "^1.2.1" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.4.tgz", - "integrity": "sha512-jJhqiY3wPMlWWO3370M86CPJ7pt8GmEwSLglMfQhjXal07RCvhmU0as4IuUEW5SJeunfItiEetHmSxCCe9lDBg==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@eslint/core": "^1.2.0" }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "peerDependencies": { + "eslint": "^8.40 || 9 || 10" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, - "node_modules/@eslint/core": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.0.tgz", - "integrity": "sha512-8FTGbNzTvmSlc4cZBaShkC6YvFMG0riksYWRFKXztqVdXaQbcZLXlFbSpC05s70sGEsXAw0qwhx69JiW7hQS7A==", + "node_modules/@eslint/compat/node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -1088,28 +1095,176 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/object-schema": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.4.tgz", - "integrity": "sha512-55lO/7+Yp0ISKRP0PsPtNTeNGapXaO085aELZmWCVc5SH3jfrqpuU6YgOdIxMS99ZHkQN1cXKE+cdIqwww9ptw==", + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "license": "Apache-2.0", - "peer": true, + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.0.tgz", - "integrity": "sha512-ejvBr8MQCbVsWNZnCwDXjUKq40MDmHalq7cJ6e9s/qzTUFIIo/afzt1Vui9T97FM/V/pN4YsFVoed5NIa96RDg==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "license": "Apache-2.0", - "peer": true, "dependencies": { - "@eslint/core": "^1.2.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@humanfs/core": { @@ -1117,7 +1272,6 @@ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18.0" } @@ -1127,7 +1281,6 @@ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" @@ -1141,7 +1294,6 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=12.22" }, @@ -1155,7 +1307,6 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18" }, @@ -1935,6 +2086,59 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/@nuxt/eslint-config": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@nuxt/eslint-config/-/eslint-config-1.15.2.tgz", + "integrity": "sha512-vS6mWB87tYjB8h3TxG/QziaZ6CGJpEOBd7N/j+64/tjNipUJzNgKwDzyGoOifNqyDDnlvgi6T3m9XpeYm4qRaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@clack/prompts": "^1.0.1", + "@eslint/js": "^9.39.3", + "@nuxt/eslint-plugin": "1.15.2", + "@stylistic/eslint-plugin": "^5.9.0", + "@typescript-eslint/eslint-plugin": "^8.56.1", + "@typescript-eslint/parser": "^8.56.1", + "eslint-config-flat-gitignore": "^2.2.1", + "eslint-flat-config-utils": "^3.0.1", + "eslint-merge-processors": "^2.0.0", + "eslint-plugin-import-lite": "^0.5.2", + "eslint-plugin-import-x": "^4.16.1", + "eslint-plugin-jsdoc": "^62.7.1", + "eslint-plugin-regexp": "^3.0.0", + "eslint-plugin-unicorn": "^63.0.0", + "eslint-plugin-vue": "^10.8.0", + "eslint-processor-vue-blocks": "^2.0.0", + "globals": "^17.3.0", + "local-pkg": "^1.1.2", + "pathe": "^2.0.3", + "vue-eslint-parser": "^10.4.0" + }, + "peerDependencies": { + "eslint": "^9.0.0 || ^10.0.0", + "eslint-plugin-format": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-format": { + "optional": true + } + } + }, + "node_modules/@nuxt/eslint-plugin": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@nuxt/eslint-plugin/-/eslint-plugin-1.15.2.tgz", + "integrity": "sha512-LZ4gEcPP5GjzAkb6Kk04a4v0vvkTLOpmnEvdDatnkSlxtQLUSwX8v11vcDGXL92ZQ98dFoC1Q1IA6Tz3jdFIig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "^8.56.1", + "@typescript-eslint/utils": "^8.56.1" + }, + "peerDependencies": { + "eslint": "^9.0.0 || ^10.0.0" + } + }, "node_modules/@nuxt/icon": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@nuxt/icon/-/icon-2.2.1.tgz", @@ -1962,6 +2166,7 @@ "resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.4.2.tgz", "integrity": "sha512-5+IPRNX2CjkBhuWUwz0hBuLqiaJPRoKzQ+SvcdrQDbAyE+VDeFt74VpSFr5/R0ujrK4b+XnSHUJWdS72w6hsog==", "license": "MIT", + "peer": true, "dependencies": { "c12": "^3.3.3", "consola": "^3.4.2", @@ -2064,6 +2269,7 @@ "resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.4.2.tgz", "integrity": "sha512-/q6C7Qhiricgi+PKR7ovBnJlKTL0memCbA1CzRT+itCW/oeYzUfeMdQ35mGntlBoyRPNrMXbzuSUhfDbSCU57w==", "license": "MIT", + "peer": true, "dependencies": { "@vue/shared": "^3.5.30", "defu": "^6.1.4", @@ -2359,6 +2565,13 @@ "node": ">=18.12.0" } }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, "node_modules/@oxc-minify/binding-android-arm-eabi": { "version": "0.117.0", "resolved": "https://registry.npmjs.org/@oxc-minify/binding-android-arm-eabi/-/binding-android-arm-eabi-0.117.0.tgz", @@ -3328,6 +3541,13 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@package-json/types": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@package-json/types/-/types-0.0.12.tgz", + "integrity": "sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw==", + "dev": true, + "license": "MIT" + }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -4260,6 +4480,19 @@ "@simple-git/args-pathspec": "^1.0.2" } }, + "node_modules/@sindresorhus/base62": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/base62/-/base62-1.0.0.tgz", + "integrity": "sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@sindresorhus/is": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", @@ -4290,6 +4523,47 @@ "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", "license": "CC0-1.0" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stylistic/eslint-plugin": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", + "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/types": "^8.56.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.0.0 || ^10.0.0" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -4300,12 +4574,23 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, "license": "MIT", - "peer": true + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", @@ -4317,8 +4602,18 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "devOptional": true, "license": "MIT", - "peer": true + "peer": true, + "dependencies": { + "undici-types": "~7.19.0" + } }, "node_modules/@types/resolve": { "version": "1.20.2", @@ -4326,14 +4621,86 @@ "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "license": "MIT" }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", - "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.0", - "@typescript-eslint/types": "^8.58.0", + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", + "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/type-utils": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", + "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", + "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.2", + "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "engines": { @@ -4348,13 +4715,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", - "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", + "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0" + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4365,9 +4732,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", - "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", + "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4380,10 +4747,35 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", + "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/@typescript-eslint/types": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", - "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", + "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4394,15 +4786,15 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", - "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", + "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.58.0", - "@typescript-eslint/tsconfig-utils": "8.58.0", - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0", + "@typescript-eslint/project-service": "8.58.2", + "@typescript-eslint/tsconfig-utils": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -4420,13 +4812,38 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", + "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", - "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", + "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -4465,6 +4882,288 @@ "vue": ">=3.5.18" } }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@vercel/nft": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-1.5.0.tgz", @@ -4527,6 +5226,129 @@ "vue": "^3.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vue-macros/common": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz", @@ -4632,6 +5454,7 @@ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/parser": "^7.29.2", "@vue/compiler-core": "3.5.32", @@ -4804,6 +5627,17 @@ "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", "license": "MIT" }, + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" + } + }, "node_modules/abbrev": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", @@ -4864,6 +5698,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4903,7 +5738,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5103,6 +5937,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/are-docs-informative": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", + "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -5115,6 +5959,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-kit": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", @@ -5232,6 +6086,7 @@ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "license": "Apache-2.0", + "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -5429,6 +6284,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5482,6 +6338,19 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/builtin-modules": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-5.1.0.tgz", + "integrity": "sha512-c5JxaDrzwRjq3WyJkI1AGR5xy6Gr6udlt7sQPbl09+3ckB+Zo2qqQ2KhCTBr7Q8dHB43bENGYEk4xddrFH/b7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -5530,6 +6399,7 @@ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -5597,6 +6467,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -5638,6 +6517,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5654,6 +6543,13 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -5678,11 +6574,51 @@ "node": ">=18" } }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/citty": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", - "license": "MIT" + "license": "MIT", + "peer": true + }, + "node_modules/clean-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz", + "integrity": "sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/clean-regexp/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } }, "node_modules/cliui": { "version": "9.0.1", @@ -5735,6 +6671,27 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/comment-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.6.tgz", + "integrity": "sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -5787,6 +6744,24 @@ "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "license": "MIT" }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/config-chain/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", @@ -5857,6 +6832,20 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -6171,8 +7160,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/deepmerge": { "version": "4.3.1", @@ -6425,6 +7413,58 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -6576,7 +7616,6 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -6606,30 +7645,33 @@ } }, "node_modules/eslint": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz", - "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "license": "MIT", "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.4", - "@eslint/config-helpers": "^0.5.4", - "@eslint/core": "^1.2.0", - "@eslint/plugin-kit": "^0.7.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", + "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.2.0", - "esquery": "^1.7.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -6639,7 +7681,8 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -6647,7 +7690,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://eslint.org/donate" @@ -6661,20 +7704,337 @@ } } }, - "node_modules/eslint-scope": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", - "license": "BSD-2-Clause", - "peer": true, + "node_modules/eslint-config-flat-gitignore": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/eslint-config-flat-gitignore/-/eslint-config-flat-gitignore-2.3.0.tgz", + "integrity": "sha512-bg4ZLGgoARg1naWfsINUUb/52Ksw/K22K+T16D38Y8v+/sGwwIYrGvH/JBjOin+RQtxxC9tzNNiy4shnGtGyyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint/compat": "^2.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "eslint": "^9.5.0 || ^10.0.0" + } + }, + "node_modules/eslint-flat-config-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eslint-flat-config-utils/-/eslint-flat-config-utils-3.1.0.tgz", + "integrity": "sha512-lM+Nwo2CzpuTS/RASQExlEIwk/BQoKqJWX6VbDlLMb/mveqvt9MMrRXFEkG3bseuK6g8noKZLeX82epkILtv4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint/config-helpers": "^0.5.3", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/eslint-flat-config-utils/node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/eslint-flat-config-utils/node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, + "node_modules/eslint-merge-processors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/eslint-merge-processors/-/eslint-merge-processors-2.0.0.tgz", + "integrity": "sha512-sUuhSf3IrJdGooquEUB5TNpGNpBoQccbnaLHsb1XkBLUPPqCNivCpY05ZcpCOiV9uHwO2yxXEWVczVclzMxYlA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "eslint": "*" + } + }, + "node_modules/eslint-plugin-import-lite": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-import-lite/-/eslint-plugin-import-lite-0.5.2.tgz", + "integrity": "sha512-XvfdWOC5dSLEI9krIPRlNmKSI2ViIE9pVylzfV9fCq0ZpDaNeUk6o0wZv0OzN83QdadgXp1NsY0qjLINxwYCsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0" + } + }, + "node_modules/eslint-plugin-import-x": { + "version": "4.16.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.16.2.tgz", + "integrity": "sha512-rM9K8UBHcWKpzQzStn1YRN2T5NvdeIfSVoKu/lKF41znQXHAUcBbYXe5wd6GNjZjTrP7viQ49n1D83x/2gYgIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@package-json/types": "^0.0.12", + "@typescript-eslint/types": "^8.56.0", + "comment-parser": "^1.4.1", + "debug": "^4.4.1", + "eslint-import-context": "^0.1.9", + "is-glob": "^4.0.3", + "minimatch": "^9.0.3 || ^10.1.2", + "semver": "^7.7.2", + "stable-hash-x": "^0.2.0", + "unrs-resolver": "^1.9.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-import-x" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "eslint-import-resolver-node": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/utils": { + "optional": true + }, + "eslint-import-resolver-node": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jsdoc": { + "version": "62.9.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.9.0.tgz", + "integrity": "sha512-PY7/X4jrVgoIDncUmITlUqK546Ltmx/Pd4Hdsu4CvSjryQZJI2mEV4vrdMufyTetMiZ5taNSqvK//BTgVUlNkA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@es-joy/jsdoccomment": "~0.86.0", + "@es-joy/resolve.exports": "1.2.0", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.6", + "debug": "^4.4.3", + "escape-string-regexp": "^4.0.0", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "html-entities": "^2.6.0", + "object-deep-merge": "^2.0.0", + "parse-imports-exports": "^0.2.4", + "semver": "^7.7.4", + "spdx-expression-parse": "^4.0.0", + "to-valid-identifier": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-regexp": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-regexp/-/eslint-plugin-regexp-3.1.0.tgz", + "integrity": "sha512-qGXIC3DIKZHcK1H9A9+Byz9gmndY6TTSRkSMTZpNXdyCw2ObSehRgccJv35n9AdUakEjQp5VFNLas6BMXizCZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.11.0", + "comment-parser": "^1.4.0", + "jsdoc-type-pratt-parser": "^7.0.0", + "refa": "^0.12.1", + "regexp-ast-analysis": "^0.7.1", + "scslre": "^0.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "peerDependencies": { + "eslint": ">=9.38.0" + } + }, + "node_modules/eslint-plugin-unicorn": { + "version": "63.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-63.0.0.tgz", + "integrity": "sha512-Iqecl9118uQEXYh7adylgEmGfkn5es3/mlQTLLkd4pXkIk9CTGrAbeUux+YljSa2ohXCBmQQ0+Ej1kZaFgcfkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "@eslint-community/eslint-utils": "^4.9.0", + "change-case": "^5.4.4", + "ci-info": "^4.3.1", + "clean-regexp": "^1.0.0", + "core-js-compat": "^3.46.0", + "find-up-simple": "^1.0.1", + "globals": "^16.4.0", + "indent-string": "^5.0.0", + "is-builtin-module": "^5.0.0", + "jsesc": "^3.1.0", + "pluralize": "^8.0.0", + "regexp-tree": "^0.1.27", + "regjsparser": "^0.13.0", + "semver": "^7.7.3", + "strip-indent": "^4.1.1" + }, + "engines": { + "node": "^20.10.0 || >=21.0.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1" + }, + "peerDependencies": { + "eslint": ">=9.38.0" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-vue": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.8.0.tgz", + "integrity": "sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "natural-compare": "^1.4.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^7.1.0", + "semver": "^7.6.3", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "@stylistic/eslint-plugin": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "vue-eslint-parser": "^10.0.0" + }, + "peerDependenciesMeta": { + "@stylistic/eslint-plugin": { + "optional": true + }, + "@typescript-eslint/parser": { + "optional": true + } + } + }, + "node_modules/eslint-processor-vue-blocks": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/eslint-processor-vue-blocks/-/eslint-processor-vue-blocks-2.0.0.tgz", + "integrity": "sha512-u4W0CJwGoWY3bjXAuFpc/b6eK3NQEI8MoeW7ritKj3G3z/WtHrKjkqf+wk8mPEy5rlMGS+k6AZYOw2XBoN/02Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/compiler-sfc": "^3.3.0", + "eslint": ">=9.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "license": "BSD-2-Clause", "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -6692,14 +8052,29 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "license": "Apache-2.0", - "peer": true, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -6710,37 +8085,46 @@ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "license": "MIT", - "peer": true, "engines": { "node": ">= 4" } }, - "node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", - "license": "BSD-2-Clause", - "peer": true, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" + "brace-expansion": "^1.1.7" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "license": "Apache-2.0", - "peer": true, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -6764,7 +8148,6 @@ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "estraverse": "^5.1.0" }, @@ -6777,7 +8160,6 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -6868,6 +8250,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -6878,8 +8270,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-fifo": { "version": "1.3.2", @@ -6919,15 +8310,13 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-npm-meta": { "version": "1.4.2", @@ -6996,7 +8385,6 @@ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "license": "MIT", - "peer": true, "dependencies": { "flat-cache": "^4.0.0" }, @@ -7027,7 +8415,6 @@ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "license": "MIT", - "peer": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -7039,12 +8426,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "license": "MIT", - "peer": true, "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" @@ -7057,8 +8456,7 @@ "version": "3.4.2", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/foreground-child": { "version": "3.3.1", @@ -7255,6 +8653,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/giget": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/giget/-/giget-3.2.0.tgz", @@ -7308,6 +8719,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globals": { + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", + "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/globby": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.0.tgz", @@ -7378,6 +8802,25 @@ "uncrypto": "^0.1.3" } }, + "node_modules/happy-dom": { + "version": "20.9.0", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.9.0.tgz", + "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -7432,6 +8875,23 @@ "integrity": "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==", "license": "MIT" }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/http-assert": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", @@ -7572,6 +9032,31 @@ "integrity": "sha512-3MOLanc3sb3LNGWQl1RlQlNWURE5g32aUphrDyFeCsxBTk08iE3VNe4CwsUZ0Qs1X+EfX0+r29Sxdpza4B+yRA==", "license": "MIT" }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/impound": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/impound/-/impound-1.1.5.tgz", @@ -7604,11 +9089,23 @@ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -7680,6 +9177,22 @@ "node": ">=8" } }, + "node_modules/is-builtin-module": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-5.0.0.tgz", + "integrity": "sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "builtin-modules": "^5.0.0" + }, + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -7941,6 +9454,143 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-beautify/node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/js-beautify/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-beautify/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/js-beautify/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-beautify/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-beautify/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-beautify/node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/js-beautify/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7959,6 +9609,16 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.2.0.tgz", + "integrity": "sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7975,22 +9635,19 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", @@ -8068,7 +9725,6 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "license": "MIT", - "peer": true, "dependencies": { "json-buffer": "3.0.1" } @@ -8329,7 +9985,6 @@ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -8414,7 +10069,6 @@ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "license": "MIT", - "peer": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -8449,6 +10103,12 @@ "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", "license": "MIT" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -8787,12 +10447,27 @@ "integrity": "sha512-Kv2JYYiCzt16Kt5QwAc9BFG89xfPNBx+oQL4GQXD9nLqPkZBiNaqaCWtwnbk/q7UVsTYevvM1b0UF8zmEI4pCg==", "license": "MIT" }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/negotiator": { "version": "0.6.3", @@ -9524,6 +11199,7 @@ "resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.4.2.tgz", "integrity": "sha512-iWVFpr/YEqVU/CenqIHMnIkvb2HE/9f+q8oxZ+pj2et+60NljGRClCgnmbvGPdmNFE0F1bEhoBCYfqbDOCim3Q==", "license": "MIT", + "peer": true, "dependencies": { "@dxup/nuxt": "^0.4.0", "@nuxt/cli": "^3.34.0", @@ -10461,6 +12137,13 @@ "node": ">=0.10.0" } }, + "node_modules/object-deep-merge": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/object-deep-merge/-/object-deep-merge-2.0.0.tgz", + "integrity": "sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==", + "dev": true, + "license": "MIT" + }, "node_modules/object-hash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", @@ -10627,6 +12310,7 @@ "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz", "integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==", "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "^0.112.0" }, @@ -10710,7 +12394,6 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "license": "MIT", - "peer": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -10726,7 +12409,6 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "license": "MIT", - "peer": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -10749,6 +12431,35 @@ "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", "license": "MIT" }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-imports-exports": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", + "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-statements": "1.0.11" + } + }, + "node_modules/parse-statements": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", + "integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==", + "dev": true, + "license": "MIT" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -10763,7 +12474,6 @@ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -10867,6 +12577,7 @@ "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "license": "MIT", + "peer": true, "dependencies": { "@vue/devtools-api": "^7.7.7" }, @@ -10903,6 +12614,16 @@ "pathe": "^2.0.3" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/portfinder": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", @@ -10935,6 +12656,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11478,6 +13200,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -11540,7 +13263,6 @@ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8.0" } @@ -11572,12 +13294,18 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -11738,6 +13466,33 @@ "node": ">=4" } }, + "node_modules/refa": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/refa/-/refa-0.12.1.tgz", + "integrity": "sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0" + }, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/regexp-ast-analysis": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/regexp-ast-analysis/-/regexp-ast-analysis-0.7.1.tgz", + "integrity": "sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0", + "refa": "^0.12.1" + }, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, "node_modules/regexp-tree": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -11747,6 +13502,19 @@ "regexp-tree": "bin/regexp-tree" } }, + "node_modules/regjsparser": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", + "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, "node_modules/replace-in-file": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/replace-in-file/-/replace-in-file-6.3.5.tgz", @@ -11921,6 +13689,19 @@ "node": ">=0.10.0" } }, + "node_modules/reserved-identifiers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz", + "integrity": "sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -12008,6 +13789,16 @@ "node": ">= 0.6" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -12029,6 +13820,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -12194,6 +13986,21 @@ "node": ">=11.0.0" } }, + "node_modules/scslre": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/scslre/-/scslre-0.3.0.tgz", + "integrity": "sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0", + "refa": "^0.12.0", + "regexp-ast-analysis": "^0.7.0" + }, + "engines": { + "node": "^14.0.0 || >=16.0.0" + } + }, "node_modules/scule": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", @@ -12323,6 +14130,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -12421,6 +14235,31 @@ "source-map": "^0.6.0" } }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/speakingurl": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", @@ -12442,6 +14281,23 @@ "node": ">=20.16.0" } }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -12591,6 +14447,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-indent": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", + "integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -12837,6 +14718,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -13057,6 +14939,13 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyclip": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.12.tgz", @@ -13091,6 +14980,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -13103,6 +15002,23 @@ "node": ">=8.0" } }, + "node_modules/to-valid-identifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-valid-identifier/-/to-valid-identifier-1.0.0.tgz", + "integrity": "sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/base62": "^1.0.0", + "reserved-identifiers": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -13174,7 +15090,6 @@ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -13290,6 +15205,13 @@ "@types/estree": "^1.0.0" } }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/unenv": { "version": "2.0.0-rc.24", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", @@ -13445,6 +15367,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, "node_modules/unstorage": { "version": "1.17.5", "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", @@ -13659,7 +15617,6 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "punycode": "^2.1.0" } @@ -13684,6 +15641,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -14494,6 +16452,103 @@ "@esbuild/win32-x64": "0.27.7" } }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -14505,6 +16560,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/compiler-sfc": "3.5.32", @@ -14530,17 +16586,63 @@ "ufo": "^1.6.1" } }, + "node_modules/vue-component-type-helpers": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", + "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", + "dev": true, + "license": "MIT" + }, "node_modules/vue-devtools-stub": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/vue-devtools-stub/-/vue-devtools-stub-0.1.0.tgz", "integrity": "sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==", "license": "MIT" }, + "node_modules/vue-eslint-parser": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz", + "integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "eslint-scope": "^8.2.0 || ^9.0.0", + "eslint-visitor-keys": "^4.2.0 || ^5.0.0", + "espree": "^10.3.0 || ^11.0.0", + "esquery": "^1.6.0", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/vue-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/vue-i18n": { "version": "11.3.1", "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.1.tgz", "integrity": "sha512-azq8fhVnCwJAw0iXW7i44h9P+Bj+snNuevBAaJ9bxn0I3YVsRU3deVFPNnTfZ2uxVJefGp83JUmL68ddCPw5Pw==", "license": "MIT", + "peer": true, "dependencies": { "@intlify/core-base": "11.3.1", "@intlify/devtools-types": "11.3.1", @@ -14596,6 +16698,16 @@ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -14621,12 +16733,28 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14762,6 +16890,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -14848,7 +16986,6 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, diff --git a/frontend/package.json b/frontend/package.json index adf892b..618ed7a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,9 @@ "postinstall": "nuxt prepare", "build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist", "lint": "eslint .", - "lint:fix": "eslint . --fix" + "lint:fix": "eslint . --fix", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@malio/layer-ui": "^1.2.3", @@ -28,8 +30,11 @@ "@nuxt/eslint-config": "^1.9.0", "@typescript-eslint/eslint-plugin": "^8.44.1", "@typescript-eslint/parser": "^8.44.1", + "@vue/test-utils": "^2.4.6", "eslint": "^9.36.0", "eslint-plugin-vue": "^10.5.0", + "happy-dom": "^20.9.0", + "vitest": "^4.1.4", "vue-eslint-parser": "^10.2.0" } } diff --git a/frontend/shared/composables/__tests__/usePermissions.test.ts b/frontend/shared/composables/__tests__/usePermissions.test.ts new file mode 100644 index 0000000..27be3b4 --- /dev/null +++ b/frontend/shared/composables/__tests__/usePermissions.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { usePermissions } from '../usePermissions' + +// Mock du store auth : le composable ne depend que de auth.user. +const mockUser = vi.hoisted(() => ({ + value: null as { isAdmin: boolean; effectivePermissions: string[] } | null, +})) + +vi.mock('~/shared/stores/auth', () => ({ + useAuthStore: () => ({ + get user() { + return mockUser.value + }, + }), +})) + +describe('usePermissions', () => { + beforeEach(() => { + mockUser.value = null + }) + + it('refuse toute permission quand aucun utilisateur n\'est connecte', () => { + const { can, canAny, canAll } = usePermissions() + expect(can('core.users.view')).toBe(false) + expect(canAny(['core.users.view', 'core.roles.view'])).toBe(false) + expect(canAll(['core.users.view'])).toBe(false) + }) + + it('accorde toutes les permissions a un admin via le bypass', () => { + mockUser.value = { isAdmin: true, effectivePermissions: [] } + const { can, canAll } = usePermissions() + expect(can('core.users.view')).toBe(true) + expect(can('module.inexistante.action')).toBe(true) + expect(canAll(['a.b.c', 'd.e.f'])).toBe(true) + }) + + it('accorde une permission presente dans effectivePermissions', () => { + mockUser.value = { isAdmin: false, effectivePermissions: ['core.users.view'] } + const { can } = usePermissions() + expect(can('core.users.view')).toBe(true) + }) + + it('refuse une permission absente pour un non-admin', () => { + mockUser.value = { isAdmin: false, effectivePermissions: ['core.users.view'] } + const { can } = usePermissions() + expect(can('core.roles.manage')).toBe(false) + }) + + it('canAny retourne true si au moins un code matche', () => { + mockUser.value = { isAdmin: false, effectivePermissions: ['core.users.view'] } + const { canAny } = usePermissions() + expect(canAny(['core.roles.manage', 'core.users.view'])).toBe(true) + expect(canAny(['core.roles.manage', 'core.permissions.view'])).toBe(false) + }) + + it('canAll retourne true uniquement si tous les codes matchent', () => { + mockUser.value = { + isAdmin: false, + effectivePermissions: ['core.users.view', 'core.roles.view'], + } + const { canAll } = usePermissions() + expect(canAll(['core.users.view', 'core.roles.view'])).toBe(true) + expect(canAll(['core.users.view', 'core.roles.manage'])).toBe(false) + }) +}) diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..9ee0720 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config' +import { fileURLToPath } from 'node:url' + +export default defineConfig({ + test: { + environment: 'happy-dom', + globals: true, + }, + resolve: { + alias: { + '~': fileURLToPath(new URL('./', import.meta.url)), + '@': fileURLToPath(new URL('./', import.meta.url)), + }, + }, +}) -- 2.39.5 From c1a620f593930f59da78e649d4f5accbe4e436fd Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 17:19:35 +0200 Subject: [PATCH 37/55] build(core) : RBAC #345 - nuxt-test and test-all makefile targets --- makefile | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/makefile b/makefile index 9e80969..8898584 100644 --- a/makefile +++ b/makefile @@ -59,6 +59,10 @@ nuxt-lint: nuxt-lint-fix: $(EXEC_PHP) sh -c "cd frontend && npm run lint:fix" +# Lance les tests unitaires frontend (Vitest) +nuxt-test: + $(EXEC_PHP) sh -c "cd frontend && npm run test" + delete_built_dir: CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d $(DOCKER) exec -u root $(PHP_CONTAINER) rm -rf vendor/ @@ -133,5 +137,8 @@ php-cs-fixer-allow-risky: test: $(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES) +# Lance l'ensemble des tests (PHPUnit back + Vitest front) +test-all: test nuxt-test + wait: sleep 10 -- 2.39.5 From 9117bc0a6c22185c143c006bae210f4da17e85ce Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 16 Apr 2026 09:08:51 +0200 Subject: [PATCH 38/55] feat(frontend) : ERP-26 - sidebar entry + i18n keys for admin roles --- config/sidebar.php | 6 ++++++ frontend/i18n/locales/fr.json | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/config/sidebar.php b/config/sidebar.php index 6c66a51..54018b8 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -32,6 +32,12 @@ return [ 'icon' => 'mdi:cog-outline', 'module' => 'core', ], + [ + 'label' => 'sidebar.core.roles', + 'to' => '/admin/roles', + 'icon' => 'mdi:shield-account-outline', + 'module' => 'core', + ], [ 'label' => 'sidebar.general.logout', 'to' => '/logout', diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index de5013c..eaf2273 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -22,6 +22,9 @@ "commercial": { "section": "Commercial", "suppliers": "Répertoire fournisseurs" + }, + "core": { + "roles": "Gestion des roles" } }, "dashboard": { @@ -56,5 +59,41 @@ "auth": { "logout": "Deconnexion reussie" } + }, + "admin": { + "roles": { + "title": "Gestion des roles", + "newRole": "Nouveau role", + "editRole": "Modifier le role", + "createRole": "Creer un role", + "noRoles": "Aucun role configure", + "table": { + "label": "Libelle", + "code": "Code", + "permissions": "Permissions", + "system": "Systeme", + "actions": "Actions" + }, + "form": { + "label": "Libelle", + "code": "Code", + "description": "Description", + "permissions": "Permissions" + }, + "delete": { + "title": "Supprimer le role", + "message": "Etes-vous sur de vouloir supprimer le role \"{label}\" ? Cette action est irreversible.", + "systemTooltip": "Role systeme non supprimable" + }, + "toast": { + "created": "Role cree avec succes", + "updated": "Role mis a jour avec succes", + "deleted": "Role supprime avec succes" + }, + "permissions": { + "selectAll": "Tout selectionner", + "noPermissions": "Aucune permission disponible" + } + } } } -- 2.39.5 From 44c73b6551e54719d75c0e61c28d4aeab3a19ee2 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 16 Apr 2026 09:10:58 +0200 Subject: [PATCH 39/55] feat(frontend) : ERP-26 - PermissionGroup component --- .../core/components/PermissionGroup.vue | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 frontend/modules/core/components/PermissionGroup.vue diff --git a/frontend/modules/core/components/PermissionGroup.vue b/frontend/modules/core/components/PermissionGroup.vue new file mode 100644 index 0000000..1288a22 --- /dev/null +++ b/frontend/modules/core/components/PermissionGroup.vue @@ -0,0 +1,72 @@ + + + -- 2.39.5 From 84f91428bcaea6aed88602236052635a1c741c0e Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 16 Apr 2026 09:16:08 +0200 Subject: [PATCH 40/55] feat(frontend) : ERP-26 - RoleDeleteModal component Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/components/RoleDeleteModal.vue | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 frontend/modules/core/components/RoleDeleteModal.vue diff --git a/frontend/modules/core/components/RoleDeleteModal.vue b/frontend/modules/core/components/RoleDeleteModal.vue new file mode 100644 index 0000000..5f611bd --- /dev/null +++ b/frontend/modules/core/components/RoleDeleteModal.vue @@ -0,0 +1,69 @@ + + + + + -- 2.39.5 From 2cb5a7a0b0aa86c335e1cd295abb7602a685e834 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 16 Apr 2026 09:18:07 +0200 Subject: [PATCH 41/55] feat(frontend) : ERP-26 - RoleDrawer component (create/edit with permissions) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../modules/core/components/RoleDrawer.vue | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 frontend/modules/core/components/RoleDrawer.vue diff --git a/frontend/modules/core/components/RoleDrawer.vue b/frontend/modules/core/components/RoleDrawer.vue new file mode 100644 index 0000000..ebd38e4 --- /dev/null +++ b/frontend/modules/core/components/RoleDrawer.vue @@ -0,0 +1,218 @@ + + + -- 2.39.5 From 6e0c875bd71d8eebd30a1b99f29ee452b6e29c95 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 16 Apr 2026 09:20:10 +0200 Subject: [PATCH 42/55] feat(frontend) : ERP-26 - admin roles page with table, drawer, delete modal Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/modules/core/pages/admin/roles.vue | 184 ++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 frontend/modules/core/pages/admin/roles.vue diff --git a/frontend/modules/core/pages/admin/roles.vue b/frontend/modules/core/pages/admin/roles.vue new file mode 100644 index 0000000..86d747c --- /dev/null +++ b/frontend/modules/core/pages/admin/roles.vue @@ -0,0 +1,184 @@ + + + -- 2.39.5 From 6101bd85ceb537130c15a38f31048fe8a4deee0d Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 16 Apr 2026 09:21:50 +0200 Subject: [PATCH 43/55] build(frontend) : ERP-26 - bump @malio/layer-ui to ^1.3.0 (DataTable) Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/package-lock.json | 79 +++++++++++++++++--------------------- frontend/package.json | 2 +- 2 files changed, 37 insertions(+), 44 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9fc364f..6695e2e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,7 +7,7 @@ "name": "coltura-frontend", "hasInstallScript": true, "dependencies": { - "@malio/layer-ui": "^1.2.3", + "@malio/layer-ui": "^1.3.0", "@nuxt/icon": "^2.2.1", "@nuxtjs/i18n": "^10.2.3", "@nuxtjs/tailwindcss": "^6.14.0", @@ -83,7 +83,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -581,6 +580,27 @@ "integrity": "sha512-/B8YJGPzaYq1NbsQmwgP8EZqg40NpTw4ZB3suuI0TplbxKHeK94jeaawLmVhCv+YwUnOpiWEz9U6SeThku/8JQ==", "license": "MIT" }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1819,9 +1839,9 @@ "license": "MIT" }, "node_modules/@malio/layer-ui": { - "version": "1.2.3", - "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.2.3/layer-ui-1.2.3.tgz", - "integrity": "sha512-5nRnBzRkXfs3PfKwKl6sH2ikrmSK7lTifcd0TX1QZP3rFRVRTgcT6mrsrpsbR9PwI27OeCNm0X6d0Ii92Rq7Yg==", + "version": "1.3.0", + "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.3.0/layer-ui-1.3.0.tgz", + "integrity": "sha512-Gs4pnlWTWrhoF3QQKxYBu4IxN65O9B4bls7s+ONm05qvI2Y2x7N4VNFGjWvT+rNQ4BzHFCxSCzN4V3o6p0Q7uw==", "dependencies": { "@nuxt/icon": "^2.2.1", "@nuxtjs/tailwindcss": "^6.14.0", @@ -2166,7 +2186,6 @@ "resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.4.2.tgz", "integrity": "sha512-5+IPRNX2CjkBhuWUwz0hBuLqiaJPRoKzQ+SvcdrQDbAyE+VDeFt74VpSFr5/R0ujrK4b+XnSHUJWdS72w6hsog==", "license": "MIT", - "peer": true, "dependencies": { "c12": "^3.3.3", "consola": "^3.4.2", @@ -2269,7 +2288,6 @@ "resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.4.2.tgz", "integrity": "sha512-/q6C7Qhiricgi+PKR7ovBnJlKTL0memCbA1CzRT+itCW/oeYzUfeMdQ35mGntlBoyRPNrMXbzuSUhfDbSCU57w==", "license": "MIT", - "peer": true, "dependencies": { "@vue/shared": "^3.5.30", "defu": "^6.1.4", @@ -4610,7 +4628,6 @@ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -4673,7 +4690,6 @@ "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", @@ -4818,7 +4834,6 @@ "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.2", @@ -5454,7 +5469,6 @@ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.29.2", "@vue/compiler-core": "3.5.32", @@ -5698,7 +5712,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6086,7 +6099,6 @@ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "license": "Apache-2.0", - "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -6284,7 +6296,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -6399,7 +6410,6 @@ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -6594,8 +6604,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/clean-regexp": { "version": "1.0.0", @@ -6671,17 +6680,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "devOptional": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=14" - } - }, "node_modules/comment-parser": { "version": "1.4.6", "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.6.tgz", @@ -7449,6 +7447,16 @@ "balanced-match": "^1.0.0" } }, + "node_modules/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/editorconfig/node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -7649,7 +7657,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8808,7 +8815,6 @@ "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -11199,7 +11205,6 @@ "resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.4.2.tgz", "integrity": "sha512-iWVFpr/YEqVU/CenqIHMnIkvb2HE/9f+q8oxZ+pj2et+60NljGRClCgnmbvGPdmNFE0F1bEhoBCYfqbDOCim3Q==", "license": "MIT", - "peer": true, "dependencies": { "@dxup/nuxt": "^0.4.0", "@nuxt/cli": "^3.34.0", @@ -12258,7 +12263,6 @@ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "license": "MIT", - "peer": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -12310,7 +12314,6 @@ "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz", "integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==", "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/types": "^0.112.0" }, @@ -12577,7 +12580,6 @@ "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/devtools-api": "^7.7.7" }, @@ -12656,7 +12658,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13200,7 +13201,6 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -13820,7 +13820,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -14718,7 +14717,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -15374,7 +15372,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -15641,7 +15638,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -16560,7 +16556,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/compiler-sfc": "3.5.32", @@ -16605,7 +16600,6 @@ "integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", @@ -16642,7 +16636,6 @@ "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.1.tgz", "integrity": "sha512-azq8fhVnCwJAw0iXW7i44h9P+Bj+snNuevBAaJ9bxn0I3YVsRU3deVFPNnTfZ2uxVJefGp83JUmL68ddCPw5Pw==", "license": "MIT", - "peer": true, "dependencies": { "@intlify/core-base": "11.3.1", "@intlify/devtools-types": "11.3.1", diff --git a/frontend/package.json b/frontend/package.json index 618ed7a..5d025f4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,7 +15,7 @@ "test:watch": "vitest" }, "dependencies": { - "@malio/layer-ui": "^1.2.3", + "@malio/layer-ui": "^1.3.0", "@nuxt/icon": "^2.2.1", "@nuxtjs/i18n": "^10.2.3", "@nuxtjs/tailwindcss": "^6.14.0", -- 2.39.5 From 07d53cdf8cc3d91feb421e57e38d3d481ead75aa Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 16 Apr 2026 09:30:49 +0200 Subject: [PATCH 44/55] fix(frontend) : ERP-26 - fix Hydra response format (member not hydra:member) and IRI permissions Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/modules/core/components/RoleDrawer.vue | 16 +++++++++++----- frontend/modules/core/pages/admin/roles.vue | 6 +++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/frontend/modules/core/components/RoleDrawer.vue b/frontend/modules/core/components/RoleDrawer.vue index ebd38e4..a774fbf 100644 --- a/frontend/modules/core/components/RoleDrawer.vue +++ b/frontend/modules/core/components/RoleDrawer.vue @@ -84,7 +84,7 @@ interface Role { label: string description: string | null isSystem: boolean - permissions: Permission[] + permissions: (Permission | string)[] } interface PermissionModule { @@ -134,12 +134,12 @@ const permissionsByModule = computed(() => { // Charger les permissions au montage async function loadPermissions() { - const data = await api.get<{ 'hydra:member': Permission[] }>( + const data = await api.get<{ member: Permission[] }>( '/permissions', { 'orphan': false, itemsPerPage: 200 }, { toast: false }, ) - allPermissions.value = data['hydra:member'] + allPermissions.value = data.member } // Remplir le formulaire quand le role change @@ -148,7 +148,13 @@ watch(() => props.role, (role) => { form.value.label = role.label form.value.code = role.code form.value.description = role.description || '' - selectedPermissionIds.value = new Set(role.permissions.map(p => p.id)) + selectedPermissionIds.value = new Set(role.permissions.map(p => { + // L'API peut retourner des objets Permission ou des IRIs string + if (typeof p === 'string') { + return Number(p.split('/').pop()) + } + return p.id + })) } else { form.value.label = '' form.value.code = '' @@ -196,7 +202,7 @@ async function handleSave() { label: form.value.label, code: form.value.code, description: form.value.description || null, - permissions: Array.from(selectedPermissionIds.value).map(id => ({ id })), + permissions: Array.from(selectedPermissionIds.value).map(id => `/api/permissions/${id}`), } if (isEditMode.value && props.role) { diff --git a/frontend/modules/core/pages/admin/roles.vue b/frontend/modules/core/pages/admin/roles.vue index 86d747c..e0e6e05 100644 --- a/frontend/modules/core/pages/admin/roles.vue +++ b/frontend/modules/core/pages/admin/roles.vue @@ -111,7 +111,7 @@ interface Role { label: string description: string | null isSystem: boolean - permissions: Permission[] + permissions: (Permission | string)[] } const { t } = useI18n() @@ -132,12 +132,12 @@ const deleting = ref(false) async function loadRoles() { loading.value = true try { - const data = await api.get<{ 'hydra:member': Role[] }>( + const data = await api.get<{ member: Role[] }>( '/roles', {}, { toast: false }, ) - roles.value = data['hydra:member'] + roles.value = data.member } finally { loading.value = false } -- 2.39.5 From 3fe44e4de2c61c24c805819bcbbd26f00534a61e Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 16 Apr 2026 09:32:54 +0200 Subject: [PATCH 45/55] refactor(frontend) : ERP-26 - migrate roles table to MalioDataTable component Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/modules/core/pages/admin/roles.vue | 137 +++++++++++--------- 1 file changed, 73 insertions(+), 64 deletions(-) diff --git a/frontend/modules/core/pages/admin/roles.vue b/frontend/modules/core/pages/admin/roles.vue index e0e6e05..ec81460 100644 --- a/frontend/modules/core/pages/admin/roles.vue +++ b/frontend/modules/core/pages/admin/roles.vue @@ -14,70 +14,49 @@ -
- - - - - - - - - - - - - - - - - - - -
{{ t('admin.roles.table.label') }}{{ t('admin.roles.table.code') }}{{ t('admin.roles.table.permissions') }}{{ t('admin.roles.table.system') }}{{ t('admin.roles.table.actions') }}
- {{ role.label }} - - {{ role.code }} - - {{ role.permissions.length }} - - - {{ t('admin.roles.table.system') }} - - -
- - -
-
- - -
- -

{{ t('admin.roles.noRoles') }}

-
-
+ + + + + + ([]) const loading = ref(false) + +const columns = [ + { key: 'label', label: t('admin.roles.table.label') }, + { key: 'code', label: t('admin.roles.table.code') }, + { key: 'permissions', label: t('admin.roles.table.permissions') }, + { key: 'system', label: t('admin.roles.table.system') }, + { key: 'actions', label: t('admin.roles.table.actions') }, +] + +// Transformer les roles en items compatibles MalioDataTable +const roleItems = computed(() => + roles.value.map(role => ({ + id: role.id, + label: role.label, + code: role.code, + permissions: role.permissions.length, + isSystem: role.isSystem, + system: '', // colonne geree par le slot + actions: '', // colonne geree par le slot + })) +) + +function getRoleById(id: number): Role | undefined { + return roles.value.find(r => r.id === id) +} + +function onRowClick(item: Record) { + const role = getRoleById(item.id as number) + if (role) openEditDrawer(role) +} const drawerOpen = ref(false) const selectedRole = ref(null) const deleteModalOpen = ref(false) -- 2.39.5 From 17a331b31d661145048c2278ab46e37a14381b63 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 16 Apr 2026 09:58:10 +0200 Subject: [PATCH 46/55] feat(frontend) : ERP-27 - sidebar entry + i18n keys for admin users Co-Authored-By: Claude Sonnet 4.6 --- config/sidebar.php | 6 ++++++ frontend/i18n/locales/fr.json | 29 ++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/config/sidebar.php b/config/sidebar.php index 54018b8..edad75c 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -38,6 +38,12 @@ return [ 'icon' => 'mdi:shield-account-outline', 'module' => 'core', ], + [ + 'label' => 'sidebar.core.users', + 'to' => '/admin/users', + 'icon' => 'mdi:account-group-outline', + 'module' => 'core', + ], [ 'label' => 'sidebar.general.logout', 'to' => '/logout', diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index eaf2273..8d4c92e 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -24,7 +24,8 @@ "suppliers": "Répertoire fournisseurs" }, "core": { - "roles": "Gestion des roles" + "roles": "Gestion des roles", + "users": "Utilisateurs" } }, "dashboard": { @@ -94,6 +95,32 @@ "selectAll": "Tout selectionner", "noPermissions": "Aucune permission disponible" } + }, + "users": { + "title": "Gestion des utilisateurs", + "noUsers": "Aucun utilisateur", + "table": { + "username": "Nom d'utilisateur", + "admin": "Administrateur", + "roles": "Roles", + "directPermissions": "Permissions directes", + "actions": "Actions" + }, + "drawer": { + "title": "Permissions de {username}", + "selfWarning": "Vous modifiez vos propres droits", + "adminToggle": "Administrateur (bypass total)", + "rolesSection": "Roles", + "directPermissionsSection": "Permissions directes", + "summarySection": "Resume des permissions effectives", + "noEffectivePermissions": "Aucune permission effective", + "sourceRole": "via {role}", + "sourceDirect": "Direct", + "lastAdminWarning": "Impossible de retirer le statut administrateur du dernier admin" + }, + "toast": { + "updated": "Permissions mises a jour avec succes" + } } } } -- 2.39.5 From d0ee109afbd2887bf67cab7b9b7e36e03d865e13 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 16 Apr 2026 09:59:26 +0200 Subject: [PATCH 47/55] feat(frontend) : ERP-27 - EffectivePermissions component Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/components/EffectivePermissions.vue | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 frontend/modules/core/components/EffectivePermissions.vue diff --git a/frontend/modules/core/components/EffectivePermissions.vue b/frontend/modules/core/components/EffectivePermissions.vue new file mode 100644 index 0000000..98646ec --- /dev/null +++ b/frontend/modules/core/components/EffectivePermissions.vue @@ -0,0 +1,73 @@ + + + -- 2.39.5 From c36b8b6c095f27044a94c7807d87f36a99a467ea Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 16 Apr 2026 10:01:46 +0200 Subject: [PATCH 48/55] feat(frontend) : ERP-27 - UserRbacDrawer component Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/components/UserRbacDrawer.vue | 285 ++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 frontend/modules/core/components/UserRbacDrawer.vue diff --git a/frontend/modules/core/components/UserRbacDrawer.vue b/frontend/modules/core/components/UserRbacDrawer.vue new file mode 100644 index 0000000..a215f6a --- /dev/null +++ b/frontend/modules/core/components/UserRbacDrawer.vue @@ -0,0 +1,285 @@ + + + -- 2.39.5 From 580ea019411142f6114d4ae10975dcfaec11069c Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 16 Apr 2026 10:03:14 +0200 Subject: [PATCH 49/55] feat(frontend) : ERP-27 - admin users page with DataTable and RBAC drawer Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/modules/core/pages/admin/users.vue | 126 ++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 frontend/modules/core/pages/admin/users.vue diff --git a/frontend/modules/core/pages/admin/users.vue b/frontend/modules/core/pages/admin/users.vue new file mode 100644 index 0000000..8f496dd --- /dev/null +++ b/frontend/modules/core/pages/admin/users.vue @@ -0,0 +1,126 @@ + + + -- 2.39.5 From d49c317c49c4c20a945fdbe0084eb043ca06d8d1 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 16 Apr 2026 10:45:21 +0200 Subject: [PATCH 50/55] fix(frontend) : ERP-26/27 - review fixes: shared types, accents i18n, escape key, self-edit refresh, row-clickable guard Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/i18n/locales/fr.json | 36 +++++++++---------- .../core/components/EffectivePermissions.vue | 9 ++--- .../core/components/PermissionGroup.vue | 8 +---- .../core/components/RoleDeleteModal.vue | 8 +++++ .../modules/core/components/RoleDrawer.vue | 17 +-------- .../core/components/UserRbacDrawer.vue | 36 +++---------------- frontend/modules/core/pages/admin/roles.vue | 20 ++--------- frontend/modules/core/pages/admin/users.vue | 8 +---- frontend/shared/types/rbac.ts | 31 ++++++++++++++++ 9 files changed, 70 insertions(+), 103 deletions(-) create mode 100644 frontend/shared/types/rbac.ts diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 8d4c92e..998f3dc 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -24,7 +24,7 @@ "suppliers": "Répertoire fournisseurs" }, "core": { - "roles": "Gestion des roles", + "roles": "Gestion des rôles", "users": "Utilisateurs" } }, @@ -63,33 +63,33 @@ }, "admin": { "roles": { - "title": "Gestion des roles", - "newRole": "Nouveau role", - "editRole": "Modifier le role", - "createRole": "Creer un role", - "noRoles": "Aucun role configure", + "title": "Gestion des rôles", + "newRole": "Nouveau rôle", + "editRole": "Modifier le rôle", + "createRole": "Créer un rôle", + "noRoles": "Aucun rôle configuré", "table": { - "label": "Libelle", + "label": "Libellé", "code": "Code", "permissions": "Permissions", - "system": "Systeme", + "system": "Système", "actions": "Actions" }, "form": { - "label": "Libelle", + "label": "Libellé", "code": "Code", "description": "Description", "permissions": "Permissions" }, "delete": { - "title": "Supprimer le role", - "message": "Etes-vous sur de vouloir supprimer le role \"{label}\" ? Cette action est irreversible.", - "systemTooltip": "Role systeme non supprimable" + "title": "Supprimer le rôle", + "message": "Êtes-vous sûr de vouloir supprimer le rôle \"{label}\" ? Cette action est irréversible.", + "systemTooltip": "Rôle système non supprimable" }, "toast": { - "created": "Role cree avec succes", - "updated": "Role mis a jour avec succes", - "deleted": "Role supprime avec succes" + "created": "Rôle créé avec succès", + "updated": "Rôle mis à jour avec succès", + "deleted": "Rôle supprimé avec succès" }, "permissions": { "selectAll": "Tout selectionner", @@ -110,16 +110,16 @@ "title": "Permissions de {username}", "selfWarning": "Vous modifiez vos propres droits", "adminToggle": "Administrateur (bypass total)", - "rolesSection": "Roles", + "rolesSection": "Rôles", "directPermissionsSection": "Permissions directes", - "summarySection": "Resume des permissions effectives", + "summarySection": "Résumé des permissions effectives", "noEffectivePermissions": "Aucune permission effective", "sourceRole": "via {role}", "sourceDirect": "Direct", "lastAdminWarning": "Impossible de retirer le statut administrateur du dernier admin" }, "toast": { - "updated": "Permissions mises a jour avec succes" + "updated": "Permissions mises à jour avec succès" } } } diff --git a/frontend/modules/core/components/EffectivePermissions.vue b/frontend/modules/core/components/EffectivePermissions.vue index 98646ec..1e6bcab 100644 --- a/frontend/modules/core/components/EffectivePermissions.vue +++ b/frontend/modules/core/components/EffectivePermissions.vue @@ -40,14 +40,9 @@