Module sites (#8)
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s

| 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: #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:
2026-04-20 15:31:58 +00:00
committed by Autin
parent 6b4868b261
commit 6cf5ef4cfc
77 changed files with 7739 additions and 80 deletions

View File

@@ -15,6 +15,14 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
// Note architecture : User.php utilise SiteInterface (Shared) pour les
// type-hints afin de ne pas coupler le module Core au module Sites.
// La seule reference concrete a Site subsiste dans les metadonnees ORM
// (targetEntity) via FQCN string, ce qui est obligatoire pour Doctrine.
// SiteNotAuthorizedException est importee depuis Shared (sa location canonique).
use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Exception\SiteNotAuthorizedException;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -107,6 +115,45 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])]
private Collection $directPermissions;
/**
* Sites autorises pour l'utilisateur (ticket 2 du module Sites).
*
* Relation ManyToMany avec table de jointure `user_site`. Fetch LAZY :
* le chargement est defere jusqu'a l'acces explicite a la collection.
* MeProvider (ou un futur provider avec JOIN FETCH) est responsable de
* precharger cette collection pour /api/me afin d'eviter N+1.
*
* Le groupe `user:list` a ete retire deliberement (securite : evite
* de fuiter la liste des sites de chaque user via GET /api/users).
*
* @var Collection<int, Site>
*/
#[ORM\ManyToMany(targetEntity: 'App\Module\Sites\Domain\Entity\Site', inversedBy: 'users', fetch: 'LAZY')]
#[ORM\JoinTable(name: 'user_site')]
#[Groups(['me:read', 'user:rbac:read', 'user:rbac:write'])]
private Collection $sites;
/**
* Site courant selectionne par l'utilisateur (ticket 2 du module Sites).
*
* Relation ManyToOne nullable : un user peut ne pas avoir encore choisi
* de site actif (par ex. apres creation avant premier login). La FK porte
* `onDelete: SET NULL` pour que la suppression d'un site ne detruise pas
* les users qui le pointaient — ils repassent simplement a `null`.
*
* Doit TOUJOURS pointer vers un site present dans `$sites`. L'invariant
* est enforce par UserRbacProcessor qui bascule automatiquement a `null`
* si le site courant est retire des sites autorises.
*
* Fetch LAZY : MeProvider (ou un futur provider avec JOIN FETCH) assure
* le prechargement pour /api/me. Le groupe `user:list` a ete retire
* deliberement (securite : evite de fuiter le site actif via /api/users).
*/
#[ORM\ManyToOne(targetEntity: 'App\Module\Sites\Domain\Entity\Site', fetch: 'LAZY')]
#[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['me:read'])]
private ?SiteInterface $currentSite = null;
#[ORM\Column]
private ?string $password = null;
@@ -121,6 +168,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
$this->createdAt = new DateTimeImmutable();
$this->rbacRoles = new ArrayCollection();
$this->directPermissions = new ArrayCollection();
$this->sites = new ArrayCollection();
}
public function getId(): ?int
@@ -313,4 +361,95 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
{
$this->plainPassword = null;
}
/**
* @return Collection<int, Site>
*/
public function getSites(): Collection
{
return $this->sites;
}
/**
* Idempotent : ajouter deux fois le meme site n'entraine pas de doublon.
* Synchronise la collection inverse Site::$users en memoire pour eviter
* un etat incoherent entre les deux cotes de la M2M dans une meme
* session Doctrine (cf. ticket 2 review point #1).
*
* Le parametre est type SiteInterface pour eviter le couplage Core → Sites.
* En pratique seule App\Module\Sites\Domain\Entity\Site est passee ici.
*/
public function addSite(SiteInterface $site): static
{
if (!$this->sites->contains($site)) {
$this->sites->add($site);
// @phpstan-ignore-next-line : Site concret toujours passe en pratique
$site->addUser($this);
}
return $this;
}
/**
* Retire un site de la collection + maintient la collection inverse en
* memoire (cf. addSite). Attention : ne met PAS a jour `$currentSite`
* si le site retire en etait le courant — cet invariant est enforce
* par UserRbacProcessor (cote applicatif) ou doit etre maintenu
* explicitement par l'appelant. Voir Risque 2 du ticket 2 spec.
*/
public function removeSite(SiteInterface $site): static
{
if ($this->sites->removeElement($site)) {
// @phpstan-ignore-next-line : Site concret toujours passe en pratique
$site->removeUser($this);
}
return $this;
}
/**
* Garde applicative rapide : teste la presence d'un site dans la
* collection autorisee, via comparaison d'identite d'objet Doctrine.
* Utilise par CurrentSiteProcessor pour valider un switch.
*/
public function hasSite(SiteInterface $site): bool
{
return $this->sites->contains($site);
}
public function getCurrentSite(): ?SiteInterface
{
return $this->currentSite;
}
/**
* Setter brut, sans garde. Usage interne pour les flux qui doivent
* pouvoir positionner un site arbitraire ou null (reset de coherence
* post-PATCH RBAC, fixtures, init). Pour le flux user-facing
* "selectionner un site dans la liste autorisee", utiliser
* switchCurrentSite() qui porte la garde domaine.
*/
public function setCurrentSite(?SiteInterface $currentSite): static
{
$this->currentSite = $currentSite;
return $this;
}
/**
* Garde domaine du switch utilisateur : refuse un site qui n'est pas
* dans la collection autorisee. Levee d'une exception domaine que le
* processor HTTP traduit en 403 (pattern aligne sur Role::ensureDeletable
* → SystemRoleDeletionException).
*
* @throws SiteNotAuthorizedException si $site n'appartient pas a $this->sites
*/
public function switchCurrentSite(SiteInterface $site): void
{
if (!$this->hasSite($site)) {
throw SiteNotAuthorizedException::forSite($site);
}
$this->currentSite = $site;
}
}

View File

@@ -10,9 +10,11 @@ use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\PersistentCollection;
use LogicException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
@@ -29,6 +31,21 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
* - Dernier admin global : impossible de retirer `isAdmin` si c'est le
* dernier administrateur de l'instance, meme par un tiers. Enforce via
* AdminHeadcountGuardInterface.
* - Permission sites.manage : si le payload mute la collection `sites`,
* la permission `sites.manage` est requise en plus de `core.users.manage`.
* - Coherence currentSite (ticket 2 module Sites) : apres persist des
* sites autorises, si le `currentSite` n'est plus dans la collection,
* il est repositionne automatiquement :
* a) repasse a `null` s'il pointait vers un site retire ;
* b) est auto-selectionne sur le premier site de `sites` s'il etait
* null alors que la collection vient d'etre modifiee et n'est pas vide.
* Un second flush est emis uniquement si la coherence a du etre corrigee.
* La garde coherence est skippee si ni les sites ni le currentSite n'ont
* change (evite le silent site-switch sur un PATCH ne touchant pas aux sites).
*
* Atomicite : persistProcessor->process() + ensureCurrentSiteConsistency() sont
* executes dans une meme transaction wrapInTransaction pour eviter un etat
* partiellement persiste en cas d'erreur entre les deux flush.
*
* @implements ProcessorInterface<User, User>
*/
@@ -80,6 +97,87 @@ final class UserRbacProcessor implements ProcessorInterface
}
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
// Detection de la mutation de la collection `sites` avant tout flush.
// La collection est deja denormalisee dans $data quand process() est appele.
// On utilise PersistentCollection::isDirty() pour savoir si l'ORM a detecte
// une modification depuis le chargement initial (ajout/retrait d'elements).
$sitesCollection = $data->getSites();
$sitesWereMutated = $sitesCollection instanceof PersistentCollection
&& $sitesCollection->isDirty();
// Capture de l'ID du currentSite avant persist pour la detection post-flush.
$originalCurrentSiteId = $data->getCurrentSite()?->getId();
// Garde sites.manage : la modification de la collection de sites rattaches
// a un user est une operation sensible qui requiert une permission distincte
// de core.users.manage (evite le bypass de sites.manage via /rbac).
if ($sitesWereMutated && !$this->security->isGranted('sites.manage')) {
throw new AccessDeniedHttpException(
'La modification des sites rattaches a un user requiert la permission sites.manage.'
);
}
// Persistance + correction de coherence currentSite dans une seule transaction.
// wrapInTransaction rollback automatiquement sur toute exception et la re-lance,
// ce qui preserve le comportement attendu pour BadRequestHttpException.
$result = null;
$this->entityManager->wrapInTransaction(function () use (
$data,
$operation,
$uriVariables,
$context,
$sitesWereMutated,
$originalCurrentSiteId,
&$result,
): void {
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
// Garde coherence currentSite (ticket 2 module Sites).
// Post-persist : le champ `sites` a ete applique par le persist processor.
// On s'assure que `currentSite` pointe toujours vers un site present
// dans la collection ou est recale automatiquement — mais UNIQUEMENT si
// les sites ou le currentSite ont effectivement ete touches dans ce PATCH.
$currentSiteChangedByPersist = $originalCurrentSiteId !== $data->getCurrentSite()?->getId();
if ($sitesWereMutated || $currentSiteChangedByPersist) {
$this->ensureCurrentSiteConsistency($data);
}
});
return $result;
}
/**
* Applique deux corrections post-persist sur `currentSite` :
* - si l'actuel n'est plus dans `sites` apres update → repasse a null ;
* - si null et `sites` non vide → auto-selectionne le premier site
* (coherent avec le choix de ne jamais laisser un user rattache a
* plusieurs sites sans contexte courant apres une mutation effective).
*
* N'emet un flush additionnel que si une correction a ete necessaire :
* pas de cout DB sur la majorite des requetes /rbac qui ne touchent pas
* aux sites.
*
* Cette methode ne doit etre appelee que si les sites ont reellement
* ete mutes dans la requete courante (voir logique dans process()).
*/
private function ensureCurrentSiteConsistency(User $user): void
{
$currentSite = $user->getCurrentSite();
$sites = $user->getSites();
$changed = false;
if (null !== $currentSite && !$user->hasSite($currentSite)) {
$user->setCurrentSite(null);
$changed = true;
}
if (null === $user->getCurrentSite() && !$sites->isEmpty()) {
$user->setCurrentSite($sites->first() ?: null);
$changed = true;
}
if ($changed) {
$this->entityManager->flush();
}
}
}

View File

@@ -8,26 +8,50 @@ use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Sites\Domain\Repository\SiteRepositoryInterface;
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use RuntimeException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Fixtures de base du module Core : 3 utilisateurs (1 admin + 2 standards)
* rattaches aux roles systeme RBAC seedes par la migration Version20260414150034.
* rattaches aux roles systeme RBAC seedes par la migration Version20260414150034,
* puis (ticket 2 module Sites) rattaches a au moins un site avec un currentSite
* coherent.
*
* Note : le purger Doctrine execute avant load() supprime l'ensemble des
* entites managees, ce qui inclut la table role. On re-seede donc les roles
* systeme de maniere idempotente avant de rattacher les utilisateurs, afin
* que le workflow "make db-reset && make fixtures" reste one-shot.
*
* Dependance explicite a SitesFixtures (ticket 2) : les 3 sites Chatellerault,
* Saint-Jean et Pommevic doivent etre presents en base avant d'etre rattaches
* aux users. L'inversion volontaire de l'ordre (AppFixtures ← SitesFixtures)
* casse l'independance declaree au ticket 1 : c'est un couplage assume car
* apres ticket 2 le modele metier exprime un besoin legitime de rattachement.
*/
class AppFixtures extends Fixture
class AppFixtures extends Fixture implements DependentFixtureInterface
{
public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly RoleRepositoryInterface $roleRepository,
private readonly SiteRepositoryInterface $siteRepository,
) {}
/**
* @return array<int, class-string>
*/
public function getDependencies(): array
{
// SitesFixtures doit tourner AVANT AppFixtures pour que les sites
// soient disponibles au rattachement des users ci-dessous.
return [SitesFixtures::class];
}
public function load(ObjectManager $manager): void
{
$adminRole = $this->ensureSystemRole(
@@ -43,23 +67,43 @@ class AppFixtures extends Fixture
'Role de base sans permission specifique',
);
// Recupere les 3 sites seedes par SitesFixtures. Si absents, c'est
// une misconfiguration (fixture hors purge ou dependance ignoree) :
// on fail fort avec un message explicite plutot que de continuer
// avec des users orphelins de site.
$chatellerault = $this->requireSite('Chatellerault');
$saintJean = $this->requireSite('Saint-Jean');
$pommevic = $this->requireSite('Pommevic');
$admin = new User();
$admin->setUsername('admin');
$admin->setIsAdmin(true);
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
$admin->addRbacRole($adminRole);
// Admin rattache aux 3 sites pour faciliter le dev / les tests manuels.
$admin->addSite($chatellerault);
$admin->addSite($saintJean);
$admin->addSite($pommevic);
$admin->setCurrentSite($chatellerault);
$manager->persist($admin);
$alice = new User();
$alice->setUsername('alice');
$alice->setPassword($this->passwordHasher->hashPassword($alice, 'alice'));
$alice->addRbacRole($userRole);
// Alice : un seul site, site courant = ce site.
$alice->addSite($chatellerault);
$alice->setCurrentSite($chatellerault);
$manager->persist($alice);
$bob = new User();
$bob->setUsername('bob');
$bob->setPassword($this->passwordHasher->hashPassword($bob, 'bob'));
$bob->addRbacRole($userRole);
// Bob : site different de Alice, pour prouver le filtrage par site
// dans les futurs tests (ticket 4 outillage SiteAware).
$bob->addSite($saintJean);
$bob->setCurrentSite($saintJean);
$manager->persist($bob);
$manager->flush();
@@ -90,4 +134,19 @@ class AppFixtures extends Fixture
return $role;
}
private function requireSite(string $name): Site
{
$site = $this->siteRepository->findByName($name);
if (null === $site) {
throw new RuntimeException(sprintf(
'SitesFixtures doit avoir seede le site "%s" avant le chargement des users. '
.'Verifier que SitesFixtures est bien en dependance de AppFixtures.',
$name,
));
}
return $site;
}
}

View 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();
}
}

View File

@@ -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;
}

View 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;
}
}

View File

@@ -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 {}

View File

@@ -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;
}

View File

@@ -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)
;
}
}

View File

@@ -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)
;
}
}

View File

@@ -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()
;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View 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;
}
}

View File

@@ -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();
}
}

View 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)'],
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat opt-in pour les entites dont la visibilite est scopee par site.
*
* Une entite implementant cette interface sera :
* - filtree en lecture par SiteScopedQueryExtension (collection + item)
* selon le site courant de l'utilisateur authentifie ;
* - alimentee automatiquement en POST/PATCH par SiteAwareInjectionProcessor
* si le payload ne precise pas de site.
*
* L'implementation concrete doit :
* - Declarer une relation ManyToOne vers l'entite concrete Site avec colonne
* `site_id` NOT NULL (targetEntity: \App\Module\Sites\Domain\Entity\Site).
* - Indexer `site_id` en base (sinon le filtre WHERE genere un full-scan).
*
* Les signatures utilisent SiteInterface (et non la classe concrete Site)
* pour que Shared n'importe pas directement le module Sites.
*
* Ne PAS implementer cette interface pour :
* - Des entites globales (catalogue partage, roles, permissions, users).
* - Des entites dont le scope est "par tenant" plus large que le site
* (utiliser TenantAwareInterface le cas echeant).
* - Des entites transversales references par plusieurs sites.
*
* Voir `docs/modules/site-aware.md` pour le guide d'adoption complet.
*/
interface SiteAwareInterface
{
public function getSite(): ?SiteInterface;
public function setSite(SiteInterface $site): void;
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Interface minimale exposant ce que le noyau (Shared/Core) doit connaitre
* d'un Site, sans creer de couplage direct vers le module Sites.
*
* Implemente par App\Module\Sites\Domain\Entity\Site.
* Utilisee comme type-hint dans SiteAwareInterface, User et toute entite
* Shared/Core qui manipule un site sans avoir besoin des details metier.
*/
interface SiteInterface
{
public function getId(): ?int;
public function getName(): ?string;
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Exception;
use App\Shared\Domain\Contract\SiteInterface;
use DomainException;
/**
* Levee lorsqu'un utilisateur tente de selectionner comme site courant un
* site qui ne fait pas partie de ses sites autorises.
*
* Exception purement domaine : la traduction HTTP (403) est faite par le
* CurrentSiteProcessor via try/catch, aligne sur le pattern
* SystemRoleDeletionException du module Core.
*
* Deplacee dans Shared/Domain/Exception/ pour eviter que le module Core
* n'importe directement depuis le module Sites (violation du principe de
* non-couplage inter-modules).
*/
class SiteNotAuthorizedException extends DomainException
{
public static function forSite(SiteInterface $site): self
{
return new self(sprintf(
'Le site "%s" ne fait pas partie de vos sites autorises.',
$site->getName(),
));
}
}