fix(technique) : cloisonner par site les sous-ressources prestataire + RG-3.04 fonction (ERP-134, ERP-135)

Les operations Get/Patch/Delete des sous-ressources Contact/Adresse/RIB
passaient par le provider Doctrine par defaut (non cloisonne), et le POST
resolvait le parent sans controle de scope : un user cloisonne pouvait
lire/editer/supprimer une sous-ressource d'un prestataire hors de son site
(IBAN/BIC du RIB inclus). SiteScopedQueryExtension ne filtre que les
SiteAwareInterface, que ces entites ne sont pas.

- ProviderSiteScopeChecker : decision de cloisonnement centralisee (source
  unique), consommee par ProviderProvider (refactore), le provider decore
  et les processors.
- ProviderSubResourceItemProvider : decore le provider par defaut sur
  Get/Patch/Delete des 3 sous-ressources -> 404 si parent hors perimetre.
- Garde assertInScope au POST dans les 3 processors -> 404 si parent hors
  perimetre. ProviderOwnedInterface sur les 3 entites.

RG-3.04 : alignement code <-> spec (ligne 926). La Fonction (jobTitle) rend
desormais un contact valide a elle seule : ajout au validateName, au CHECK
chk_provider_contact_name et normalisation (normalizeText, vide -> null).

Tests : ProviderSubResourceSiteScopeTest (fuite cross-site, 7 cas) ;
RG-3.04 jobTitle reecrit. Spec § 2.13 corrigee (l'heritage n'etait pas
automatique). Suite back complete verte (685 tests).
This commit is contained in:
Matthieu
2026-06-12 16:12:00 +02:00
parent d6ed4f5faf
commit 8b6b4f2dbb
15 changed files with 410 additions and 61 deletions
@@ -65,6 +65,23 @@ final class ProviderFieldNormalizer
return '' === $value ? null : mb_strtolower($value, 'UTF-8');
}
/**
* Texte libre simplement trim (ex : jobTitle / Fonction du contact). Pas de
* changement de casse — on preserve la saisie. Une chaine vide apres trim
* devient null (evite de persister "" et de faire passer a tort le garde-fou
* RG-3.04 / le CHECK chk_provider_contact_name sur une Fonction vide).
*/
public function normalizeText(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : $value;
}
/**
* Telephone reduit aux chiffres (RG-3.11) : "06.12.34.56.78" ->
* "0612345678". Une valeur sans aucun chiffre devient null.
@@ -11,6 +11,7 @@ use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderAddressProcessor;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
@@ -60,6 +61,8 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
// site:read + category:read : embarquent les Site / Category lies
// (maillon (c)) plutot que des IRI nus dans le retour.
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
provider: ProviderSubResourceItemProvider::class,
),
new Post(
uriTemplate: '/providers/{providerId}/addresses',
@@ -80,10 +83,12 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
denormalizationContext: ['groups' => ['provider:write:addresses']],
provider: ProviderSubResourceItemProvider::class,
processor: ProviderAddressProcessor::class,
),
new Delete(
security: "is_granted('technique.providers.manage')",
provider: ProviderSubResourceItemProvider::class,
processor: ProviderAddressProcessor::class,
),
],
@@ -92,7 +97,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Table(name: 'provider_address')]
#[ORM\Index(name: 'idx_provider_address_provider', columns: ['provider_id'])]
#[Auditable]
class ProviderAddress implements TimestampableInterface, BlamableInterface
class ProviderAddress implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
{
use TimestampableBlamableTrait;
@@ -11,6 +11,7 @@ use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderContactProcessor;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
@@ -47,6 +48,8 @@ use Symfony\Component\Validator\Constraints as Assert;
new Get(
security: "is_granted('technique.providers.view')",
normalizationContext: ['groups' => ['provider:item:read']],
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
provider: ProviderSubResourceItemProvider::class,
),
new Post(
uriTemplate: '/providers/{providerId}/contacts',
@@ -67,10 +70,12 @@ use Symfony\Component\Validator\Constraints as Assert;
security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:item:read']],
denormalizationContext: ['groups' => ['provider:write:contacts']],
provider: ProviderSubResourceItemProvider::class,
processor: ProviderContactProcessor::class,
),
new Delete(
security: "is_granted('technique.providers.manage')",
provider: ProviderSubResourceItemProvider::class,
processor: ProviderContactProcessor::class,
),
],
@@ -79,7 +84,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'provider_contact')]
#[ORM\Index(name: 'idx_provider_contact_provider', columns: ['provider_id'])]
#[Auditable]
class ProviderContact implements TimestampableInterface, BlamableInterface
class ProviderContact implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
{
use TimestampableBlamableTrait;
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Domain\Entity;
/**
* Contrat des sous-ressources d'un prestataire (Contact, Adresse, RIB) : chacune
* appartient a un Provider parent. Permet au provider decore
* ProviderSubResourceItemProvider d'appliquer le cloisonnement par site du parent
* (§ 2.13 / RG-3.17) de maniere uniforme, sans connaitre le type concret.
*/
interface ProviderOwnedInterface
{
public function getProvider(): ?Provider;
}
@@ -11,6 +11,7 @@ use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderRibProcessor;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
@@ -49,6 +50,8 @@ use Symfony\Component\Validator\Constraints as Assert;
new Get(
security: "is_granted('technique.providers.accounting.view')",
normalizationContext: ['groups' => ['provider:read:accounting']],
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
provider: ProviderSubResourceItemProvider::class,
),
new Post(
uriTemplate: '/providers/{providerId}/ribs',
@@ -69,10 +72,12 @@ use Symfony\Component\Validator\Constraints as Assert;
security: "is_granted('technique.providers.accounting.manage')",
normalizationContext: ['groups' => ['provider:read:accounting']],
denormalizationContext: ['groups' => ['provider:write:accounting']],
provider: ProviderSubResourceItemProvider::class,
processor: ProviderRibProcessor::class,
),
new Delete(
security: "is_granted('technique.providers.accounting.manage')",
provider: ProviderSubResourceItemProvider::class,
processor: ProviderRibProcessor::class,
),
],
@@ -81,7 +86,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'provider_rib')]
#[ORM\Index(name: 'idx_provider_rib_provider', columns: ['provider_id'])]
#[Auditable]
class ProviderRib implements TimestampableInterface, BlamableInterface
class ProviderRib implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
{
use TimestampableBlamableTrait;
@@ -11,6 +11,7 @@ use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Core\Domain\Entity\User;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderAddress;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use App\Shared\Domain\Contract\SiteInterface;
use Doctrine\ORM\EntityManagerInterface;
use JsonException;
@@ -53,6 +54,7 @@ final class ProviderAddressProcessor implements ProcessorInterface
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em,
private readonly ProviderSiteScopeChecker $scopeChecker,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
@@ -98,6 +100,12 @@ final class ProviderAddressProcessor implements ProcessorInterface
throw new NotFoundHttpException('Prestataire introuvable.');
}
// Cloisonnement par site (§ 2.13 / RG-3.17) : interdiction de creer une
// sous-ressource sur un prestataire hors du perimetre de l'user -> 404
// (anti-enumeration). Distinct du guardSiteScope ci-dessous, qui cloisonne
// les sites ATTACHES a l'adresse (et non l'acces au prestataire parent).
$this->scopeChecker->assertInScope($provider);
$address->setProvider($provider);
}
@@ -11,6 +11,7 @@ use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderContact;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -46,6 +47,7 @@ final class ProviderContactProcessor implements ProcessorInterface
private readonly ProcessorInterface $removeProcessor,
private readonly ProviderFieldNormalizer $normalizer,
private readonly EntityManagerInterface $em,
private readonly ProviderSiteScopeChecker $scopeChecker,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
@@ -94,6 +96,11 @@ final class ProviderContactProcessor implements ProcessorInterface
throw new NotFoundHttpException('Prestataire introuvable.');
}
// Cloisonnement par site (§ 2.13 / RG-3.17) : interdiction de creer une
// sous-ressource sur un prestataire hors du perimetre de l'user -> 404
// (anti-enumeration, coherent avec le detail Provider garde en 404).
$this->scopeChecker->assertInScope($provider);
$contact->setProvider($provider);
}
@@ -105,6 +112,7 @@ final class ProviderContactProcessor implements ProcessorInterface
{
$contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
$contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
$contact->setJobTitle($this->normalizer->normalizeText($contact->getJobTitle()));
$contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary()));
$contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary()));
$contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail()));
@@ -112,21 +120,22 @@ final class ProviderContactProcessor implements ProcessorInterface
/**
* RG-3.04 : un bloc Contact est valide des qu'au moins un champ parmi prenom /
* nom / telephone principal / email est renseigne (double garde avec le CHECK
* BDD chk_provider_contact_name — leve une 422 propre rattachee au champ
* `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
* chaines vides (y compris un phone_secondary seul, hors CHECK) sont deja
* nom / fonction / telephone principal / email est renseigne (double garde avec
* le CHECK BDD chk_provider_contact_name — leve une 422 propre rattachee au
* champ `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
* chaines vides (y compris une fonction ou un phone_secondary vides) sont deja
* ramenees a null et ne suffisent pas a valider le bloc.
*/
private function validateName(ProviderContact $contact): void
{
if (null === $contact->getFirstName()
&& null === $contact->getLastName()
&& null === $contact->getJobTitle()
&& null === $contact->getPhonePrimary()
&& null === $contact->getEmail()) {
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Au moins un champ du contact est obligatoire (nom, prénom, téléphone ou email).',
'Au moins un champ du contact est obligatoire (nom, prénom, fonction, téléphone ou email).',
null,
[],
$contact,
@@ -9,6 +9,7 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderRib;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
@@ -42,6 +43,7 @@ final class ProviderRibProcessor implements ProcessorInterface
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly EntityManagerInterface $em,
private readonly ProviderSiteScopeChecker $scopeChecker,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
@@ -88,6 +90,11 @@ final class ProviderRibProcessor implements ProcessorInterface
throw new NotFoundHttpException('Prestataire introuvable.');
}
// Cloisonnement par site (§ 2.13 / RG-3.17) : interdiction de creer une
// sous-ressource sur un prestataire hors du perimetre de l'user -> 404
// (anti-enumeration, coherent avec le detail Provider garde en 404).
$this->scopeChecker->assertInScope($provider);
$rib->setProvider($provider);
}
@@ -9,12 +9,10 @@ use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
@@ -64,12 +62,11 @@ final class ProviderProvider implements ProviderInterface
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
private readonly ProviderRepositoryInterface $repository,
private readonly Pagination $pagination,
private readonly Security $security,
// Outillage site-aware sanctionne (site-aware.md § 6.2 : « injecter
// CurrentSiteProvider dans le service et ajouter la clause WHERE
// manuellement » pour les cas multi-site non couverts par
// SiteScopedQueryExtension). Type-hint sur l'interface pour le mock test.
private readonly CurrentSiteProviderInterface $currentSiteProvider,
// Decision de cloisonnement par site centralisee (site-aware.md § 6.2) :
// source UNIQUE partagee avec le provider decore des sous-ressources
// (ProviderSubResourceItemProvider) et les processors d'ecriture, pour
// eviter tout drift entre ces points d'application.
private readonly ProviderSiteScopeChecker $scopeChecker,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Provider|null
@@ -109,7 +106,7 @@ final class ProviderProvider implements ProviderInterface
// Cloisonnement par site (RG-3.17) AVANT pagination : ajoute une clause
// restreignant au currentSite pour un user non-bypass. S'intersecte avec
// un eventuel filtre ?siteId du client (deux sous-requetes ANDees).
$scopeSite = $this->siteScopeOrNull();
$scopeSite = $this->scopeChecker->siteScopeOrNull();
if (null !== $scopeSite) {
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
}
@@ -164,44 +161,14 @@ final class ProviderProvider implements ProviderInterface
// Cloisonnement par site (RG-3.17) : un prestataire hors du perimetre de
// l'user -> 404 (ne pas reveler son existence). No-op pour bypass_scope ou
// currentSite null.
$scopeSite = $this->siteScopeOrNull();
if (null !== $scopeSite && !$this->providerHasSite($provider, (int) $scopeSite->getId())) {
// currentSite null (delegue au ProviderSiteScopeChecker).
if (!$this->scopeChecker->isInScope($provider)) {
return null;
}
return $provider;
}
/**
* Site de cloisonnement a appliquer en LECTURE, ou null si aucun cloisonnement
* (user `sites.bypass_scope`, ou pas de site courant resolu — module Sites off
* / user sans currentSite, aligne site-aware.md § 5).
*/
private function siteScopeOrNull(): ?SiteInterface
{
if ($this->security->isGranted('sites.bypass_scope')) {
return null;
}
return $this->currentSiteProvider->get();
}
/**
* Vrai si le prestataire est rattache (relation directe provider.sites) au
* site d'id donne. Comparaison en memoire sur l'entite deja chargee (detail).
*/
private function providerHasSite(Provider $provider, int $siteId): bool
{
foreach ($provider->getSites() as $site) {
if ($site instanceof SiteInterface && $site->getId() === $siteId) {
return true;
}
}
return false;
}
/**
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
*/
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Technique\Domain\Entity\ProviderOwnedInterface;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider d'item des sous-ressources d'un prestataire (Contact / Adresse / RIB).
* Decore le provider Doctrine par defaut et applique le cloisonnement par site du
* PARENT (§ 2.13 / RG-3.17) sur Get / Patch / Delete.
*
* Sans ce garde, un user cloisonne pourrait lire / editer / supprimer une
* sous-ressource d'un prestataire hors de son site : le detail Provider est bien
* garde en 404 (ProviderProvider), mais les sous-ressources ne passent pas par lui
* (provider Doctrine par defaut, et SiteScopedQueryExtension ne filtre que les
* resources SiteAwareInterface — ce que ces entites ne sont pas). Le RIB est
* particulierement sensible (IBAN / BIC).
*
* Hors perimetre -> retour null -> 404 (anti-enumeration, coherent avec le detail
* Provider). La decision de scope est deleguee a ProviderSiteScopeChecker (source
* unique partagee avec le ProviderProvider et les processors).
*
* @implements ProviderInterface<ProviderOwnedInterface>
*/
final class ProviderSubResourceItemProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.item_provider')]
private readonly ProviderInterface $itemProvider,
private readonly ProviderSiteScopeChecker $scopeChecker,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object
{
$entity = $this->itemProvider->provide($operation, $uriVariables, $context);
if ($entity instanceof ProviderOwnedInterface) {
$parent = $entity->getProvider();
if (null === $parent || !$this->scopeChecker->isInScope($parent)) {
return null;
}
}
return $entity;
}
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\Security;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Technique\Domain\Entity\Provider;
use App\Shared\Domain\Contract\SiteInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Decision centralisee du cloisonnement par site des prestataires (§ 2.13 /
* RG-3.17). Source UNIQUE partagee par le ProviderProvider (liste + detail), le
* provider decore des sous-ressources (ProviderSubResourceItemProvider) et les
* processors d'ecriture des sous-ressources — afin d'eviter tout drift entre ces
* points d'application.
*
* Regle : un user SANS `sites.bypass_scope` ET avec un site courant ne voit /
* n'opere que sur les prestataires rattaches (relation directe provider.sites) a
* son site courant. `bypass_scope` (Admin inclus via isAdmin) ou absence de site
* courant (module Sites off / user sans currentSite) -> aucun cloisonnement
* (no-op, aligne site-aware.md § 5).
*/
final class ProviderSiteScopeChecker
{
public function __construct(
private readonly Security $security,
private readonly CurrentSiteProviderInterface $currentSiteProvider,
) {}
/**
* Site de cloisonnement a appliquer, ou null si aucun cloisonnement
* (`bypass_scope`, ou pas de site courant resolu).
*/
public function siteScopeOrNull(): ?SiteInterface
{
if ($this->security->isGranted('sites.bypass_scope')) {
return null;
}
return $this->currentSiteProvider->get();
}
/**
* Vrai si le prestataire est dans le perimetre site de l'user courant — ou si
* aucun cloisonnement ne s'applique.
*/
public function isInScope(Provider $provider): bool
{
$scopeSite = $this->siteScopeOrNull();
if (null === $scopeSite) {
return true;
}
return $this->providerHasSite($provider, (int) $scopeSite->getId());
}
/**
* Leve un 404 si le prestataire est hors perimetre (anti-enumeration : ne pas
* reveler l'existence d'une ligne hors site). No-op si dans le perimetre.
*/
public function assertInScope(Provider $provider): void
{
if (!$this->isInScope($provider)) {
throw new NotFoundHttpException('Prestataire introuvable.');
}
}
/**
* Vrai si le prestataire est rattache (relation directe provider.sites) au site
* d'id donne. Comparaison en memoire sur l'entite deja chargee.
*/
private function providerHasSite(Provider $provider, int $siteId): bool
{
foreach ($provider->getSites() as $site) {
if ($site instanceof SiteInterface && $site->getId() === $siteId) {
return true;
}
}
return false;
}
}