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:
2026-04-20 15:11:07 +02:00
parent 03c761eed4
commit 296befe187
18 changed files with 1263 additions and 27 deletions

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Module\Sites\Application\Service;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Sites\SitesModule;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use function in_array;
/**
* Resout le site courant de l'utilisateur authentifie pour les besoins de
* l'outillage opt-in "site-aware" (ticket 4 module Sites).
*
* Consomme par :
* - SiteScopedQueryExtension : filtrage automatique des collections API.
* - SiteAwareInjectionProcessor : injection automatique sur POST/PATCH.
*
* Retourne `null` dans trois cas distincts (chacun volontairement
* silencieux pour que les extensions/processor deviennent no-op sans
* erreur visible) :
* 1. Le module Sites est desactive dans `config/modules.php`.
* 2. Aucun user n'est authentifie (appel depuis un endpoint public).
* 3. L'user authentifie n'a pas de `currentSite` positionne (cas rare
* grace a la garde `UserRbacProcessor::ensureCurrentSiteConsistency`).
*
* Le flag `sitesActive` est calcule UNE FOIS au boot du service pour
* eviter un `require` a chaque resolution.
*/
final class CurrentSiteProvider implements CurrentSiteProviderInterface
{
private readonly bool $sitesActive;
public function __construct(
private readonly Security $security,
#[Autowire(param: 'kernel.project_dir')]
string $projectDir,
) {
// Lit config/modules.php (tableau de FQCN) et verifie la presence
// de SitesModule::class. Pattern aligne sur ModulesProvider.
$configPath = $projectDir.'/config/modules.php';
$moduleClasses = file_exists($configPath) ? require $configPath : [];
$this->sitesActive = in_array(SitesModule::class, $moduleClasses, true);
}
/**
* Retourne le site courant de l'utilisateur authentifie, ou null si
* l'une des 3 conditions de desactivation est remplie (cf. docblock
* de classe).
*/
public function get(): ?Site
{
if (!$this->sitesActive) {
return null;
}
$user = $this->security->getUser();
if (!$user instanceof User) {
return null;
}
return $user->getCurrentSite();
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Module\Sites\Application\Service;
use App\Module\Sites\Domain\Entity\Site;
/**
* Contrat de resolution du site courant pour l'outillage opt-in
* "site-aware" (ticket 4 module Sites).
*
* Facilite le test de l'extension et du processor en permettant un mock
* sans dependre de l'implementation concrete (qui garde `final` pour
* l'immutabilite du service en prod).
*
* Retourne `null` dans trois cas (cf. CurrentSiteProvider) :
* - module Sites desactive dans config/modules.php
* - pas d'user authentifie
* - user sans currentSite positionne
*/
interface CurrentSiteProviderInterface
{
public function get(): ?Site;
}

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

View File

@@ -32,6 +32,7 @@ final class SitesModule
return [
['code' => 'sites.view', 'label' => 'Voir les sites'],
['code' => 'sites.manage', 'label' => 'Gerer les sites (creer, editer, supprimer)'],
['code' => 'sites.bypass_scope', 'label' => 'Voir les donnees site-scoped de tous les sites (bypass du filtrage)'],
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
use App\Module\Sites\Domain\Entity\Site;
/**
* Contrat opt-in pour les entites dont la visibilite est scopee par site.
*
* Une entite implementant cette interface sera :
* - filtree en lecture par SiteScopedQueryExtension (collection + item)
* selon le site courant de l'utilisateur authentifie ;
* - alimentee automatiquement en POST/PATCH par SiteAwareInjectionProcessor
* si le payload ne precise pas de site.
*
* L'implementation concrete doit :
* - Declarer une relation ManyToOne(Site::class) avec colonne `site_id` NOT NULL.
* - Indexer `site_id` en base (sinon le filtre WHERE genere un full-scan).
*
* Ne PAS implementer cette interface pour :
* - Des entites globales (catalogue partage, roles, permissions, users).
* - Des entites dont le scope est "par tenant" plus large que le site
* (utiliser TenantAwareInterface le cas echeant).
* - Des entites transversales references par plusieurs sites.
*
* Voir `docs/modules/site-aware.md` pour le guide d'adoption complet.
*/
interface SiteAwareInterface
{
public function getSite(): ?Site;
public function setSite(Site $site): void;
}