| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [x] Pas de régression - [x] TU/TI/TF rédigée - [x] TU/TI/TF OK - [ ] CHANGELOG modifié Co-authored-by: Matthieu <mtholot19@gmail.com> Reviewed-on: MALIO-DEV/Coltura#8 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #8.
This commit is contained in:
69
src/Module/Sites/Application/Service/CurrentSiteProvider.php
Normal file
69
src/Module/Sites/Application/Service/CurrentSiteProvider.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Application\Service;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Module\Sites\SitesModule;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
use function in_array;
|
||||
|
||||
/**
|
||||
* Resout le site courant de l'utilisateur authentifie pour les besoins de
|
||||
* l'outillage opt-in "site-aware" (ticket 4 module Sites).
|
||||
*
|
||||
* Consomme par :
|
||||
* - SiteScopedQueryExtension : filtrage automatique des collections API.
|
||||
* - SiteAwareInjectionProcessor : injection automatique sur POST/PATCH.
|
||||
*
|
||||
* Retourne `null` dans trois cas distincts (chacun volontairement
|
||||
* silencieux pour que les extensions/processor deviennent no-op sans
|
||||
* erreur visible) :
|
||||
* 1. Le module Sites est desactive dans `config/modules.php`.
|
||||
* 2. Aucun user n'est authentifie (appel depuis un endpoint public).
|
||||
* 3. L'user authentifie n'a pas de `currentSite` positionne (cas rare
|
||||
* grace a la garde `UserRbacProcessor::ensureCurrentSiteConsistency`).
|
||||
*
|
||||
* Le flag `sitesActive` est calcule UNE FOIS au boot du service pour
|
||||
* eviter un `require` a chaque resolution.
|
||||
*/
|
||||
final class CurrentSiteProvider implements CurrentSiteProviderInterface
|
||||
{
|
||||
private readonly bool $sitesActive;
|
||||
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
#[Autowire(param: 'kernel.project_dir')]
|
||||
string $projectDir,
|
||||
) {
|
||||
// Lit config/modules.php (tableau de FQCN) et verifie la presence
|
||||
// de SitesModule::class. Pattern aligne sur ModulesProvider.
|
||||
$configPath = $projectDir.'/config/modules.php';
|
||||
$moduleClasses = file_exists($configPath) ? require $configPath : [];
|
||||
|
||||
$this->sitesActive = in_array(SitesModule::class, $moduleClasses, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le site courant de l'utilisateur authentifie, ou null si
|
||||
* l'une des 3 conditions de desactivation est remplie (cf. docblock
|
||||
* de classe).
|
||||
*/
|
||||
public function get(): ?Site
|
||||
{
|
||||
if (!$this->sitesActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $user->getCurrentSite();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Application\Service;
|
||||
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
|
||||
/**
|
||||
* Contrat de resolution du site courant pour l'outillage opt-in
|
||||
* "site-aware" (ticket 4 module Sites).
|
||||
*
|
||||
* Facilite le test de l'extension et du processor en permettant un mock
|
||||
* sans dependre de l'implementation concrete (qui garde `final` pour
|
||||
* l'immutabilite du service en prod).
|
||||
*
|
||||
* Retourne `null` dans trois cas (cf. CurrentSiteProvider) :
|
||||
* - module Sites desactive dans config/modules.php
|
||||
* - pas d'user authentifie
|
||||
* - user sans currentSite positionne
|
||||
*/
|
||||
interface CurrentSiteProviderInterface
|
||||
{
|
||||
public function get(): ?Site;
|
||||
}
|
||||
327
src/Module/Sites/Domain/Entity/Site.php
Normal file
327
src/Module/Sites/Domain/Entity/Site.php
Normal file
@@ -0,0 +1,327 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Site physique (usine / etablissement) appartenant a l'instance Coltura.
|
||||
*
|
||||
* Adresse decomposee en champs structures (rue, complement, CP, ville) pour
|
||||
* permettre des recherches/tris fins ulterieurs et eviter les divergences
|
||||
* entre champs duplique. La methode `getFullAddress()` fournit la version
|
||||
* concatenee multi-lignes pour les usages d'affichage.
|
||||
*
|
||||
* Expose en API Platform pour l'administration CRUD avec RBAC :
|
||||
* - lecture (GET list / item) : requiert la permission `sites.view`
|
||||
* - ecriture (POST / PATCH / DELETE) : requiert la permission `sites.manage`
|
||||
*
|
||||
* Egalement embarque dans la reponse `/api/me` (groupe `me:read`) pour que
|
||||
* le frontend connaisse les sites autorises et le site courant de l'user.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
normalizationContext: ['groups' => ['site:read']],
|
||||
security: "is_granted('sites.view')",
|
||||
),
|
||||
new Get(
|
||||
normalizationContext: ['groups' => ['site:read']],
|
||||
security: "is_granted('sites.view')",
|
||||
),
|
||||
new Post(
|
||||
normalizationContext: ['groups' => ['site:read']],
|
||||
denormalizationContext: ['groups' => ['site:write']],
|
||||
security: "is_granted('sites.manage')",
|
||||
),
|
||||
new Patch(
|
||||
normalizationContext: ['groups' => ['site:read']],
|
||||
denormalizationContext: ['groups' => ['site:write']],
|
||||
security: "is_granted('sites.manage')",
|
||||
),
|
||||
new Delete(security: "is_granted('sites.manage')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['site:read']],
|
||||
denormalizationContext: ['groups' => ['site:write']],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineSiteRepository::class)]
|
||||
#[ORM\Table(name: 'site')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[UniqueEntity(fields: ['name'], message: 'Un site avec ce nom existe deja.')]
|
||||
class Site implements SiteInterface
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['site:read', 'me:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
#[Assert\NotBlank(message: 'Le nom du site est requis.')]
|
||||
#[Assert\Length(max: 100, maxMessage: 'Le nom du site ne peut pas depasser {{ limit }} caracteres.')]
|
||||
#[Groups(['site:read', 'site:write', 'me:read'])]
|
||||
private string $name;
|
||||
|
||||
// Premiere ligne d'adresse : numero + voie. Requise.
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank(message: 'La rue est requise.')]
|
||||
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut pas depasser {{ limit }} caracteres.')]
|
||||
#[Groups(['site:read', 'site:write', 'me:read'])]
|
||||
private string $street;
|
||||
|
||||
// Complement d'adresse optionnel : batiment, escalier, BP, etc.
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, maxMessage: 'Le complement ne peut pas depasser {{ limit }} caracteres.')]
|
||||
#[Groups(['site:read', 'site:write', 'me:read'])]
|
||||
private ?string $complement = null;
|
||||
|
||||
// Colonne mappee sur le snake_case PostgreSQL (convention projet : noms de
|
||||
// colonnes en minuscules dans le SQL brut). Le format est contraint au
|
||||
// code postal francais strict : 5 chiffres numeriques.
|
||||
#[ORM\Column(name: 'postal_code', length: 10)]
|
||||
#[Assert\NotBlank(message: 'Le code postal est requis.')]
|
||||
#[Assert\Length(max: 10, maxMessage: 'Le code postal ne peut pas depasser {{ limit }} caracteres.')]
|
||||
#[Assert\Regex(
|
||||
pattern: '/^\d{5}$/',
|
||||
message: 'Le code postal doit etre compose de 5 chiffres (format FR).',
|
||||
)]
|
||||
#[Groups(['site:read', 'site:write', 'me:read'])]
|
||||
private string $postalCode;
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
#[Assert\NotBlank(message: 'La ville du site est requise.')]
|
||||
#[Assert\Length(max: 100, maxMessage: 'La ville ne peut pas depasser {{ limit }} caracteres.')]
|
||||
#[Groups(['site:read', 'site:write', 'me:read'])]
|
||||
private string $city;
|
||||
|
||||
// Couleur d'identification visuelle du site au format hex #RRGGBB (7 chars
|
||||
// incluant le diese). Utilisee par la navbar (ticket 3) pour distinguer
|
||||
// les sites d'un coup d'oeil.
|
||||
#[ORM\Column(length: 7)]
|
||||
#[Assert\NotBlank(message: 'La couleur est requise.')]
|
||||
#[Assert\Regex(
|
||||
pattern: '/^#[0-9A-Fa-f]{6}$/',
|
||||
message: 'La couleur doit etre un code hex de 7 caracteres au format #RRGGBB.',
|
||||
)]
|
||||
#[Groups(['site:read', 'site:write', 'me:read'])]
|
||||
private string $color;
|
||||
|
||||
// createdAt / updatedAt volontairement exclus du groupe `me:read` :
|
||||
// le payload `/api/me` doit rester leger, ces metadonnees ne sont utiles
|
||||
// qu'a l'admin (exposees uniquement via `site:read` sur /api/sites).
|
||||
#[ORM\Column(name: 'created_at', type: Types::DATETIME_IMMUTABLE)]
|
||||
#[Groups(['site:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: Types::DATETIME_IMMUTABLE)]
|
||||
#[Groups(['site:read'])]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
/**
|
||||
* Collection inverse des users rattaches a ce site.
|
||||
*
|
||||
* Volontairement SANS `#[Groups]` : la collection n'est jamais exposee via
|
||||
* l'API pour deux raisons :
|
||||
* - eviter une boucle de serialisation infinie User → sites → users → ...
|
||||
* si un jour un developpeur ajoute `me:read` ici par megarde ;
|
||||
* - l'inverse n'a de valeur qu'en interne (compter les users d'un site,
|
||||
* iterer en test de cascade).
|
||||
*
|
||||
* @var Collection<int, User>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: User::class, mappedBy: 'sites')]
|
||||
private Collection $users;
|
||||
|
||||
public function __construct(
|
||||
string $name,
|
||||
string $street,
|
||||
?string $complement,
|
||||
string $postalCode,
|
||||
string $city,
|
||||
string $color,
|
||||
) {
|
||||
$this->name = $name;
|
||||
$this->street = $street;
|
||||
$this->complement = $complement;
|
||||
$this->postalCode = $postalCode;
|
||||
$this->city = $city;
|
||||
$this->color = $color;
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
$this->users = new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback Doctrine : a chaque update en base on rafraichit updatedAt.
|
||||
* Ne pas toucher a createdAt ici (immutable apres creation).
|
||||
*/
|
||||
#[ORM\PreUpdate]
|
||||
public function onPreUpdate(): void
|
||||
{
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): static
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStreet(): string
|
||||
{
|
||||
return $this->street;
|
||||
}
|
||||
|
||||
public function setStreet(string $street): static
|
||||
{
|
||||
$this->street = $street;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getComplement(): ?string
|
||||
{
|
||||
return $this->complement;
|
||||
}
|
||||
|
||||
public function setComplement(?string $complement): static
|
||||
{
|
||||
$this->complement = $complement;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPostalCode(): string
|
||||
{
|
||||
return $this->postalCode;
|
||||
}
|
||||
|
||||
public function setPostalCode(string $postalCode): static
|
||||
{
|
||||
$this->postalCode = $postalCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCity(): string
|
||||
{
|
||||
return $this->city;
|
||||
}
|
||||
|
||||
public function setCity(string $city): static
|
||||
{
|
||||
$this->city = $city;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getColor(): string
|
||||
{
|
||||
return $this->color;
|
||||
}
|
||||
|
||||
public function setColor(string $color): static
|
||||
{
|
||||
$this->color = $color;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse complete reconstituee : street, [complement,] {CP} {ville},
|
||||
* separes par des sauts de ligne. Methode pure, jamais persistee.
|
||||
*
|
||||
* Expose en lecture API (groupes site:read + me:read) pour que les
|
||||
* consommateurs (frontend, exports PDF) recoivent une adresse prete a
|
||||
* afficher sans dupliquer la logique de concatenation cote client.
|
||||
*/
|
||||
#[Groups(['site:read', 'me:read'])]
|
||||
public function getFullAddress(): string
|
||||
{
|
||||
$lines = [$this->street];
|
||||
|
||||
if (null !== $this->complement && '' !== trim($this->complement)) {
|
||||
$lines[] = $this->complement;
|
||||
}
|
||||
|
||||
$lines[] = sprintf('%s %s', $this->postalCode, $this->city);
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, User>
|
||||
*/
|
||||
public function getUsers(): Collection
|
||||
{
|
||||
return $this->users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronise la collection inverse cote Site quand User::addSite est
|
||||
* appele. Idempotent. Ne re-appelle pas $user->addSite($this) pour
|
||||
* eviter une recursion infinie : User::addSite est le point d'entree
|
||||
* unique de la mutation.
|
||||
*
|
||||
* @internal Appele uniquement par User::addSite()
|
||||
*/
|
||||
public function addUser(User $user): static
|
||||
{
|
||||
if (!$this->users->contains($user)) {
|
||||
$this->users->add($user);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Appele uniquement par User::removeSite()
|
||||
*/
|
||||
public function removeUser(User $user): static
|
||||
{
|
||||
$this->users->removeElement($user);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Domain\Exception;
|
||||
|
||||
use App\Shared\Domain\Exception\SiteNotAuthorizedException as SharedSiteNotAuthorizedException;
|
||||
|
||||
/**
|
||||
* Alias de retrocompatibilite vers Shared\Domain\Exception\SiteNotAuthorizedException.
|
||||
*
|
||||
* La classe canonique a ete deplacee dans Shared pour rompre le couplage
|
||||
* Core → Sites. Les consommateurs existants dans le module Sites
|
||||
* (CurrentSiteProcessor) continuent de l'attraper ici sans modification.
|
||||
*
|
||||
* @see SharedSiteNotAuthorizedException
|
||||
*/
|
||||
final class SiteNotAuthorizedException extends SharedSiteNotAuthorizedException {}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Domain\Repository;
|
||||
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
|
||||
interface SiteRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?Site;
|
||||
|
||||
public function findByName(string $name): ?Site;
|
||||
|
||||
/**
|
||||
* @return list<Site>
|
||||
*/
|
||||
public function findAllOrderedByName(): array;
|
||||
|
||||
public function save(Site $site): void;
|
||||
|
||||
public function remove(Site $site): void;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Infrastructure\ApiPlatform\Extension;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* Extension API Platform qui restreint les collections et items de la
|
||||
* resource Site (/api/sites) aux seuls sites auxquels l'utilisateur
|
||||
* authentifie est rattache (ticket module Sites — prevention de la fuite
|
||||
* de donnees cross-tenant).
|
||||
*
|
||||
* `Site` n'implemente pas `SiteAwareInterface` (ce serait circulaire : un
|
||||
* site ne s'appartient pas a lui-meme). Cette extension complementaire
|
||||
* cible specifiquement `Site::class` et applique un filtre IN sur les IDs
|
||||
* des sites de l'utilisateur.
|
||||
*
|
||||
* Comportement selon les cas :
|
||||
* - resource != Site::class → no-op (les autres resources sont
|
||||
* gerees par SiteScopedQueryExtension) ;
|
||||
* - is_granted('sites.bypass_scope') → pas de filtre (admin / bypass) ;
|
||||
* - user non authentifie → no-op (API Platform renvoie 401 avant) ;
|
||||
* - user sans aucun site → WHERE 1 = 0 (aucun acces) ;
|
||||
* - cas normal → WHERE site.id IN (:allowedSites).
|
||||
*
|
||||
* Consequence anti-enumeration : GET /api/sites/{id} retourne 404 et non
|
||||
* 403 si l'item existe mais n'appartient pas aux sites de l'utilisateur
|
||||
* (comportement natif API Platform quand Doctrine retourne null).
|
||||
*/
|
||||
final class SiteCollectionScopedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function applyToCollection(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
|
||||
}
|
||||
|
||||
public function applyToItem(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
array $identifiers,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique le filtre IN sur les IDs de sites autorises si les conditions
|
||||
* d'application sont remplies. No-op sinon.
|
||||
*/
|
||||
private function applyScope(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
): void {
|
||||
// 1) Cette extension cible uniquement la resource Site.
|
||||
if (Site::class !== $resourceClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Admin ou user avec bypass explicite : visibilite globale.
|
||||
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) Pas d'user authentifie -> no-op (API Platform gere le 401 en amont).
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rootAlias = $queryBuilder->getRootAliases()[0];
|
||||
|
||||
// 4) User sans aucun site rattache -> aucun acces possible.
|
||||
$siteIds = $user->getSites()->map(fn (Site $s) => $s->getId())->toArray();
|
||||
if (empty($siteIds)) {
|
||||
$queryBuilder->andWhere('1 = 0');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 5) Cas normal : restriction aux sites autorises de l'utilisateur.
|
||||
$param = $queryNameGenerator->generateParameterName('allowedSites');
|
||||
$queryBuilder
|
||||
->andWhere(sprintf('%s.id IN (:%s)', $rootAlias, $param))
|
||||
->setParameter($param, $siteIds)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Infrastructure\ApiPlatform\Extension;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Shared\Domain\Contract\SiteAwareInterface;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
use function is_subclass_of;
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* Extension API Platform qui filtre automatiquement les collections et les
|
||||
* items des resources implementant SiteAwareInterface selon le site
|
||||
* courant de l'utilisateur authentifie (ticket 4 module Sites).
|
||||
*
|
||||
* Appliquee automatiquement par API Platform sur toutes les requetes GET
|
||||
* (collection + item), mais devient no-op si :
|
||||
* - la resource cible n'implemente pas SiteAwareInterface ;
|
||||
* - l'user a la permission `sites.bypass_scope` ;
|
||||
* - CurrentSiteProvider::get() retourne null (module desactive, pas
|
||||
* d'user authentifie, ou user sans currentSite).
|
||||
*
|
||||
* Le filtrage est identique pour les deux interfaces Collection et Item,
|
||||
* factorise dans `applyScope()`. Consequence sur GET /api/resource/{id} :
|
||||
* si l'item existe en base mais appartient a un autre site, Doctrine
|
||||
* retourne null apres filtrage et API Platform converti en 404
|
||||
* (anti-enumeration : le user ne peut pas distinguer "n'existe pas" de
|
||||
* "appartient a un autre site").
|
||||
*/
|
||||
final class SiteScopedQueryExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function applyToCollection(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
|
||||
}
|
||||
|
||||
public function applyToItem(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
array $identifiers,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute la clause `WHERE <alias>.site = :currentSite` au query builder
|
||||
* si les 3 conditions d'application sont remplies. No-op sinon.
|
||||
*/
|
||||
private function applyScope(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
): void {
|
||||
// 1) Filtrer uniquement les resources qui ont opt-in via l'interface.
|
||||
// `is_subclass_of` gere a la fois `implements` direct et herite.
|
||||
if (!is_subclass_of($resourceClass, SiteAwareInterface::class)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Admin ou user avec bypass explicite : visibilite globale.
|
||||
// is_granted('sites.bypass_scope') retourne true pour les admins
|
||||
// (bypass total via isAdmin) meme sans permission explicite.
|
||||
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) Pas de site courant -> no-op plutot que collection vide.
|
||||
// Decision assumee (cf. ticket 4 spec Risque 1) : un user sans
|
||||
// currentSite voit tout. L'alternative "collection vide" est
|
||||
// rejetee car elle rendrait l'app inutilisable pour un user
|
||||
// mal configure.
|
||||
$currentSite = $this->currentSiteProvider->get();
|
||||
if (null === $currentSite) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Application du filtre : alias racine du QueryBuilder, parametre
|
||||
// genere pour eviter les collisions avec d'autres extensions.
|
||||
$rootAlias = $queryBuilder->getRootAliases()[0];
|
||||
$parameterName = $queryNameGenerator->generateParameterName('currentSite');
|
||||
|
||||
$queryBuilder
|
||||
->andWhere(sprintf('%s.site = :%s', $rootAlias, $parameterName))
|
||||
->setParameter($parameterName, $currentSite)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Infrastructure\ApiPlatform\Extension;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* Extension API Platform qui restreint /api/users (collection + item) aux
|
||||
* utilisateurs partageant au moins un site commun avec l'appelant.
|
||||
*
|
||||
* Objectif : empecher l'enumeration cross-site des utilisateurs. Sans ce
|
||||
* filtre, un user du site A pourrait lister tous les users du site B via
|
||||
* GET /api/users.
|
||||
*
|
||||
* Conditions de bypass :
|
||||
* - is_granted('sites.bypass_scope') → visibilite totale (admin ou bypass
|
||||
* explicite) ;
|
||||
* - user non authentifie → no-op (API Platform renvoie 401) ;
|
||||
*
|
||||
* Cas particulier — appelant sans aucun site rattache :
|
||||
* Comportement defensif : l'utilisateur ne voit que lui-meme. Cela evite
|
||||
* de bloquer completement un user mal configure tout en ne lui revelant
|
||||
* aucun autre utilisateur.
|
||||
*
|
||||
* Strategie DQL : JOIN sur la relation ManyToMany `.sites` + DISTINCT pour
|
||||
* eviter les doublons si un user partage plusieurs sites avec l'appelant.
|
||||
* Le alias `s_scope` est utilise pour la jointure intermediaire.
|
||||
*/
|
||||
final class UserSiteScopedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function applyToCollection(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
|
||||
}
|
||||
|
||||
public function applyToItem(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
array $identifiers,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique le filtre de partage de site si les conditions d'application
|
||||
* sont remplies. No-op sinon.
|
||||
*/
|
||||
private function applyScope(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
): void {
|
||||
// 1) Cette extension cible uniquement la resource User.
|
||||
if (User::class !== $resourceClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Admin ou bypass explicite : visibilite totale.
|
||||
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) Pas d'user authentifie -> no-op (API Platform gere le 401 en amont).
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rootAlias = $queryBuilder->getRootAliases()[0];
|
||||
$callerSiteIds = $user->getSites()->map(fn (Site $s) => $s->getId())->toArray();
|
||||
|
||||
// 4) Appelant sans site : comportement defensif -> il ne voit que lui-meme.
|
||||
if (empty($callerSiteIds)) {
|
||||
$queryBuilder
|
||||
->andWhere(sprintf('%s.id = :self', $rootAlias))
|
||||
->setParameter('self', $user->getId())
|
||||
;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 5) Cas normal : garder uniquement les users qui partagent au moins
|
||||
// un site avec l'appelant. JOIN sur la relation ManyToMany `.sites`
|
||||
// + filtre IN + DISTINCT pour eviter les lignes dupliquees.
|
||||
$param = $queryNameGenerator->generateParameterName('callerSites');
|
||||
$queryBuilder
|
||||
->innerJoin(sprintf('%s.sites', $rootAlias), 's_scope')
|
||||
->andWhere(sprintf('s_scope.id IN (:%s)', $param))
|
||||
->setParameter($param, $callerSiteIds)
|
||||
->distinct()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Infrastructure\ApiPlatform\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Module\Sites\Infrastructure\ApiPlatform\State\Processor\CurrentSiteProcessor;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
/**
|
||||
* Ressource API Platform virtuelle (non mappee Doctrine) qui porte
|
||||
* l'operation `PATCH /api/me/current-site` : basculement du site courant
|
||||
* de l'utilisateur authentifie.
|
||||
*
|
||||
* `read: false` informe API Platform qu'il ne doit pas tenter de charger
|
||||
* une entite existante via un Provider — l'operation denormalise le payload
|
||||
* directement dans cette ressource, puis CurrentSiteProcessor prend le relais.
|
||||
*
|
||||
* `shortName: 'CurrentSite'` : evite toute collision avec l'entite `Site`
|
||||
* dans le routage et la documentation OpenAPI.
|
||||
*
|
||||
* Securite : l'autorisation "ROLE_USER" suffit au niveau voter — la verification
|
||||
* fine (le site demande fait-il partie des sites autorises de l'user ?)
|
||||
* est faite par CurrentSiteProcessor, car elle dependence de l'user
|
||||
* authentifie, pas d'une permission statique.
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'CurrentSite',
|
||||
operations: [
|
||||
new Patch(
|
||||
uriTemplate: '/me/current-site',
|
||||
security: "is_granted('ROLE_USER')",
|
||||
normalizationContext: ['groups' => ['me:read']],
|
||||
denormalizationContext: ['groups' => ['current-site:write']],
|
||||
processor: CurrentSiteProcessor::class,
|
||||
read: false,
|
||||
priority: 1,
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class CurrentSiteResource
|
||||
{
|
||||
/**
|
||||
* Site cible du switch, denormalise depuis l'IRI envoye dans le body :
|
||||
* `{ "site": "/api/sites/{id}" }`. Resolu automatiquement par
|
||||
* l'IriConverter d'API Platform en instance de `Site`.
|
||||
*/
|
||||
#[Groups(['current-site:write'])]
|
||||
public ?Site $site = null;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Sites\Domain\Exception\SiteNotAuthorizedException;
|
||||
use App\Module\Sites\Infrastructure\ApiPlatform\Resource\CurrentSiteResource;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\OptimisticLockException;
|
||||
use LogicException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Processor de l'operation `PATCH /api/me/current-site`.
|
||||
*
|
||||
* Flux :
|
||||
* 1. Recupere l'user authentifie via Security.
|
||||
* 2. Extrait le site cible depuis la ressource denormalisee.
|
||||
* 3. Refresh de l'user depuis la BDD pour eliminer la race condition TOCTOU :
|
||||
* si un autre thread a revoque le site entre le chargement de session et
|
||||
* ce PATCH, le refresh garantit que hasSite() reflete l'etat reel en base.
|
||||
* 4. Valide que le site fait partie des `sites` de l'user — sinon leve
|
||||
* SiteNotAuthorizedException convertie immediatement en 403.
|
||||
* 5. Positionne `currentSite`, flush, retourne l'user pour normalisation
|
||||
* par API Platform via les groupes `me:read` (payload identique a /api/me).
|
||||
*
|
||||
* Les etapes 3-5 sont executees dans une meme transaction pour garantir
|
||||
* un rollback propre en cas d'erreur entre le refresh et le flush.
|
||||
*
|
||||
* @implements ProcessorInterface<CurrentSiteResource, User>
|
||||
*/
|
||||
final class CurrentSiteProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof CurrentSiteResource) {
|
||||
throw new LogicException(sprintf(
|
||||
'CurrentSiteProcessor attend une instance de %s, %s recu.',
|
||||
CurrentSiteResource::class,
|
||||
get_debug_type($data),
|
||||
));
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
// security: "is_granted('ROLE_USER')" sur l'operation doit deja
|
||||
// bloquer ce cas — garde defensive si la config change.
|
||||
throw new AccessDeniedHttpException('Authentification requise pour changer de site courant.');
|
||||
}
|
||||
|
||||
$targetSite = $data->site;
|
||||
if (null === $targetSite) {
|
||||
throw new BadRequestHttpException('Le champ "site" est requis.');
|
||||
}
|
||||
|
||||
// Refresh + switchCurrentSite + flush dans une transaction atomique.
|
||||
// Le refresh elimine la race condition TOCTOU : si un PATCH /rbac concurrent
|
||||
// a revoque le site de l'user entre le chargement de session et ici, le
|
||||
// refresh force un re-fetch de l'user et de sa collection de sites depuis
|
||||
// la BDD, garantissant que hasSite() reflete l'etat reel persisté.
|
||||
try {
|
||||
$this->entityManager->wrapInTransaction(function () use ($user, $targetSite): void {
|
||||
// Re-fetch de l'user + ses collections depuis la BDD (elimination TOCTOU).
|
||||
$this->entityManager->refresh($user);
|
||||
|
||||
try {
|
||||
$user->switchCurrentSite($targetSite);
|
||||
} catch (SiteNotAuthorizedException $e) {
|
||||
// Traduction HTTP immediate (pas de listener kernel necessaire) :
|
||||
// aligne sur le pattern RoleProcessor → SystemRoleDeletionException.
|
||||
throw new AccessDeniedHttpException($e->getMessage(), $e);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
});
|
||||
} catch (OptimisticLockException $e) {
|
||||
// Protection future : si un champ @Version est ajoute sur User,
|
||||
// le conflit de version sera intercepte ici plutot que de remonter
|
||||
// comme une erreur generique.
|
||||
throw new BadRequestHttpException(
|
||||
'Conflit de version detecte lors du changement de site courant. Veuillez reessayer.',
|
||||
$e,
|
||||
);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Shared\Domain\Contract\SiteAwareInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Decorator du processor de persistance Doctrine d'API Platform qui injecte
|
||||
* automatiquement le site courant de l'utilisateur sur les entites
|
||||
* implementant SiteAwareInterface, si le payload ne precise pas de site.
|
||||
*
|
||||
* S'applique a TOUTES les operations POST/PATCH qui deleguent au persist
|
||||
* processor natif. Les processors custom qui appellent
|
||||
* `$this->persistProcessor->process()` (pattern UserRbacProcessor) passent
|
||||
* aussi par ce decorator, transparent pour les entites non-SiteAware.
|
||||
*
|
||||
* Comportement :
|
||||
* - $data pas SiteAware -> delegation directe (no-op).
|
||||
* - $data SiteAware avec site deja positionne, appelant a `sites.bypass_scope`
|
||||
* -> delegation directe (ex: admin qui cree une entite dans un autre site).
|
||||
* - $data SiteAware avec site deja positionne, appelant SANS `sites.bypass_scope`
|
||||
* -> validation que le site precise appartient aux sites autorises de l'user.
|
||||
* Si non, leve AccessDeniedHttpException (cross-site write interdite).
|
||||
* - $data SiteAware sans site, provider retourne un Site -> injection
|
||||
* puis delegation.
|
||||
* - $data SiteAware sans site, provider retourne null -> throw 400
|
||||
* BadRequestHttpException avec message explicite.
|
||||
*
|
||||
* Volontairement HTTP-only : ne couvre pas les persistances hors API
|
||||
* Platform (fixtures, commandes CLI, imports). Ces contextes doivent
|
||||
* positionner le site explicitement — c'est assume dans la doc
|
||||
* d'adoption (`docs/modules/site-aware.md`).
|
||||
*
|
||||
* @implements ProcessorInterface<mixed|SiteAwareInterface, mixed>
|
||||
*/
|
||||
#[AsDecorator('api_platform.doctrine.orm.state.persist_processor')]
|
||||
final class SiteAwareInjectionProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProcessorInterface $inner,
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if ($data instanceof SiteAwareInterface) {
|
||||
if (null !== $data->getSite()) {
|
||||
// Le payload precise un site explicite : on valide que le site
|
||||
// appartient aux sites autorises de l'utilisateur courant, sauf
|
||||
// si celui-ci dispose de la permission `sites.bypass_scope`
|
||||
// (ex: admin effectuant une operation cross-site).
|
||||
if (!$this->security->isGranted('sites.bypass_scope')) {
|
||||
$user = $this->security->getUser();
|
||||
$explicitSite = $data->getSite();
|
||||
// hasSite() attend un Site concret. Si l'agent entity fait
|
||||
// evoluer la signature vers SiteInterface, le instanceof
|
||||
// reste valide (Site implemente SiteInterface) et le cast
|
||||
// disparaitra naturellement lors du prochain nettoyage.
|
||||
if ($user instanceof User && $explicitSite instanceof Site && !$user->hasSite($explicitSite)) {
|
||||
throw new AccessDeniedHttpException(
|
||||
'Le site specifie n\'est pas dans les sites autorises pour cet utilisateur.'
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Aucun site dans le payload : injection automatique depuis le
|
||||
// site courant de l'utilisateur.
|
||||
$currentSite = $this->currentSiteProvider->get();
|
||||
|
||||
if (null === $currentSite) {
|
||||
throw new BadRequestHttpException(
|
||||
'Impossible de creer l\'enregistrement : aucun site selectionne.',
|
||||
);
|
||||
}
|
||||
|
||||
$data->setSite($currentSite);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->inner->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
}
|
||||
112
src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php
Normal file
112
src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Infrastructure\DataFixtures;
|
||||
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Module\Sites\Domain\Repository\SiteRepositoryInterface;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
/**
|
||||
* Fixtures du module Sites : 3 etablissements de demonstration utilises par
|
||||
* les tickets suivants (rattachement utilisateurs, navbar, etc.).
|
||||
*
|
||||
* Idempotence supportee : le purger Doctrine (ORMPurger) vide la table
|
||||
* `site` avant chaque `doctrine:fixtures:load`. Si le purger est
|
||||
* desactive et la fixture rejouee telle quelle sur une base deja seedee,
|
||||
* le lookup par nom evite le doublon et re-aligne les autres champs.
|
||||
*
|
||||
* Idempotence NON supportee :
|
||||
* - chargement cumulatif apres qu'une autre fixture ait persiste (sans
|
||||
* flush) des Site dans la meme session : `findByName()` s'appuie sur
|
||||
* `findOneBy`, qui n'inspecte pas les entites en attente dans l'unit-of-work
|
||||
* et peut renvoyer null alors qu'un homonyme est deja manage ;
|
||||
* - renommage d'un site : le nom etant la cle de lookup, modifier
|
||||
* `name` dans cette fixture cree un nouveau site et laisse l'ancien
|
||||
* en base (purger desactive). Les autres champs (city, color, etc.)
|
||||
* sont en revanche bien re-synchronises pour un site retrouve.
|
||||
*/
|
||||
class SitesFixtures extends Fixture
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SiteRepositoryInterface $siteRepository,
|
||||
) {}
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
// Chatellerault : bleu Coltura.
|
||||
$this->ensureSite(
|
||||
$manager,
|
||||
name: 'Chatellerault',
|
||||
street: "14 All. d'Argenson",
|
||||
complement: null,
|
||||
postalCode: '86100',
|
||||
city: 'Châtellerault',
|
||||
color: '#056CF2',
|
||||
);
|
||||
|
||||
// Saint-Jean : jaune vif. Le nom du site (identifier) ne reflete
|
||||
// pas la ville reelle (Fontenet) — c'est une nomenclature interne
|
||||
// client.
|
||||
$this->ensureSite(
|
||||
$manager,
|
||||
name: 'Saint-Jean',
|
||||
street: 'Z i',
|
||||
complement: null,
|
||||
postalCode: '17400',
|
||||
city: 'Fontenet',
|
||||
color: '#F3CB00',
|
||||
);
|
||||
|
||||
// Pommevic : vert clair.
|
||||
$this->ensureSite(
|
||||
$manager,
|
||||
name: 'Pommevic',
|
||||
street: '1 Av. Jean Duquesne',
|
||||
complement: null,
|
||||
postalCode: '82400',
|
||||
city: 'Pommevic',
|
||||
color: '#74BF04',
|
||||
);
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree le site s'il n'existe pas encore, sinon re-aligne rue, complement,
|
||||
* code postal, ville et couleur sur les valeurs de reference.
|
||||
*
|
||||
* Note : le nom sert de cle de lookup (il est unique en base) et n'est
|
||||
* donc pas resynchronise. Consequence : renommer un site dans la
|
||||
* fixture cree un nouveau site sans supprimer l'ancien, sauf si le
|
||||
* purger Doctrine est actif (cas nominal de `doctrine:fixtures:load`).
|
||||
*/
|
||||
private function ensureSite(
|
||||
ObjectManager $manager,
|
||||
string $name,
|
||||
string $street,
|
||||
?string $complement,
|
||||
string $postalCode,
|
||||
string $city,
|
||||
string $color,
|
||||
): Site {
|
||||
$site = $this->siteRepository->findByName($name);
|
||||
|
||||
if (null === $site) {
|
||||
$site = new Site($name, $street, $complement, $postalCode, $city, $color);
|
||||
$manager->persist($site);
|
||||
|
||||
return $site;
|
||||
}
|
||||
|
||||
$site->setStreet($street);
|
||||
$site->setComplement($complement);
|
||||
$site->setPostalCode($postalCode);
|
||||
$site->setCity($city);
|
||||
$site->setColor($color);
|
||||
|
||||
return $site;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Module\Sites\Domain\Repository\SiteRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Site>
|
||||
*/
|
||||
class DoctrineSiteRepository extends ServiceEntityRepository implements SiteRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Site::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Site
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function findByName(string $name): ?Site
|
||||
{
|
||||
return $this->findOneBy(['name' => $name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Site>
|
||||
*/
|
||||
public function findAllOrderedByName(): array
|
||||
{
|
||||
/** @var list<Site> $sites */
|
||||
return $this->findBy([], ['name' => 'ASC']);
|
||||
}
|
||||
|
||||
public function save(Site $site): void
|
||||
{
|
||||
$this->getEntityManager()->persist($site);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(Site $site): void
|
||||
{
|
||||
$this->getEntityManager()->remove($site);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
38
src/Module/Sites/SitesModule.php
Normal file
38
src/Module/Sites/SitesModule.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites;
|
||||
|
||||
final class SitesModule
|
||||
{
|
||||
public const string ID = 'sites';
|
||||
public const string LABEL = 'Sites';
|
||||
public const bool REQUIRED = false;
|
||||
|
||||
/**
|
||||
* Liste declarative des permissions RBAC exposees par le module Sites.
|
||||
*
|
||||
* Consommee par la commande `app:sync-permissions` (SyncPermissionsCommand)
|
||||
* qui se charge d'upserter ces entrees dans la table `permission`, de
|
||||
* reactiver les codes precedemment marques orphelins et de marquer comme
|
||||
* orphelins ceux qui ont disparu du code source.
|
||||
*
|
||||
* La cle `module` est auto-injectee par le sync command a partir de
|
||||
* `self::ID`, il est donc inutile de la repeter dans chaque entree.
|
||||
*
|
||||
* Convention de nommage des codes : `module.resource[.sub].action` en
|
||||
* snake_case, le prefixe module devant correspondre exactement a
|
||||
* `self::ID` (verifie par la commande de synchronisation).
|
||||
*
|
||||
* @return array<int, array{code: string, label: string}>
|
||||
*/
|
||||
public static function permissions(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'sites.view', 'label' => 'Voir les sites'],
|
||||
['code' => 'sites.manage', 'label' => 'Gerer les sites (creer, editer, supprimer)'],
|
||||
['code' => 'sites.bypass_scope', 'label' => 'Voir les donnees site-scoped de tous les sites (bypass du filtrage)'],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user