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: 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:
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

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