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:
@@ -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 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).
|
- **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).
|
- **É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).
|
> **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).
|
||||||
|
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ final class Version20260612100000 extends AbstractMigration
|
|||||||
updated_by INT DEFAULT NULL,
|
updated_by INT DEFAULT NULL,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
CONSTRAINT chk_provider_contact_name
|
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
|
CONSTRAINT fk_provider_contact_provider
|
||||||
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
|
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
|
||||||
CONSTRAINT fk_provider_contact_created_by
|
CONSTRAINT fk_provider_contact_created_by
|
||||||
@@ -263,12 +263,12 @@ final class Version20260612100000 extends AbstractMigration
|
|||||||
SQL);
|
SQL);
|
||||||
$this->addSql('CREATE INDEX idx_provider_contact_provider ON provider_contact (provider_id)');
|
$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', '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', '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', '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', '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_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', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).');
|
||||||
$this->comment('provider_contact', 'email', 'Email du contact (lowercase serveur).');
|
$this->comment('provider_contact', 'email', 'Email du contact (lowercase serveur).');
|
||||||
|
|||||||
@@ -65,6 +65,23 @@ final class ProviderFieldNormalizer
|
|||||||
return '' === $value ? null : mb_strtolower($value, 'UTF-8');
|
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" ->
|
* Telephone reduit aux chiffres (RG-3.11) : "06.12.34.56.78" ->
|
||||||
* "0612345678". Une valeur sans aucun chiffre devient null.
|
* "0612345678". Une valeur sans aucun chiffre devient null.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use ApiPlatform\Metadata\Link;
|
|||||||
use ApiPlatform\Metadata\Patch;
|
use ApiPlatform\Metadata\Patch;
|
||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderAddressProcessor;
|
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\Attribute\Auditable;
|
||||||
use App\Shared\Domain\Contract\BlamableInterface;
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
use App\Shared\Domain\Contract\CategoryInterface;
|
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
|
// site:read + category:read : embarquent les Site / Category lies
|
||||||
// (maillon (c)) plutot que des IRI nus dans le retour.
|
// (maillon (c)) plutot que des IRI nus dans le retour.
|
||||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
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(
|
new Post(
|
||||||
uriTemplate: '/providers/{providerId}/addresses',
|
uriTemplate: '/providers/{providerId}/addresses',
|
||||||
@@ -80,10 +83,12 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
security: "is_granted('technique.providers.manage')",
|
security: "is_granted('technique.providers.manage')",
|
||||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
||||||
denormalizationContext: ['groups' => ['provider:write:addresses']],
|
denormalizationContext: ['groups' => ['provider:write:addresses']],
|
||||||
|
provider: ProviderSubResourceItemProvider::class,
|
||||||
processor: ProviderAddressProcessor::class,
|
processor: ProviderAddressProcessor::class,
|
||||||
),
|
),
|
||||||
new Delete(
|
new Delete(
|
||||||
security: "is_granted('technique.providers.manage')",
|
security: "is_granted('technique.providers.manage')",
|
||||||
|
provider: ProviderSubResourceItemProvider::class,
|
||||||
processor: ProviderAddressProcessor::class,
|
processor: ProviderAddressProcessor::class,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -92,7 +97,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
#[ORM\Table(name: 'provider_address')]
|
#[ORM\Table(name: 'provider_address')]
|
||||||
#[ORM\Index(name: 'idx_provider_address_provider', columns: ['provider_id'])]
|
#[ORM\Index(name: 'idx_provider_address_provider', columns: ['provider_id'])]
|
||||||
#[Auditable]
|
#[Auditable]
|
||||||
class ProviderAddress implements TimestampableInterface, BlamableInterface
|
class ProviderAddress implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
|
||||||
{
|
{
|
||||||
use TimestampableBlamableTrait;
|
use TimestampableBlamableTrait;
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use ApiPlatform\Metadata\Link;
|
|||||||
use ApiPlatform\Metadata\Patch;
|
use ApiPlatform\Metadata\Patch;
|
||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderContactProcessor;
|
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\Attribute\Auditable;
|
||||||
use App\Shared\Domain\Contract\BlamableInterface;
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||||
@@ -47,6 +48,8 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
new Get(
|
new Get(
|
||||||
security: "is_granted('technique.providers.view')",
|
security: "is_granted('technique.providers.view')",
|
||||||
normalizationContext: ['groups' => ['provider:item:read']],
|
normalizationContext: ['groups' => ['provider:item:read']],
|
||||||
|
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
|
||||||
|
provider: ProviderSubResourceItemProvider::class,
|
||||||
),
|
),
|
||||||
new Post(
|
new Post(
|
||||||
uriTemplate: '/providers/{providerId}/contacts',
|
uriTemplate: '/providers/{providerId}/contacts',
|
||||||
@@ -67,10 +70,12 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
security: "is_granted('technique.providers.manage')",
|
security: "is_granted('technique.providers.manage')",
|
||||||
normalizationContext: ['groups' => ['provider:item:read']],
|
normalizationContext: ['groups' => ['provider:item:read']],
|
||||||
denormalizationContext: ['groups' => ['provider:write:contacts']],
|
denormalizationContext: ['groups' => ['provider:write:contacts']],
|
||||||
|
provider: ProviderSubResourceItemProvider::class,
|
||||||
processor: ProviderContactProcessor::class,
|
processor: ProviderContactProcessor::class,
|
||||||
),
|
),
|
||||||
new Delete(
|
new Delete(
|
||||||
security: "is_granted('technique.providers.manage')",
|
security: "is_granted('technique.providers.manage')",
|
||||||
|
provider: ProviderSubResourceItemProvider::class,
|
||||||
processor: ProviderContactProcessor::class,
|
processor: ProviderContactProcessor::class,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -79,7 +84,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
#[ORM\Table(name: 'provider_contact')]
|
#[ORM\Table(name: 'provider_contact')]
|
||||||
#[ORM\Index(name: 'idx_provider_contact_provider', columns: ['provider_id'])]
|
#[ORM\Index(name: 'idx_provider_contact_provider', columns: ['provider_id'])]
|
||||||
#[Auditable]
|
#[Auditable]
|
||||||
class ProviderContact implements TimestampableInterface, BlamableInterface
|
class ProviderContact implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
|
||||||
{
|
{
|
||||||
use TimestampableBlamableTrait;
|
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\Patch;
|
||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderRibProcessor;
|
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\Attribute\Auditable;
|
||||||
use App\Shared\Domain\Contract\BlamableInterface;
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||||
@@ -49,6 +50,8 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
new Get(
|
new Get(
|
||||||
security: "is_granted('technique.providers.accounting.view')",
|
security: "is_granted('technique.providers.accounting.view')",
|
||||||
normalizationContext: ['groups' => ['provider:read:accounting']],
|
normalizationContext: ['groups' => ['provider:read:accounting']],
|
||||||
|
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
|
||||||
|
provider: ProviderSubResourceItemProvider::class,
|
||||||
),
|
),
|
||||||
new Post(
|
new Post(
|
||||||
uriTemplate: '/providers/{providerId}/ribs',
|
uriTemplate: '/providers/{providerId}/ribs',
|
||||||
@@ -69,10 +72,12 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
security: "is_granted('technique.providers.accounting.manage')",
|
security: "is_granted('technique.providers.accounting.manage')",
|
||||||
normalizationContext: ['groups' => ['provider:read:accounting']],
|
normalizationContext: ['groups' => ['provider:read:accounting']],
|
||||||
denormalizationContext: ['groups' => ['provider:write:accounting']],
|
denormalizationContext: ['groups' => ['provider:write:accounting']],
|
||||||
|
provider: ProviderSubResourceItemProvider::class,
|
||||||
processor: ProviderRibProcessor::class,
|
processor: ProviderRibProcessor::class,
|
||||||
),
|
),
|
||||||
new Delete(
|
new Delete(
|
||||||
security: "is_granted('technique.providers.accounting.manage')",
|
security: "is_granted('technique.providers.accounting.manage')",
|
||||||
|
provider: ProviderSubResourceItemProvider::class,
|
||||||
processor: ProviderRibProcessor::class,
|
processor: ProviderRibProcessor::class,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -81,7 +86,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
#[ORM\Table(name: 'provider_rib')]
|
#[ORM\Table(name: 'provider_rib')]
|
||||||
#[ORM\Index(name: 'idx_provider_rib_provider', columns: ['provider_id'])]
|
#[ORM\Index(name: 'idx_provider_rib_provider', columns: ['provider_id'])]
|
||||||
#[Auditable]
|
#[Auditable]
|
||||||
class ProviderRib implements TimestampableInterface, BlamableInterface
|
class ProviderRib implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
|
||||||
{
|
{
|
||||||
use TimestampableBlamableTrait;
|
use TimestampableBlamableTrait;
|
||||||
|
|
||||||
|
|||||||
+8
@@ -11,6 +11,7 @@ use ApiPlatform\Validator\Exception\ValidationException;
|
|||||||
use App\Module\Core\Domain\Entity\User;
|
use App\Module\Core\Domain\Entity\User;
|
||||||
use App\Module\Technique\Domain\Entity\Provider;
|
use App\Module\Technique\Domain\Entity\Provider;
|
||||||
use App\Module\Technique\Domain\Entity\ProviderAddress;
|
use App\Module\Technique\Domain\Entity\ProviderAddress;
|
||||||
|
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
|
||||||
use App\Shared\Domain\Contract\SiteInterface;
|
use App\Shared\Domain\Contract\SiteInterface;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use JsonException;
|
use JsonException;
|
||||||
@@ -53,6 +54,7 @@ final class ProviderAddressProcessor implements ProcessorInterface
|
|||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
private readonly RequestStack $requestStack,
|
private readonly RequestStack $requestStack,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly ProviderSiteScopeChecker $scopeChecker,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
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.');
|
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);
|
$address->setProvider($provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-5
@@ -11,6 +11,7 @@ use ApiPlatform\Validator\Exception\ValidationException;
|
|||||||
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
|
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
|
||||||
use App\Module\Technique\Domain\Entity\Provider;
|
use App\Module\Technique\Domain\Entity\Provider;
|
||||||
use App\Module\Technique\Domain\Entity\ProviderContact;
|
use App\Module\Technique\Domain\Entity\ProviderContact;
|
||||||
|
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
@@ -46,6 +47,7 @@ final class ProviderContactProcessor implements ProcessorInterface
|
|||||||
private readonly ProcessorInterface $removeProcessor,
|
private readonly ProcessorInterface $removeProcessor,
|
||||||
private readonly ProviderFieldNormalizer $normalizer,
|
private readonly ProviderFieldNormalizer $normalizer,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly ProviderSiteScopeChecker $scopeChecker,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
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.');
|
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);
|
$contact->setProvider($provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +112,7 @@ final class ProviderContactProcessor implements ProcessorInterface
|
|||||||
{
|
{
|
||||||
$contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
|
$contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
|
||||||
$contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
|
$contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
|
||||||
|
$contact->setJobTitle($this->normalizer->normalizeText($contact->getJobTitle()));
|
||||||
$contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary()));
|
$contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary()));
|
||||||
$contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary()));
|
$contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary()));
|
||||||
$contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail()));
|
$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 /
|
* 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
|
* nom / fonction / telephone principal / email est renseigne (double garde avec
|
||||||
* BDD chk_provider_contact_name — leve une 422 propre rattachee au champ
|
* le CHECK BDD chk_provider_contact_name — leve une 422 propre rattachee au
|
||||||
* `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
|
* champ `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
|
||||||
* chaines vides (y compris un phone_secondary seul, hors CHECK) sont deja
|
* chaines vides (y compris une fonction ou un phone_secondary vides) sont deja
|
||||||
* ramenees a null et ne suffisent pas a valider le bloc.
|
* ramenees a null et ne suffisent pas a valider le bloc.
|
||||||
*/
|
*/
|
||||||
private function validateName(ProviderContact $contact): void
|
private function validateName(ProviderContact $contact): void
|
||||||
{
|
{
|
||||||
if (null === $contact->getFirstName()
|
if (null === $contact->getFirstName()
|
||||||
&& null === $contact->getLastName()
|
&& null === $contact->getLastName()
|
||||||
|
&& null === $contact->getJobTitle()
|
||||||
&& null === $contact->getPhonePrimary()
|
&& null === $contact->getPhonePrimary()
|
||||||
&& null === $contact->getEmail()) {
|
&& null === $contact->getEmail()) {
|
||||||
$violations = new ConstraintViolationList();
|
$violations = new ConstraintViolationList();
|
||||||
$violations->add(new ConstraintViolation(
|
$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,
|
null,
|
||||||
[],
|
[],
|
||||||
$contact,
|
$contact,
|
||||||
|
|||||||
+7
@@ -9,6 +9,7 @@ use ApiPlatform\Metadata\Operation;
|
|||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
use App\Module\Technique\Domain\Entity\Provider;
|
use App\Module\Technique\Domain\Entity\Provider;
|
||||||
use App\Module\Technique\Domain\Entity\ProviderRib;
|
use App\Module\Technique\Domain\Entity\ProviderRib;
|
||||||
|
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||||
@@ -42,6 +43,7 @@ final class ProviderRibProcessor implements ProcessorInterface
|
|||||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||||
private readonly ProcessorInterface $removeProcessor,
|
private readonly ProcessorInterface $removeProcessor,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly ProviderSiteScopeChecker $scopeChecker,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
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.');
|
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);
|
$rib->setProvider($provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-42
@@ -9,12 +9,10 @@ use ApiPlatform\Metadata\CollectionOperationInterface;
|
|||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\Pagination\Pagination;
|
use ApiPlatform\State\Pagination\Pagination;
|
||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
|
||||||
use App\Module\Technique\Domain\Entity\Provider;
|
use App\Module\Technique\Domain\Entity\Provider;
|
||||||
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
|
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 Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,12 +62,11 @@ final class ProviderProvider implements ProviderInterface
|
|||||||
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
|
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
|
||||||
private readonly ProviderRepositoryInterface $repository,
|
private readonly ProviderRepositoryInterface $repository,
|
||||||
private readonly Pagination $pagination,
|
private readonly Pagination $pagination,
|
||||||
private readonly Security $security,
|
// Decision de cloisonnement par site centralisee (site-aware.md § 6.2) :
|
||||||
// Outillage site-aware sanctionne (site-aware.md § 6.2 : « injecter
|
// source UNIQUE partagee avec le provider decore des sous-ressources
|
||||||
// CurrentSiteProvider dans le service et ajouter la clause WHERE
|
// (ProviderSubResourceItemProvider) et les processors d'ecriture, pour
|
||||||
// manuellement » pour les cas multi-site non couverts par
|
// eviter tout drift entre ces points d'application.
|
||||||
// SiteScopedQueryExtension). Type-hint sur l'interface pour le mock test.
|
private readonly ProviderSiteScopeChecker $scopeChecker,
|
||||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Provider|null
|
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
|
// Cloisonnement par site (RG-3.17) AVANT pagination : ajoute une clause
|
||||||
// restreignant au currentSite pour un user non-bypass. S'intersecte avec
|
// restreignant au currentSite pour un user non-bypass. S'intersecte avec
|
||||||
// un eventuel filtre ?siteId du client (deux sous-requetes ANDees).
|
// un eventuel filtre ?siteId du client (deux sous-requetes ANDees).
|
||||||
$scopeSite = $this->siteScopeOrNull();
|
$scopeSite = $this->scopeChecker->siteScopeOrNull();
|
||||||
if (null !== $scopeSite) {
|
if (null !== $scopeSite) {
|
||||||
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
|
$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
|
// 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
|
// l'user -> 404 (ne pas reveler son existence). No-op pour bypass_scope ou
|
||||||
// currentSite null.
|
// currentSite null (delegue au ProviderSiteScopeChecker).
|
||||||
$scopeSite = $this->siteScopeOrNull();
|
if (!$this->scopeChecker->isInScope($provider)) {
|
||||||
if (null !== $scopeSite && !$this->providerHasSite($provider, (int) $scopeSite->getId())) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $provider;
|
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".
|
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
||||||
*/
|
*/
|
||||||
|
|||||||
+52
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ use App\Module\Technique\Domain\Entity\Provider;
|
|||||||
/**
|
/**
|
||||||
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du prestataire
|
* 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
|
* (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),
|
* 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`),
|
* 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
|
* 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
|
* RG-3.04 : un bloc Contact est valide des qu'AU MOINS UN champ est rempli parmi
|
||||||
* rejete avant la base (chk_provider_contact_name) -> 422 rattachee a firstName.
|
* prenom / nom / FONCTION / telephone / email (spec § RG-3.04, ligne 926). Ici
|
||||||
* Ici seul jobTitle est fourni (hors CHECK).
|
* seul jobTitle (Fonction) est fourni -> le bloc est valide -> 201.
|
||||||
*/
|
*/
|
||||||
public function testPostContactWithoutNamedFieldReturns422OnFirstNamePath(): void
|
public function testPostContactWithOnlyJobTitleReturns201(): void
|
||||||
{
|
{
|
||||||
$client = $this->createAdminClient();
|
$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', [
|
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
|
||||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||||
'json' => ['jobTitle' => 'Directeur'],
|
'json' => ['jobTitle' => ' '],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Technique\Api;
|
||||||
|
|
||||||
|
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests du cloisonnement par site des SOUS-RESSOURCES d'un prestataire (Contacts /
|
||||||
|
* Adresses / RIB) — § 2.13 / RG-3.17. Complement de ProviderSiteScopeTest (qui ne
|
||||||
|
* couvrait que le Provider lui-meme).
|
||||||
|
*
|
||||||
|
* Sans garde dedie, un user cloisonne pouvait lire / editer / supprimer une
|
||||||
|
* sous-ressource d'un prestataire HORS de son site (le detail Provider est garde en
|
||||||
|
* 404, mais les sous-ressources passent par le provider Doctrine par defaut, non
|
||||||
|
* cloisonne — et SiteScopedQueryExtension ne filtre que les SiteAwareInterface).
|
||||||
|
* Le RIB est particulierement sensible (IBAN / BIC).
|
||||||
|
*
|
||||||
|
* Garde pose par ProviderSubResourceItemProvider (Get/Patch/Delete -> 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user