Livre l'infrastructure permettant aux modules metier de declarer leurs entites comme "scopees par site" via SiteAwareInterface. Strictement opt-in : aucune entite metier touchee, aucune migration sur tables existantes. Composants : - SiteAwareInterface (Shared/Domain/Contract) : getSite/setSite - CurrentSiteProvider + interface (Module/Sites/Application) : resolve ?Site selon 3 conditions (module actif, user authentifie, currentSite). Interface extraite pour mockabilite en tests (implementation reste final). - SiteScopedQueryExtension : QueryCollection + QueryItem API Platform, ajoute WHERE site = :currentSite si resource SiteAware + provider non-null + pas sites.bypass_scope. - SiteAwareInjectionProcessor : decorator de api_platform.doctrine.orm. state.persist_processor (#[AsDecorator]). Injecte currentSite sur entites SiteAware sans site ; throw 400 si provider null. - Permission sites.bypass_scope declaree dans SitesModule::permissions(). Tests : - FakeSiteAwareEntity dans tests/Fixtures/ + mapping when@test dans doctrine.yaml. Table creee a la volee via SchemaTool dans setUp. schema:update --force ajoute dans test-db-setup pour que fixtures:load ne crashe pas au purger. - 17 tests dedies au ticket 4 (CurrentSiteProvider unitaire, Injection Processor unitaire, Extension integration avec 7 cas couvrant filtrage collection + item, bypass, no-op, resource non SiteAware). - SitesModuleTest : verifie le set de 3 permissions + que le decorator est bien enregistre sur le persist processor. Documentation docs/modules/site-aware.md : guide developpeur 8 sections (quand/ne pas adopter, comment, migration, mode degrade, anti-patterns, exemple d'adoption Supplier, cascade delete). Upgrade @malio/layer-ui 1.4.0 → 1.4.2 (bug 1.4.0 : tailwind.config.ts oublie dans les files publies npm → classe rounded-malio manquante sur les DataTables). Simplification tailwind.config.ts Coltura : retrait des colors/fontFamily/borderRadius dupliques, seule la specifique projet (primary, secondary, tertiary, m.secondary, m.tertiary) est conservee. Tests : 201/201 avec et sans SitesModule actif (2 skipped en disabled). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
109 lines
4.2 KiB
PHP
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)
|
|
;
|
|
}
|
|
}
|