diff --git a/docs/specs/M3-prestataires/spec-back.md b/docs/specs/M3-prestataires/spec-back.md index 54a573a..7d9825c 100644 --- a/docs/specs/M3-prestataires/spec-back.md +++ b/docs/specs/M3-prestataires/spec-back.md @@ -176,7 +176,7 @@ Anti-N+1 (le code fera foi) : le `DoctrineProviderRepository` ne fetch-joine PAS - **Filtre LISTE** (`ProviderProvider` ou query extension dédiée `ProviderSiteScopeExtension`) : si l'user **n'a pas** `sites.bypass_scope` ET que `CurrentSiteProvider::get()` retourne un site → ne renvoyer que les prestataires dont `provider.sites` **contient** le `currentSite` (jointure `provider_site` + `WHERE site = :currentSite`). Si l'user a `bypass_scope` (Admin, profils consolidation) → aucun filtre (tous sites). Si `currentSite = null` (mode dégradé / module Sites off) → aligné `site-aware.md § 5` (no-op lecture, à documenter). - **Filtre DÉTAIL** (`Get`) : un user sans `bypass_scope` qui demande un prestataire **hors de son site courant** → **404** (cohérence : ne pas révéler l'existence d'une ligne hors périmètre). - **Écriture (décision Matthieu, 11/06)** : un user **sans** `bypass_scope` ne peut attacher **que les sites dont il dispose** (ses `user_site`) — sur le formulaire principal (`provider.sites`, RG-3.03) **comme** sur chaque adresse (`provider_address.sites`, RG-3.05). Tout site hors de ses `user_site` dans le payload → **422** sur `sites`. Un user `bypass_scope` (Admin) peut attacher n'importe quel site. Garde porté par le `ProviderProcessor` (POST + PATCH + sous-ressource adresses). -- **Cohérence sous-ressources** (`/providers/{id}/...`) : le détail étant déjà gardé en 404 hors périmètre, les sous-ressources héritent du garde-fou parent (cf. `site-aware.md § 6.1`). +- **Cohérence sous-ressources** (Contacts / Adresses / RIB) : le cloisonnement du parent **n'est PAS hérité automatiquement** — les opérations `Get` / `Patch` / `Delete` des sous-ressources passent par le provider Doctrine par défaut (et `SiteScopedQueryExtension` ne filtre que les `SiteAwareInterface`, ce que ces entités ne sont pas). Le garde-fou est donc posé **explicitement** : (a) en lecture/édition/suppression via le provider décoré `ProviderSubResourceItemProvider` (un parent hors périmètre → **404**) ; (b) en création (`POST /providers/{id}/...`) via `ProviderSiteScopeChecker::assertInScope` dans chaque processor (parent hors périmètre → **404**). La décision de scope est centralisée dans `ProviderSiteScopeChecker` (source unique partagée avec `ProviderProvider`). ⚠️ Ne pas retirer ces gardes en les croyant redondants : sans eux, un user cloisonné peut lire l'IBAN/BIC d'un RIB d'un autre site. > **Conséquence RBAC** : la colonne « Consultation » du docx (« Tout » vs « son site uniquement ») se réalise **par `sites.bypass_scope`**, pas par le code de rôle. Décision d'attribution par défaut (à acter au ticket RBAC) : `bypass_scope` aux profils Admin (auto) + Bureau + Compta + Commerciale (ils voient « Tout » d'après le docx) ; **Usine ne l'a pas** → cloisonné à son site. Si MALIO préfère que Bureau/Commerciale soient aussi cloisonnés, il suffit de ne pas leur donner `bypass_scope` — **aucun code à changer** (c'est l'intérêt de piloter par user/permission et non par rôle). diff --git a/migrations/Version20260612100000.php b/migrations/Version20260612100000.php index 9ee3aec..16eacd8 100644 --- a/migrations/Version20260612100000.php +++ b/migrations/Version20260612100000.php @@ -252,7 +252,7 @@ final class Version20260612100000 extends AbstractMigration updated_by INT DEFAULT NULL, PRIMARY KEY (id), CONSTRAINT chk_provider_contact_name - CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL), + CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL), CONSTRAINT fk_provider_contact_provider FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE, CONSTRAINT fk_provider_contact_created_by @@ -263,12 +263,12 @@ final class Version20260612100000 extends AbstractMigration SQL); $this->addSql('CREATE INDEX idx_provider_contact_provider ON provider_contact (provider_id)'); - $this->comment('provider_contact', '_table', 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/telephone/email (RG-3.04, chk_provider_contact_name).'); + $this->comment('provider_contact', '_table', 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/fonction/telephone/email (RG-3.04, chk_provider_contact_name).'); $this->comment('provider_contact', 'id', 'Identifiant interne auto-incremente.'); $this->comment('provider_contact', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.'); $this->comment('provider_contact', 'first_name', 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).'); $this->comment('provider_contact', 'last_name', 'Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).'); - $this->comment('provider_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).'); + $this->comment('provider_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).'); $this->comment('provider_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (normalisation serveur).'); $this->comment('provider_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).'); $this->comment('provider_contact', 'email', 'Email du contact (lowercase serveur).'); diff --git a/src/Module/Technique/Application/Service/ProviderFieldNormalizer.php b/src/Module/Technique/Application/Service/ProviderFieldNormalizer.php index facc4c0..ea7f6f6 100644 --- a/src/Module/Technique/Application/Service/ProviderFieldNormalizer.php +++ b/src/Module/Technique/Application/Service/ProviderFieldNormalizer.php @@ -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. diff --git a/src/Module/Technique/Domain/Entity/ProviderAddress.php b/src/Module/Technique/Domain/Entity/ProviderAddress.php index bc80254..55ef080 100644 --- a/src/Module/Technique/Domain/Entity/ProviderAddress.php +++ b/src/Module/Technique/Domain/Entity/ProviderAddress.php @@ -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; diff --git a/src/Module/Technique/Domain/Entity/ProviderContact.php b/src/Module/Technique/Domain/Entity/ProviderContact.php index e286c4c..9511f6b 100644 --- a/src/Module/Technique/Domain/Entity/ProviderContact.php +++ b/src/Module/Technique/Domain/Entity/ProviderContact.php @@ -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; diff --git a/src/Module/Technique/Domain/Entity/ProviderOwnedInterface.php b/src/Module/Technique/Domain/Entity/ProviderOwnedInterface.php new file mode 100644 index 0000000..a6a7303 --- /dev/null +++ b/src/Module/Technique/Domain/Entity/ProviderOwnedInterface.php @@ -0,0 +1,16 @@ + ['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; diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderAddressProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderAddressProcessor.php index ea60640..653cf91 100644 --- a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderAddressProcessor.php +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderAddressProcessor.php @@ -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); } diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php index abf16c4..21ee45c 100644 --- a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php @@ -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, diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderRibProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderRibProcessor.php index d9ac8f6..0c8107f 100644 --- a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderRibProcessor.php +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderRibProcessor.php @@ -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); } diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderProvider.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderProvider.php index 5ed3645..b5955f9 100644 --- a/src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderProvider.php +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderProvider.php @@ -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". */ diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderSubResourceItemProvider.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderSubResourceItemProvider.php new file mode 100644 index 0000000..6814eb6 --- /dev/null +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderSubResourceItemProvider.php @@ -0,0 +1,52 @@ + 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 + */ +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; + } +} diff --git a/src/Module/Technique/Infrastructure/Security/ProviderSiteScopeChecker.php b/src/Module/Technique/Infrastructure/Security/ProviderSiteScopeChecker.php new file mode 100644 index 0000000..8e8f2ce --- /dev/null +++ b/src/Module/Technique/Infrastructure/Security/ProviderSiteScopeChecker.php @@ -0,0 +1,85 @@ + 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; + } +} diff --git a/tests/Module/Technique/Api/ProviderSubResourceApiTest.php b/tests/Module/Technique/Api/ProviderSubResourceApiTest.php index 7663083..fc252bb 100644 --- a/tests/Module/Technique/Api/ProviderSubResourceApiTest.php +++ b/tests/Module/Technique/Api/ProviderSubResourceApiTest.php @@ -9,7 +9,7 @@ use App\Module\Technique\Domain\Entity\Provider; /** * Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du prestataire * (M3, spec § 4.5 — ERP-135). Couvrent : normalisation contact (RG-3.11), RG-3.04 - * (au moins un champ parmi prenom/nom/telephone/email), RG-3.05 (>= 1 site sur + * (au moins un champ parmi prenom/nom/fonction/telephone/email), RG-3.05 (>= 1 site sur * l'adresse), RG-3.06 (code postal), RG-3.09 (categorie PRESTATAIRE sur adresse), * le cloisonnement d'ecriture des sites de l'adresse (§ 2.13 -> 422 sur `sites`), * RG-3.08 (DELETE dernier RIB sous LCR -> 409), DELETE contact libre au M3 (pas de @@ -53,18 +53,37 @@ final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase } /** - * RG-3.04 : un bloc sans aucun champ du CHECK (prenom/nom/telephone/email) est - * rejete avant la base (chk_provider_contact_name) -> 422 rattachee a firstName. - * Ici seul jobTitle est fourni (hors CHECK). + * RG-3.04 : un bloc Contact est valide des qu'AU MOINS UN champ est rempli parmi + * prenom / nom / FONCTION / telephone / email (spec § RG-3.04, ligne 926). Ici + * seul jobTitle (Fonction) est fourni -> le bloc est valide -> 201. */ - public function testPostContactWithoutNamedFieldReturns422OnFirstNamePath(): void + public function testPostContactWithOnlyJobTitleReturns201(): void { $client = $this->createAdminClient(); - $seed = $this->seedProvider('Contact No Name'); + $seed = $this->seedProvider('Contact JobTitle Only'); + + $data = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => ['jobTitle' => 'Directeur'], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + self::assertSame('Directeur', $data['jobTitle']); + } + + /** + * RG-3.04 : un bloc Contact TOTALEMENT vide (aucun champ du CHECK + * chk_provider_contact_name) est rejete avant la base -> 422 rattachee a + * firstName. Une Fonction vide (apres normalisation) ne suffit pas a valider. + */ + public function testPostContactCompletelyEmptyReturns422OnFirstNamePath(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Contact No Field'); $response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], - 'json' => ['jobTitle' => 'Directeur'], + 'json' => ['jobTitle' => ' '], ]); self::assertResponseStatusCodeSame(422); diff --git a/tests/Module/Technique/Api/ProviderSubResourceSiteScopeTest.php b/tests/Module/Technique/Api/ProviderSubResourceSiteScopeTest.php new file mode 100644 index 0000000..1191748 --- /dev/null +++ b/tests/Module/Technique/Api/ProviderSubResourceSiteScopeTest.php @@ -0,0 +1,154 @@ + 404 hors + * perimetre) + ProviderSiteScopeChecker::assertInScope dans les processors (POST + * sur parent hors perimetre -> 404). Decision de scope partagee (source unique). + * + * @internal + */ +final class ProviderSubResourceSiteScopeTest extends AbstractProviderApiTestCase +{ + /** Permissions completes pour exercer view + manage + accounting sur tous les chemins. */ + private const array FULL_PERMS = [ + 'technique.providers.view', + 'technique.providers.manage', + 'technique.providers.accounting.view', + 'technique.providers.accounting.manage', + ]; + + protected function setUp(): void + { + parent::setUp(); + $this->skipIfSitesModuleDisabled(); + } + + public function testGetContactOutOfScopeReturns404ButInScope200(): void + { + $inScope = $this->seedProvider('Presta In Scope', [self::SITE_86]); + $inContactId = $this->addContact($inScope, 'Marie', 'Martin')->getId(); + + $outScope = $this->seedProvider('Presta Out Scope', [self::SITE_17]); + $outContactId = $this->addContact($outScope, 'Paul', 'Durand')->getId(); + + $client = $this->scopedClient(); + + $ok = $client->request('GET', '/api/provider_contacts/'.$inContactId, ['headers' => ['Accept' => self::LD]]); + self::assertSame(200, $ok->getStatusCode()); + + // Hors perimetre : 404 (ne pas reveler l'existence du contact d'un autre site). + $ko = $client->request('GET', '/api/provider_contacts/'.$outContactId, ['headers' => ['Accept' => self::LD]]); + self::assertSame(404, $ko->getStatusCode()); + } + + public function testGetRibOutOfScopeReturns404(): void + { + // RIB = donnee bancaire sensible (IBAN/BIC) : le cas le plus critique. + $outScope = $this->seedProvider('Presta Out Rib', [self::SITE_17]); + $ribId = $this->addRib($outScope)->getId(); + + $client = $this->scopedClient(); + + $response = $client->request('GET', '/api/provider_ribs/'.$ribId, ['headers' => ['Accept' => self::LD]]); + self::assertSame(404, $response->getStatusCode()); + } + + public function testPatchRibOutOfScopeReturns404(): void + { + $outScope = $this->seedProvider('Presta Patch Rib', [self::SITE_17]); + $ribId = $this->addRib($outScope)->getId(); + + $client = $this->scopedClient(); + + $response = $client->request('PATCH', '/api/provider_ribs/'.$ribId, [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['label' => 'Hacked'], + ]); + self::assertSame(404, $response->getStatusCode()); + } + + public function testDeleteContactOutOfScopeReturns404(): void + { + $outScope = $this->seedProvider('Presta Del Contact', [self::SITE_17]); + $contactId = $this->addContact($outScope, 'Paul', 'Durand')->getId(); + + $client = $this->scopedClient(); + + $response = $client->request('DELETE', '/api/provider_contacts/'.$contactId); + self::assertSame(404, $response->getStatusCode()); + } + + public function testPostContactOnOutOfScopeProviderReturns404(): void + { + $outScope = $this->seedProvider('Presta Post Contact', [self::SITE_17]); + $id = $outScope->getId(); + + $client = $this->scopedClient(); + + $response = $client->request('POST', '/api/providers/'.$id.'/contacts', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => ['firstName' => 'Intrus'], + ]); + self::assertSame(404, $response->getStatusCode()); + } + + public function testPostRibOnOutOfScopeProviderReturns404(): void + { + $outScope = $this->seedProvider('Presta Post Rib', [self::SITE_17]); + $id = $outScope->getId(); + + $client = $this->scopedClient(); + + $response = $client->request('POST', '/api/providers/'.$id.'/ribs', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => [ + 'label' => 'Intrus', + 'iban' => self::VALID_IBAN, + 'bic' => self::VALID_BIC, + ], + ]); + self::assertSame(404, $response->getStatusCode()); + } + + public function testBypassUserReachesSubResourceOnAnySite(): void + { + // Temoin : l'admin (bypass total) lit bien un contact hors « son » site. + $outScope = $this->seedProvider('Presta Admin Reach', [self::SITE_17]); + $contactId = $this->addContact($outScope, 'Marie', 'Martin')->getId(); + + $client = $this->createAdminClient(); + $response = $client->request('GET', '/api/provider_contacts/'.$contactId, ['headers' => ['Accept' => self::LD]]); + self::assertSame(200, $response->getStatusCode()); + } + + /** + * Client authentifie comme un user NON-bypass rattache au seul site 86 (avec + * currentSite 86) — sujet des tests de cloisonnement des sous-ressources. + */ + private function scopedClient(): Client + { + $creds = $this->createScopedUser( + self::FULL_PERMS, + sitePostalCodes: [self::SITE_86], + currentSitePostalCode: self::SITE_86, + ); + + return $this->authenticatedClient($creds['username'], $creds['password']); + } +}