fix(sites) : processors transactionnels + garde sites.manage + anti-TOCTOU
- UserRbacProcessor : persist + ensureCurrentSiteConsistency wrappes dans
wrapInTransaction (plus de double flush non atomique qui pouvait laisser
currentSite orphelin sur un crash entre les deux flush).
- UserRbacProcessor : detecte la mutation de `sites` via
PersistentCollection::isDirty() et verifie is_granted('sites.manage')
avant de deleguer (empeche core.users.manage de contourner sites.manage).
- UserRbacProcessor : skip ensureCurrentSiteConsistency si ni sites ni
currentSite n'ont ete modifies (plus de bascule silencieuse de site sur
un simple toggle isAdmin apres suppression de site).
- CurrentSiteProcessor : refresh($user) avant hasSite() pour fermer la
fenetre TOCTOU entre /rbac revoke et /me/current-site. Catch
OptimisticLockException pour etre pret a un futur @ORM\Version.
- SiteAwareInjectionProcessor : valide un site explicite contre
$user->getSites() (bypass via sites.bypass_scope) — bloque le cross-site
write quand l'entite expose `site` en ecriture.
This commit is contained in:
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -21,11 +22,17 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
* Flux :
|
||||
* 1. Recupere l'user authentifie via Security.
|
||||
* 2. Extrait le site cible depuis la ressource denormalisee.
|
||||
* 3. Valide que le site fait partie des `sites` de l'user — sinon leve
|
||||
* 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.
|
||||
* 4. Positionne `currentSite`, flush, retourne l'user pour normalisation
|
||||
* 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
|
||||
@@ -57,15 +64,35 @@ final class CurrentSiteProcessor implements ProcessorInterface
|
||||
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 {
|
||||
$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->wrapInTransaction(function () use ($user, $targetSite): void {
|
||||
// Re-fetch de l'user + ses collections depuis la BDD (elimination TOCTOU).
|
||||
$this->entityManager->refresh($user);
|
||||
|
||||
$this->entityManager->flush();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,13 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -23,8 +27,11 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
*
|
||||
* Comportement :
|
||||
* - $data pas SiteAware -> delegation directe (no-op).
|
||||
* - $data SiteAware avec site deja positionne -> delegation directe
|
||||
* (l'admin qui envoie un site explicite garde ce site).
|
||||
* - $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
|
||||
@@ -43,20 +50,43 @@ 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 && null === $data->getSite()) {
|
||||
$currentSite = $this->currentSiteProvider->get();
|
||||
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.',
|
||||
);
|
||||
if (null === $currentSite) {
|
||||
throw new BadRequestHttpException(
|
||||
'Impossible de creer l\'enregistrement : aucun site selectionne.',
|
||||
);
|
||||
}
|
||||
|
||||
$data->setSite($currentSite);
|
||||
}
|
||||
|
||||
$data->setSite($currentSite);
|
||||
}
|
||||
|
||||
return $this->inner->process($data, $operation, $uriVariables, $context);
|
||||
|
||||
Reference in New Issue
Block a user