feat(sites) : outillage opt-in site-aware (ticket 4/4)
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>
This commit is contained in:
@@ -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)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Shared\Domain\Contract\SiteAwareInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Decorator du processor de persistance Doctrine d'API Platform qui injecte
|
||||
* automatiquement le site courant de l'utilisateur sur les entites
|
||||
* implementant SiteAwareInterface, si le payload ne precise pas de site.
|
||||
*
|
||||
* S'applique a TOUTES les operations POST/PATCH qui deleguent au persist
|
||||
* processor natif. Les processors custom qui appellent
|
||||
* `$this->persistProcessor->process()` (pattern UserRbacProcessor) passent
|
||||
* aussi par ce decorator, transparent pour les entites non-SiteAware.
|
||||
*
|
||||
* 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 sans site, provider retourne un Site -> injection
|
||||
* puis delegation.
|
||||
* - $data SiteAware sans site, provider retourne null -> throw 400
|
||||
* BadRequestHttpException avec message explicite.
|
||||
*
|
||||
* Volontairement HTTP-only : ne couvre pas les persistances hors API
|
||||
* Platform (fixtures, commandes CLI, imports). Ces contextes doivent
|
||||
* positionner le site explicitement — c'est assume dans la doc
|
||||
* d'adoption (`docs/modules/site-aware.md`).
|
||||
*
|
||||
* @implements ProcessorInterface<mixed|SiteAwareInterface, mixed>
|
||||
*/
|
||||
#[AsDecorator('api_platform.doctrine.orm.state.persist_processor')]
|
||||
final class SiteAwareInjectionProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProcessorInterface $inner,
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if ($data instanceof SiteAwareInterface && null === $data->getSite()) {
|
||||
$currentSite = $this->currentSiteProvider->get();
|
||||
|
||||
if (null === $currentSite) {
|
||||
throw new BadRequestHttpException(
|
||||
'Impossible de creer l\'enregistrement : aucun site selectionne.',
|
||||
);
|
||||
}
|
||||
|
||||
$data->setSite($currentSite);
|
||||
}
|
||||
|
||||
return $this->inner->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user