1888b70623
Auto Tag Develop / tag (push) Successful in 11s
## Contexte
ERP-102 — Découvert pendant ERP-64. Connecté avec un rôle **métier** (bureau / compta / commerciale), `GET /api/categories` et `GET /api/sites` renvoient **403**, alors que `/tva_modes`, `/payment_delays`, `/payment_types`, `/banks` renvoient 200.
Conséquences : page **Création client** inutilisable (le `Promise.all` rejetait → **tous** les selects vides) et **filtres Catégories/Sites vides** au répertoire.
## Cause
La `security` des `GetCollection`/`Get` de `Category` et `Site` exigeait `catalog.categories.view` / `sites.view` — permissions d'**administration** du Catalogue / des Sites. Or ces référentiels sont **transverses** : tout rôle qui gère un tiers doit pouvoir les lire.
## Correctif back — Option C (permission de lecture-référentiel dédiée)
Choix d'archi retenu parmi les 3 du ticket :
- **Pourquoi pas A** (`... or is_granted('commercial.clients.view')`) : coupler `Category`/`Site` à une permission **Commercial** viole l'esprit de la règle ABSOLUE n°1 et ne scale pas (M2 Fournisseurs devrait rajouter un OR).
- **Pourquoi pas B** (donner `.view` aux rôles métier) : `.view` = accès admin → items sidebar admin Catégories/Sites exposés à une commerciale.
- **C** : nouvelle permission `catalog.categories.read_ref` / `sites.read_ref`, distincte de `.view` (pas d'item sidebar) et de `.manage`. Chaque permission appartient à **son** module → isolement inter-module préservé, **réutilisable tel quel par M2 Fournisseurs**. C'est la « permission référentiel lisible » que le ticket pointe lui-même.
Détail :
- `CatalogModule` / `SitesModule` : déclaration des deux permissions `read_ref`.
- `Category` / `Site` : security lecture (liste + item) = `view OR read_ref`.
- `RbacSeeder` (matrice § 2.7) : `read_ref` attaché à bureau / compta / commerciale ; usine reste sans accès.
## Durcissement front (résilience — requis dans tous les cas)
`useClientReferentials.loadCommon` : `Promise.all` → **`Promise.allSettled`** avec affectation isolée par référentiel. L'échec d'un endpoint ne vide plus que **son** select, plus la totalité du formulaire.
## Tests (TDD)
- `ClientRBACMatrixTest::testBusinessRolesCanReadCategoriesAndSitesReferentials` — bureau/compta/commerciale listent `/categories` et `/sites` (200), usine reste 403.
- `SitesModuleTest` — set de permissions porté à 4 codes.
- `useClientReferentials.spec` (Vitest) — un référentiel en échec ne vide que son select.
## Vérifications
- `make test` (back) : **467/467** ✓
- `make nuxt-test` (front) : **131/131** ✓
- `make php-cs-fixer` : conforme ✓
## Note miroirs RBAC
`config/sidebar.php` / `personas.ts` / `SeedE2ECommand.php` **non touchés** : `read_ref` n'ajoute aucun item sidebar, le persona E2E `user-full` lit déjà via `.view`, et aucun persona ne modélise un rôle métier seul. Pas de nouveau test E2E (règle n°7 : bug attrapé avant prod). La source de vérité de la matrice (`RbacSeeder`) est mise à jour et couverte par `ClientRBACMatrixTest`.
Closes ERP-102.
---------
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #53
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
228 lines
8.4 KiB
PHP
228 lines
8.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Module\Core\Application\Rbac;
|
|
|
|
use App\Module\Core\Domain\Entity\Role;
|
|
use App\Module\Core\Domain\Entity\User;
|
|
use App\Module\Core\Domain\Exception\RbacSeedException;
|
|
use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
|
|
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
|
|
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
|
|
use App\Shared\Domain\Contract\SiteProviderInterface;
|
|
use App\Shared\Domain\Security\BusinessRoles;
|
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
|
|
|
/**
|
|
* Source UNIQUE (anti-drift) du RBAC metier MALIO : les 4 roles
|
|
* (bureau / compta / commerciale / usine), la matrice § 2.7 (role -> permissions)
|
|
* et les comptes demo par role. Aucun de ces litteraux ne doit etre duplique
|
|
* ailleurs (ni SQL en dur, ni autre fixture).
|
|
*
|
|
* Consomme par :
|
|
* - la commande applicative `app:seed-rbac` (presente dans le build prod, donc
|
|
* rejouable en recette/prod, contrairement aux fixtures `require-dev`) ;
|
|
* - la fixture Core dev/test (DRY : meme seeder).
|
|
*
|
|
* Toutes les operations sont idempotentes et non destructives :
|
|
* - ensureRoles() : cree un role par lookup de code (skip si present) ;
|
|
* - attachMatrix() : attache les permissions § 2.7 via la M2M role_permission,
|
|
* sans re-attacher un lien existant ; STOP explicite si un code manque ;
|
|
* - ensureDemoUsers() : cree un user par role (lookup par username, skip si
|
|
* present), rattache au role + a >= 1 site.
|
|
*/
|
|
final class RbacSeeder
|
|
{
|
|
/**
|
|
* Codes des roles metier (snake_case, regex Role respectee). `commerciale`
|
|
* reference la constante Shared deja consommee par le ClientProcessor
|
|
* (RG-1.04) pour eviter tout drift : un seul litteral pour ce code.
|
|
*/
|
|
public const string ROLE_BUREAU = 'bureau';
|
|
public const string ROLE_COMPTA = 'compta';
|
|
public const string ROLE_COMMERCIALE = BusinessRoles::COMMERCIALE;
|
|
public const string ROLE_USINE = 'usine';
|
|
|
|
/** Site de rattachement par defaut des comptes demo (cf. SitesFixtures). */
|
|
private const string DEFAULT_SITE_NAME = 'Chatellerault';
|
|
|
|
/**
|
|
* Definition unique des 4 roles + matrice § 2.7. La cle est le code du role,
|
|
* `label` le libelle FR affichable, `permissions` la liste des codes RBAC a
|
|
* attacher (vide pour usine : aucun acces ; admin n'apparait pas car il
|
|
* bypass tout via isAdmin ; `commercial.clients.archive` n'est attache a
|
|
* aucun role metier — admin seul).
|
|
*
|
|
* @var array<string, array{label: string, permissions: list<string>}>
|
|
*/
|
|
private const array MATRIX = [
|
|
self::ROLE_BUREAU => [
|
|
'label' => 'Bureau',
|
|
'permissions' => [
|
|
'commercial.clients.view',
|
|
'commercial.clients.manage',
|
|
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
|
'catalog.categories.read_ref',
|
|
'sites.read_ref',
|
|
],
|
|
],
|
|
self::ROLE_COMPTA => [
|
|
'label' => 'Comptabilité',
|
|
'permissions' => [
|
|
'commercial.clients.view',
|
|
'commercial.clients.accounting.view',
|
|
'commercial.clients.accounting.manage',
|
|
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
|
'catalog.categories.read_ref',
|
|
'sites.read_ref',
|
|
],
|
|
],
|
|
self::ROLE_COMMERCIALE => [
|
|
'label' => 'Commerciale',
|
|
'permissions' => [
|
|
'commercial.clients.view',
|
|
'commercial.clients.manage',
|
|
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
|
'catalog.categories.read_ref',
|
|
'sites.read_ref',
|
|
],
|
|
],
|
|
self::ROLE_USINE => [
|
|
'label' => 'Usine',
|
|
'permissions' => [],
|
|
],
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly RoleRepositoryInterface $roleRepository,
|
|
private readonly PermissionRepositoryInterface $permissionRepository,
|
|
private readonly UserRepositoryInterface $userRepository,
|
|
private readonly SiteProviderInterface $siteProvider,
|
|
private readonly UserPasswordHasherInterface $passwordHasher,
|
|
) {}
|
|
|
|
/**
|
|
* Cree chaque role metier absent (lookup par code). Idempotent.
|
|
*
|
|
* @return list<string> codes des roles effectivement crees (vide au rejeu)
|
|
*/
|
|
public function ensureRoles(): array
|
|
{
|
|
$created = [];
|
|
|
|
foreach (self::MATRIX as $code => $definition) {
|
|
if (null !== $this->roleRepository->findByCode($code)) {
|
|
continue;
|
|
}
|
|
|
|
// isSystem=false : ce sont des roles metier, supprimables par un
|
|
// admin (contrairement aux roles systeme admin/user).
|
|
$this->roleRepository->save(new Role($code, $definition['label'], isSystem: false));
|
|
$created[] = $code;
|
|
}
|
|
|
|
return $created;
|
|
}
|
|
|
|
/**
|
|
* Attache la matrice § 2.7 a chaque role via la M2M role_permission. Lookup
|
|
* de la permission par code ; un code absent leve une RbacSeedException
|
|
* (garde-fou : `app:sync-permissions` doit avoir tourne). Idempotent : un
|
|
* lien deja present n'est pas recree.
|
|
*
|
|
* @return int nombre de liens role->permission effectivement ajoutes (0 au rejeu)
|
|
*
|
|
* @throws RbacSeedException si un role ou une permission de la matrice manque
|
|
*/
|
|
public function attachMatrix(): int
|
|
{
|
|
$added = 0;
|
|
|
|
foreach (self::MATRIX as $code => $definition) {
|
|
$role = $this->roleRepository->findByCode($code);
|
|
if (null === $role) {
|
|
throw RbacSeedException::missingRole($code);
|
|
}
|
|
|
|
$touched = false;
|
|
foreach ($definition['permissions'] as $permissionCode) {
|
|
$permission = $this->permissionRepository->findByCode($permissionCode);
|
|
if (null === $permission) {
|
|
throw RbacSeedException::missingPermission($permissionCode);
|
|
}
|
|
|
|
if (!$role->getPermissions()->contains($permission)) {
|
|
$role->addPermission($permission);
|
|
$touched = true;
|
|
++$added;
|
|
}
|
|
}
|
|
|
|
// Un seul flush par role, et seulement si un lien a change.
|
|
if ($touched) {
|
|
$this->roleRepository->save($role);
|
|
}
|
|
}
|
|
|
|
return $added;
|
|
}
|
|
|
|
/**
|
|
* Cree un compte demo par role metier (username = code du role), non-admin,
|
|
* mot de passe hashe, rattache a son role et a >= 1 site. Lookup par
|
|
* username : idempotent (un compte existant est laisse intact, mot de passe
|
|
* inchange).
|
|
*
|
|
* @return list<string> usernames effectivement crees (vide au rejeu)
|
|
*
|
|
* @throws RbacSeedException si un role metier attendu est absent (ensureRoles non joue)
|
|
*/
|
|
public function ensureDemoUsers(string $password): array
|
|
{
|
|
// Rattachement a un site par defaut s'il existe (les flux login / me en
|
|
// ont besoin ; le repertoire clients n'est pas site-scope mais on reste
|
|
// coherent avec les fixtures admin/alice/bob).
|
|
$defaultSite = $this->siteProvider->findByName(self::DEFAULT_SITE_NAME);
|
|
$created = [];
|
|
|
|
foreach (array_keys(self::MATRIX) as $code) {
|
|
$username = $code;
|
|
if (null !== $this->userRepository->findByUsername($username)) {
|
|
continue;
|
|
}
|
|
|
|
$role = $this->roleRepository->findByCode($code);
|
|
if (null === $role) {
|
|
throw RbacSeedException::missingRole($code);
|
|
}
|
|
|
|
$user = new User();
|
|
$user->setUsername($username);
|
|
$user->setIsAdmin(false);
|
|
$user->setPassword($this->passwordHasher->hashPassword($user, $password));
|
|
$user->addRbacRole($role);
|
|
|
|
if (null !== $defaultSite) {
|
|
$user->addSite($defaultSite);
|
|
$user->setCurrentSite($defaultSite);
|
|
}
|
|
|
|
$this->userRepository->save($user);
|
|
$created[] = $username;
|
|
}
|
|
|
|
return $created;
|
|
}
|
|
|
|
/**
|
|
* Liste des codes des roles metier definis (pour reporting / tests).
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
public static function roleCodes(): array
|
|
{
|
|
return array_keys(self::MATRIX);
|
|
}
|
|
}
|