[ERP-74] Seed RBAC idempotent (rôles + matrice § 2.7 + demo users) + RG-1.04 + test matrice (#40)
Auto Tag Develop / tag (push) Successful in 11s
Auto Tag Develop / tag (push) Successful in 11s
## Objectif
Seeder le RBAC métier de façon **rejouable et disponible en recette/prod** (commande applicative, pas fixture `require-dev`), durcir RG-1.04, et écrire le test de matrice (rôles enfin existants).
## A. `RbacSeeder` (Core) — source unique anti-drift
4 rôles (`bureau`/`compta`/`commerciale`/`usine`, isSystem=false), matrice § 2.7 (rôle → permissions) et comptes démo, définis en **un seul endroit**. Méthodes idempotentes `ensureRoles` / `attachMatrix` / `ensureDemoUsers`. `commerciale` référence `BusinessRoles::COMMERCIALE` (déjà consommé par RG-1.04).
## B. Commande `app:seed-rbac` (présente en build prod, idempotente, non destructive)
- Sans option : rôles + matrice § 2.7.
- `--with-demo-users` + `--password=<…>` ou env `RBAC_DEMO_PASSWORD` : 1 compte démo/rôle. **Aucun mot de passe en dur** côté serveur.
- Garde-fou : exit non-zéro + invite à lancer `app:sync-permissions` si les codes `commercial.clients.*` manquent.
## C. Fixture dev/test `RbacDemoFixtures` (DRY)
Appelle le **même seeder** (`ensureRoles` + `ensureDemoUsers`). La matrice est attachée juste après par l'étape `app:seed-rbac` du makefile (la table `permission` est purgée au moment du `fixtures:load`, donc `attachMatrix` ne peut pas tourner pendant le load). `make db-reset` / `test-db-setup` reproduisent l'état de recette.
## Déploiement (documenté README)
Après `migration-migrate` + `app:sync-permissions` : `app:seed-rbac` (prod) ; `app:seed-rbac --with-demo-users --password=…` (recette).
## D. Durcissement RG-1.04
Pour une Commerciale, complétude de l'onglet Information exigée sur **POST + tout PATCH** (suppression de la condition d'intersection). Conséquence : POST Commerciale → 422 (le POST n'expose pas le groupe Information), Admin → 201. Spec § 7 amendée.
## Compta ↔ onglet Comptabilité (§ 2.7)
Pour que `compta PATCH accounting → 200` (exigé par la matrice), la security du `Patch /clients/{id}` est élargie à `manage` **OU** `accounting.manage`, et un nouveau **`guardManage`** (mode strict RG-1.28) interdit à un porteur non-`manage` de modifier les onglets principal/Information (→ 403). Approche validée : élargir la security + guard in-processor (pas de nouvel endpoint).
## E. `ClientRBACMatrixTest`
Matrice § 2.7 complète via les comptes démo seedés (`app:seed-rbac --with-demo-users`) : bureau / compta / commerciale / usine (200/403 par verbe et par onglet) + RG-1.04 (POST Commerciale 422 / Admin 201).
## Tests
`make php-cs-fixer-allow-risky` OK ; `make test` **429 tests verts**. Idempotence vérifiée (rejeu de la commande : 0 rôle / 0 lien / 0 user). `test-db-setup` exécute la nouvelle étape `app:seed-rbac` sans erreur.
Cible : `develop`. Squash merge.
---------
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #40
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #40.
This commit is contained in:
+8
-10
@@ -10,17 +10,15 @@ use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Validator metier RG-1.04 : pour un utilisateur portant le role metier
|
||||
* Commerciale, TOUS les champs de l'onglet Information deviennent obligatoires
|
||||
* lors d'un PATCH touchant le groupe `client:write:information`.
|
||||
* Validator metier RG-1.04 (durcie ERP-74) : pour un utilisateur portant le
|
||||
* role metier Commerciale, TOUS les champs de l'onglet Information sont
|
||||
* obligatoires sur POST comme sur tout PATCH, independamment des champs
|
||||
* reellement envoyes.
|
||||
*
|
||||
* Invoque par le ClientProcessor UNIQUEMENT quand les deux conditions sont
|
||||
* reunies (role Commerciale + payload touchant l'onglet Information). Pour les
|
||||
* autres roles, ces champs restent optionnels — le validator n'est pas appele.
|
||||
*
|
||||
* Tant qu'aucun user ne porte le role `commerciale` (seede par ERP-74,
|
||||
* cf. App\Shared\Domain\Security\BusinessRoles::COMMERCIALE), cette regle reste
|
||||
* DORMANTE : aucun appelant ne la declenche.
|
||||
* Invoque par le ClientProcessor des que l'utilisateur courant porte le role
|
||||
* Commerciale (plus de condition d'intersection avec l'onglet Information).
|
||||
* Pour les autres roles, ces champs restent optionnels — le validator n'est
|
||||
* pas appele.
|
||||
*
|
||||
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
|
||||
* coherence avec les violations Symfony rendues par API Platform.
|
||||
|
||||
@@ -83,11 +83,19 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
processor: ClientProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('commercial.clients.manage')",
|
||||
// Security elargie (ERP-74) : `manage` OU `accounting.manage`. Le
|
||||
// role Compta n'a pas `manage` mais doit pouvoir editer l'onglet
|
||||
// Comptabilite d'un client existant (§ 2.7). Le ClientProcessor
|
||||
// re-gate ensuite onglet par onglet :
|
||||
// - champs accounting -> accounting.manage (guardAccounting, RG-1.28) ;
|
||||
// - champs main/information -> manage (guardManage : empeche Compta
|
||||
// d'editer les autres onglets) ;
|
||||
// - isArchived -> archive (guardArchive, RG-1.22).
|
||||
security: "is_granted('commercial.clients.manage') or is_granted('commercial.clients.accounting.manage')",
|
||||
// Le ClientProcessor inspecte les champs reellement envoyes pour
|
||||
// autoriser/refuser onglet par onglet (RG-1.22 / RG-1.28) : les
|
||||
// champs accounting exigent accounting.manage, isArchived exige
|
||||
// archive.
|
||||
// archive, le reste (main/information) exige manage.
|
||||
normalizationContext: ['groups' => ['client:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => [
|
||||
'client:write:main',
|
||||
|
||||
+196
-15
@@ -16,6 +16,7 @@ use App\Shared\Domain\Security\BusinessRoles;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\PersistentCollection;
|
||||
use JsonException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
@@ -31,16 +32,19 @@ use Symfony\Component\Validator\ConstraintViolationList;
|
||||
* § 2.9 / § 4.3 / § 4.4 + RG-1.01 a RG-1.28.
|
||||
*
|
||||
* Sequence (POST / PATCH) :
|
||||
* 1. Autorisation additionnelle par groupe d'onglet (le `security` de
|
||||
* l'operation a deja exige commercial.clients.manage) :
|
||||
* - champ comptable dans le payload -> exige accounting.manage (RG-1.28, 403) ;
|
||||
* 1. Autorisation additionnelle par groupe d'onglet. La security d'operation
|
||||
* du PATCH a ete elargie (ERP-74) a `manage` OU `accounting.manage` pour
|
||||
* laisser entrer le role Compta ; ce processor re-gate alors finement :
|
||||
* - champ comptable modifie dans le payload -> exige accounting.manage (RG-1.28, 403) ;
|
||||
* - champ main/information modifie -> exige manage (guardManage, 403) : empeche
|
||||
* Compta d'editer un autre onglet que la Comptabilite (§ 2.7) ;
|
||||
* - champ isArchived dans le payload -> exige archive (RG-1.22, 403) et
|
||||
* interdit toute autre modification dans la meme requete (RG-1.22, 422).
|
||||
* 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer.
|
||||
* 3. Regles metier : RG-1.01 (prenom/nom), RG-1.03 (distributor/broker
|
||||
* exclusifs + type de categorie), RG-1.12 (Virement -> banque),
|
||||
* RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information pour le role
|
||||
* Commerciale).
|
||||
* RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information exigee sur POST
|
||||
* et tout PATCH pour le role Commerciale).
|
||||
* 4. Pose / retrait de archivedAt (RG-1.22 true=now, RG-1.23 false=null).
|
||||
* 5. Persistance via le persist_processor Doctrine, avec traduction des
|
||||
* collisions d'unicite en 409 (RG-1.16 doublon de nom ; RG-1.23 conflit de
|
||||
@@ -75,9 +79,23 @@ final class ClientProcessor implements ProcessorInterface
|
||||
/** Champ d'archivage (groupe client:write:archive). */
|
||||
private const string ARCHIVE_FIELD = 'isArchived';
|
||||
|
||||
private const string PERM_MANAGE = 'commercial.clients.manage';
|
||||
private const string PERM_ACCOUNTING_MANAGE = 'commercial.clients.accounting.manage';
|
||||
private const string PERM_ARCHIVE = 'commercial.clients.archive';
|
||||
|
||||
/**
|
||||
* Memoisation du dernier corps de requete decode, clos par le contenu brut.
|
||||
* payloadKeys() est appele plusieurs fois par requete (writablePayloadKeys,
|
||||
* categoriesChanged...) : on evite de rejouer json_decode a chaque appel. La
|
||||
* cle etant le contenu lui-meme et le calcul une fonction pure de ce contenu,
|
||||
* aucune fuite n'est possible entre requetes sur ce service partage (un meme
|
||||
* corps redonne les memes cles).
|
||||
*/
|
||||
private ?string $decodedContent = null;
|
||||
|
||||
/** @var list<string> Cles de premier niveau correspondant au corps memoise. */
|
||||
private array $decodedPayloadKeys = [];
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
@@ -101,10 +119,15 @@ final class ClientProcessor implements ProcessorInterface
|
||||
|
||||
$this->normalize($data);
|
||||
|
||||
// guardManage apres normalize : la comparaison « change vs etat
|
||||
// persiste » des champs texte (companyName, email...) se fait sur des
|
||||
// valeurs normalisees des deux cotes (l'etat persiste l'a deja ete).
|
||||
$this->guardManage($data);
|
||||
|
||||
$this->validateMainContact($data);
|
||||
$this->validateDistributorBroker($data);
|
||||
$this->validateAccountingConsistency($data);
|
||||
$this->validateInformationCompleteness($data, $writableKeys);
|
||||
$this->validateInformationCompleteness($data);
|
||||
|
||||
try {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
@@ -199,6 +222,145 @@ final class ClientProcessor implements ProcessorInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* § 2.7 / RG-1.28 (ERP-74) : la modification effective d'un champ « metier »
|
||||
* (onglets principal ou Information) exige `commercial.clients.manage`. Sans
|
||||
* cette permission -> 403 sur l'ensemble du payload (mode strict, miroir de
|
||||
* guardAccounting). C'est ce qui empeche le role Compta — qui entre dans le
|
||||
* PATCH via `accounting.manage` (security d'operation elargie) — d'editer
|
||||
* autre chose que l'onglet Comptabilite.
|
||||
*
|
||||
* Ne s'applique qu'aux mises a jour (entite geree) : la creation (POST) est
|
||||
* deja gardee par la security d'operation `manage`, donc inutile de la
|
||||
* re-gater ici (et un POST par un porteur de `manage` passerait de toute
|
||||
* facon).
|
||||
*/
|
||||
private function guardManage(Client $data): void
|
||||
{
|
||||
if (!$this->em->contains($data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$changed = $this->changedBusinessFields($data);
|
||||
|
||||
if ([] === $changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted(self::PERM_MANAGE)) {
|
||||
throw new AccessDeniedHttpException(sprintf(
|
||||
'Le champ "%s" requiert la permission "%s".',
|
||||
$changed[0],
|
||||
self::PERM_MANAGE,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Champs « metier » (onglets principal + Information, hors comptabilite et
|
||||
* archivage) dont la valeur courante differe de l'etat persiste. Memes
|
||||
* regles de comparaison que changedAccountingFields (scalaires par valeur,
|
||||
* relations ManyToOne distributor/broker par identite via l'identity map).
|
||||
*
|
||||
* Cas particulier `categories` (M2M) : non trace par getOriginalEntityData,
|
||||
* compare par valeur via le snapshot de la PersistentCollection (cf.
|
||||
* categoriesChanged) — la simple presence dans le payload ne suffit pas, sous
|
||||
* peine de 403 parasite sur un PATCH representation complete reincluant des
|
||||
* categories inchangees.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function changedBusinessFields(Client $data): array
|
||||
{
|
||||
$newValues = [
|
||||
'companyName' => $data->getCompanyName(),
|
||||
'firstName' => $data->getFirstName(),
|
||||
'lastName' => $data->getLastName(),
|
||||
'phonePrimary' => $data->getPhonePrimary(),
|
||||
'phoneSecondary' => $data->getPhoneSecondary(),
|
||||
'email' => $data->getEmail(),
|
||||
'distributor' => $data->getDistributor(),
|
||||
'broker' => $data->getBroker(),
|
||||
'triageService' => $data->isTriageService(),
|
||||
'description' => $data->getDescription(),
|
||||
'competitors' => $data->getCompetitors(),
|
||||
'foundedAt' => $data->getFoundedAt(),
|
||||
'employeesCount' => $data->getEmployeesCount(),
|
||||
'revenueAmount' => $data->getRevenueAmount(),
|
||||
'directorName' => $data->getDirectorName(),
|
||||
'profitAmount' => $data->getProfitAmount(),
|
||||
];
|
||||
|
||||
$changed = [];
|
||||
foreach ($newValues as $field => $newValue) {
|
||||
if ($this->fieldChanged($data, $field, $newValue)) {
|
||||
$changed[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->categoriesChanged($data)) {
|
||||
$changed[] = 'categories';
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si l'ensemble des categories (M2M) differe reellement de l'etat
|
||||
* persiste. La collection n'etant pas tracee par getOriginalEntityData, on
|
||||
* compare par identifiants (independamment de l'ordre) le snapshot de la
|
||||
* PersistentCollection (etat charge depuis la base) a l'etat courant (apres
|
||||
* application du payload). Symetrique de changedAccountingFields : seul un
|
||||
* changement effectif compte, pas la simple presence dans le payload.
|
||||
*
|
||||
* - POST / entite non geree : fournir des categories est un acte metier
|
||||
* (comportement historique conserve) — branche defensive, guardManage ne
|
||||
* s'execute de toute facon que sur entite geree.
|
||||
* - categories absent du payload (PATCH partiel) : aucun changement.
|
||||
*/
|
||||
private function categoriesChanged(Client $data): bool
|
||||
{
|
||||
if (!$this->em->contains($data)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!in_array('categories', $this->payloadKeys(), true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$collection = $data->getCategories();
|
||||
|
||||
// Hors PersistentCollection (cas limite hors flux PATCH reel) : faute
|
||||
// d'etat persiste comparable, on se rabat sur la presence payload.
|
||||
if (!$collection instanceof PersistentCollection) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->categoryIdSet($collection->toArray())
|
||||
!== $this->categoryIdSet($collection->getSnapshot());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensemble trie des identifiants d'une liste de categories — pour une
|
||||
* comparaison par valeur independante de l'ordre.
|
||||
*
|
||||
* @param array<int, object> $categories
|
||||
*
|
||||
* @return list<mixed>
|
||||
*/
|
||||
private function categoryIdSet(array $categories): array
|
||||
{
|
||||
$ids = array_map(
|
||||
static fn (object $category): mixed => method_exists($category, 'getId')
|
||||
? $category->getId()
|
||||
: spl_object_id($category),
|
||||
array_values($categories),
|
||||
);
|
||||
sort($ids);
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Champs comptables dont la valeur courante differe de l'etat persiste. Les
|
||||
* relations (tvaMode, paymentDelay, paymentType, bank) sont comparees par
|
||||
@@ -353,17 +515,16 @@ final class ClientProcessor implements ProcessorInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.04 : si l'utilisateur porte le role metier Commerciale ET que le
|
||||
* payload touche l'onglet Information, tous les champs Information sont
|
||||
* obligatoires. Dormant tant qu'aucun user ne porte le role `commerciale`.
|
||||
*
|
||||
* @param list<string> $payloadKeys
|
||||
* RG-1.04 (durcie ERP-74) : si l'utilisateur porte le role metier
|
||||
* Commerciale, TOUS les champs de l'onglet Information sont obligatoires sur
|
||||
* POST comme sur TOUT PATCH — independamment des champs reellement envoyes
|
||||
* (plus de condition d'intersection avec INFORMATION_FIELDS). Garantit qu'un
|
||||
* client cree/edite par une Commerciale ne reste jamais avec un onglet
|
||||
* Information incomplet.
|
||||
*/
|
||||
private function validateInformationCompleteness(Client $data, array $payloadKeys): void
|
||||
private function validateInformationCompleteness(Client $data): void
|
||||
{
|
||||
$touchesInformation = [] !== array_intersect($payloadKeys, self::INFORMATION_FIELDS);
|
||||
|
||||
if ($touchesInformation && $this->currentUserIsCommerciale()) {
|
||||
if ($this->currentUserIsCommerciale()) {
|
||||
$this->informationValidator->validate($data);
|
||||
}
|
||||
}
|
||||
@@ -428,6 +589,26 @@ final class ClientProcessor implements ProcessorInterface
|
||||
}
|
||||
|
||||
$content = $request->getContent();
|
||||
|
||||
// Cache hit : meme corps brut que le dernier decodage -> memes cles.
|
||||
if ($content === $this->decodedContent) {
|
||||
return $this->decodedPayloadKeys;
|
||||
}
|
||||
|
||||
$this->decodedContent = $content;
|
||||
$this->decodedPayloadKeys = $this->extractPayloadKeys($content);
|
||||
|
||||
return $this->decodedPayloadKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode le corps brut et en extrait les cles de premier niveau (chaines).
|
||||
* Corps vide ou JSON invalide -> aucune cle.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractPayloadKeys(string $content): array
|
||||
{
|
||||
if ('' === $content) {
|
||||
return [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user