Merge remote-tracking branch 'origin/feat/module-site-backend' into feat/admin-tables-filter-pagination

# Conflicts:
#	frontend/modules/sites/pages/admin/sites.vue
This commit is contained in:
2026-04-20 17:04:04 +02:00
24 changed files with 611 additions and 82 deletions

View File

@@ -18,8 +18,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\Module\Sites\Domain\Exception\SiteNotAuthorizedException;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Exception\SiteNotAuthorizedException;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -126,17 +132,19 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
/**
* Sites autorises pour l'utilisateur (ticket 2 du module Sites).
*
* Relation ManyToMany avec table de jointure `user_site`. Fetch EAGER
* pour la meme raison que `$rbacRoles` : garantir que `/api/me` et les
* voters futurs aient toujours la collection hydratee, meme dans un
* contexte de refresh JWT hors EntityManager. Le surcout SQL reste
* negligeable (≤ quelques sites par user en pratique).
* 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: Site::class, inversedBy: 'users', fetch: 'EAGER')]
#[ORM\ManyToMany(targetEntity: 'App\Module\Sites\Domain\Entity\Site', inversedBy: 'users', fetch: 'LAZY')]
#[ORM\JoinTable(name: 'user_site')]
#[Groups(['me:read', 'user:list', 'user:rbac:read', 'user:rbac:write'])]
#[Groups(['me:read', 'user:rbac:read', 'user:rbac:write'])]
private Collection $sites;
/**
@@ -150,11 +158,15 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* 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: Site::class, fetch: 'EAGER')]
#[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', 'user:list'])]
private ?Site $currentSite = null;
#[Groups(['me:read'])]
private ?SiteInterface $currentSite = null;
#[ORM\Column]
private ?string $password = null;
@@ -377,11 +389,15 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* 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(Site $site): static
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);
}
@@ -395,9 +411,10 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* par UserRbacProcessor (cote applicatif) ou doit etre maintenu
* explicitement par l'appelant. Voir Risque 2 du ticket 2 spec.
*/
public function removeSite(Site $site): static
public function removeSite(SiteInterface $site): static
{
if ($this->sites->removeElement($site)) {
// @phpstan-ignore-next-line : Site concret toujours passe en pratique
$site->removeUser($this);
}
@@ -409,12 +426,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* collection autorisee, via comparaison d'identite d'objet Doctrine.
* Utilise par CurrentSiteProcessor pour valider un switch.
*/
public function hasSite(Site $site): bool
public function hasSite(SiteInterface $site): bool
{
return $this->sites->contains($site);
}
public function getCurrentSite(): ?Site
public function getCurrentSite(): ?SiteInterface
{
return $this->currentSite;
}
@@ -426,7 +443,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* "selectionner un site dans la liste autorisee", utiliser
* switchCurrentSite() qui porte la garde domaine.
*/
public function setCurrentSite(?Site $currentSite): static
public function setCurrentSite(?SiteInterface $currentSite): static
{
$this->currentSite = $currentSite;
@@ -441,7 +458,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
*
* @throws SiteNotAuthorizedException si $site n'appartient pas a $this->sites
*/
public function switchCurrentSite(Site $site): void
public function switchCurrentSite(SiteInterface $site): void
{
if (!$this->hasSite($site)) {
throw SiteNotAuthorizedException::forSite($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,14 +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 n'est pas vide (pratique pour un
* premier rattachement).
* 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>
*/
@@ -88,13 +97,51 @@ final class UserRbacProcessor implements ProcessorInterface
}
}
$result = $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();
// 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.
$this->ensureCurrentSiteConsistency($data);
// 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;
}
@@ -104,11 +151,14 @@ final class UserRbacProcessor implements ProcessorInterface
* - 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).
* 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
{