Files
Starseed/src/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteScopedQueryExtension.php
T
tristan 6cf5ef4cfc
Auto Tag Develop / tag (push) Successful in 6s
Module sites (#8)
| 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>
2026-04-20 15:31:58 +00:00

109 lines
4.2 KiB
PHP

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