Une spec par ticket dans docs/sites/, alignee sur le pattern RBAC : - ticket-01 : brique de donnees (entite, repo, migration, fixtures, RBAC) - ticket-02 : API Platform CRUD + User<->Site (M2M + currentSite) + admin CRUD - ticket-03 : barre horizontale SiteSelector (consomme MalioSiteSelector) - ticket-04 : outillage opt-in site-aware (interface + extensions + doc) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
33 KiB
Ticket #04 — 4/4 — Outillage opt-in « site-scoped » pour modules métier
1. Objectif
Ce ticket livre l'outillage qui permettra aux modules metier (Commercial, Stock, Production, etc.) de declarer leurs entites comme scopees par site : une fois l'adoption effectuee, un utilisateur ne verra en lecture que les lignes dont site_id correspond a son site courant, et les creations/editions injectent automatiquement ce site courant si le payload ne le precise pas.
Le ticket est volontairement strictement infrastructurel : il n'adopte le pattern sur aucune entite existante. Aucun module metier n'est modifie, aucune migration n'est jouee sur des tables deja en place. Les tickets futurs (ou des tickets annexes par module) adopteront l'interface au cas par cas apres arbitrage metier.
Le ticket livre aussi une documentation developpeur (docs/modules/site-aware.md) qui explique comment et quand adopter le pattern, et quelles entites ne doivent pas l'adopter (roles, permissions, users, catalogues globaux, etc.).
2. Périmètre
IN
- Creer le contrat
App\Shared\Domain\Contract\SiteAwareInterface: interface minimalegetSite(): ?Site/setSite(Site $site): void, place dansShared/Domain/Contract/pour que les modules metier en dependent sans importer le module Sites. - Creer
CurrentSiteProvider(module Sites, couche Application) qui resout le site courant a partir deSecurity::getUser()+User::getCurrentSite(), et renvoienullsi : pas d'user authentifie,currentSitenull, ou module Sites inactif dansconfig/modules.php. - Creer
SiteScopedQueryExtension(module Sites, Infrastructure API Platform) implementantQueryCollectionExtensionInterfaceetQueryItemExtensionInterface: ajoute la clauseWHERE <alias>.site = :currentSitequand la resource cible implementeSiteAwareInterface, le provider retourne un site, et l'user n'a passites.bypass_scope. - Creer
SiteAwareInjectionProcessor(module Sites, decorator deapi_platform.doctrine.orm.state.persist_processor) : avant de deleguer la persistance, si$dataest une instance deSiteAwareInterfaceet n'a pas deja de site positionne, injecte lecurrentSitefourni par le provider. - Declarer la permission
sites.bypass_scopedansSitesModule::permissions(). Admin ou user avec cette permission → le filtre Query Extension saute, visibilite globale. - Ecrire
docs/modules/site-aware.md: guide developpeur complet (cf. section 10). - Tests PHPUnit avec une entite fictive
FakeSiteAwareEntitydeclaree uniquement dans la suite de tests (jamais en production) pour prouver que le filtrage et l'injection automatique fonctionnent bout en bout. - Tests du cas "Sites desactive" : desactiver
SitesModule::classdansconfig/modules.phpavant la suite, re-sync, verifier que l'outillage est no-op et qu'aucun test existant ne casse.
OUT
- Adoption du pattern sur une entite metier reelle (ex:
Supplier,Client, etc.) : hors scope. C'est aux tickets annexes ou aux tickets de feature de l'adopter quand necessaire, en suivant la doc. - Migration de donnees "legacy" : ce ticket documente le piege (entites existantes sans
site_id) mais ne livre aucune migration par module. - Support CLI / commandes console : le filtre est uniquement actif dans le contexte API Platform (via les extensions). Une commande batch lira toutes les lignes sans filtre, comportement attendu pour les taches admin. Une eventuelle reimplementation via un Doctrine SQL Filter generique est citee en alternative non retenue (cf. Risque 4).
- Double-ecriture avec un Doctrine
SQLFilter: non retenu dans ce ticket. Le filtre via extension API Platform couvre 100% des usages HTTP, qui est le seul contexte ou le site courant a un sens metier (user authentifie). Les commandes CLI doivent gerer la portee explicitement. - Changement du comportement cote front : aucun. Le filtrage est transparent, le front continue de faire
GET /api/supplierset recoit une collection pre-filtree. Si une entite est adoptee au ticket futur, la page existante continue de fonctionner sans modification. - Support d'entites "partiellement site-aware" (colonne site_id nullable, certaines lignes globales partagees) : non retenu. Une entite est soit SiteAware, soit globale. Si un module a besoin de la semantique hybride, il devra creer deux entites distinctes.
3. Fichiers à créer
Shared — Contrat
/home/m-tristan/workspace/Coltura/src/Shared/Domain/Contract/SiteAwareInterface.php: interface minimale. Depends uniquement du typeApp\Module\Sites\Domain\Entity\Site, qui est deja couple cote Core depuis le ticket 2 — le placement dans Shared n'introduit pas de nouvelle dependance transversale non souhaitee.
Module Sites — Application
/home/m-tristan/workspace/Coltura/src/Module/Sites/Application/Service/CurrentSiteProvider.php: service injecte partout ou le site courant doit etre lu (extensions, processor, futurs voters). Gere les trois cas de retournull: pas d'user,currentSitenull, module desactive.
Module Sites — Infrastructure
/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteScopedQueryExtension.php: une seule classe, implementant a la foisQueryCollectionExtensionInterfaceetQueryItemExtensionInterface. Le comportement est identique pour les deux, modulo que l'item manque retourne 404 (API Platform converti ungetOneOrNullResultnull en 404)./home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessor.php: decorator sur le persist processor Doctrine. Injecte le site courant sur$datasi applicable, puis delegue a$persistProcessor.
Documentation
/home/m-tristan/workspace/Coltura/docs/modules/site-aware.md: guide developpeur (cf. contenu section 10).
Tests
/home/m-tristan/workspace/Coltura/tests/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteScopedQueryExtensionTest.php: tests d'integration (KernelTestCase) avec l'entiteFakeSiteAwareEntity(declaree uniquement dans le dossier de tests). Verifie :- Le filtre s'applique sur une resource
SiteAwarequand le provider retourne un site. - Le filtre est no-op si
SiteAwaremais provider null. - Le filtre est no-op si resource non
SiteAware. - Le filtre est no-op si user a
sites.bypass_scope. totalItemsHydra reflete bien le filtrage.
- Le filtre s'applique sur une resource
/home/m-tristan/workspace/Coltura/tests/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessorTest.php: tests unitaires (TestCasepur) avec mocks. Verifie :$dataSiteAware sans site → injection du site courant.$dataSiteAware avec site deja positionne → pas d'overwrite.$datanon-SiteAware → delegation directe sans modification.- Provider retourne null (module off ou user sans site) ET
$dataSiteAware sans site → BadRequestHttpException (400) "aucun site selectionne".
/home/m-tristan/workspace/Coltura/tests/Module/Sites/Application/Service/CurrentSiteProviderTest.php: tests unitairesTestCase. Couvre :- User authentifie avec currentSite → retourne le Site.
- User authentifie sans currentSite → null.
- Pas d'user → null.
- Module desactive dans config/modules.php de test → null meme si user.currentSite existe.
/home/m-tristan/workspace/Coltura/tests/Fixtures/SiteAware/FakeSiteAwareEntity.php: entite Doctrine minimale (id,name,site) utilisee uniquement en tests. Mapping Doctrine declare via un#[ORM\Entity]mais la table n'existe jamais en prod car la fixture n'est jamais chargee hors tests. Alternative : utiliser un schema DB dedie au dossier de tests, cree a la volee par un helper setUp. A trancher a l'implementation.
4. Fichiers à modifier
/home/m-tristan/workspace/Coltura/src/Module/Sites/SitesModule.php: ajouter la permissionsites.bypass_scopedanspermissions():Note importante : la methode['code' => 'sites.bypass_scope', 'label' => 'Voir les donnees site-scoped de tous les sites (bypass du filtrage)'],permissions()signale l'existence de la permission mais c'est la commandeapp:sync-permissions(inchangee) qui la positionne en base./home/m-tristan/workspace/Coltura/config/services.yaml: aucun changement requis.SiteScopedQueryExtension,SiteAwareInjectionProcessoretCurrentSiteProvidersont autoconfigures via les_defaultsdu module. Le decorator du persist processor est declare via#[AsDecorator]ou via tag (cf. section 8)./home/m-tristan/workspace/Coltura/phpunit.dist.xml: aucune modification requise si la config des fixtures de tests est autonome. SiFakeSiteAwareEntitynecessite un mapping dedie, l'option la plus propre est undoctrine.yaml.testajoute viawhen@test, sans polluer la config dev/prod (cf. Risque 3).
5. Contrat SiteAwareInterface
<?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;
}
Remarque sur le typage du getter
getSite(): ?Site retourne nullable pour deux raisons :
- Coherence avec des entites en cours de construction (pre-persist, avant injection).
- Compat avec des colonnes qui deviendraient nullable lors d'une migration progressive (ex: deploiement en 2 etapes avec backfill).
En regime nominal, apres persistance, getSite() ne doit jamais etre null. Un assert($entity->getSite() !== null) dans du code sensible est acceptable.
6. Service CurrentSiteProvider
Responsabilite
Expose une seule methode get(): ?Site. Resout le site courant selon la chaine :
- Si
SitesModule::classn'est pas present dansconfig/modules.php→null. - Sinon, si
Security::getUser()est null →null. - Sinon, si
$user->getCurrentSite()est null →null. - Sinon → retourne le Site.
Detection d'activation du module
Deux strategies possibles :
Strategie A — lire config/modules.php au boot du service (pattern ModulesProvider) :
public function __construct(
private readonly Security $security,
#[Autowire(param: 'kernel.project_dir')]
string $projectDir,
) {
$moduleClasses = require $projectDir.'/config/modules.php';
$this->sitesActive = in_array(SitesModule::class, $moduleClasses, true);
}
Strategie B — extraire un service ActiveModulesRegistry partage entre ModulesProvider et CurrentSiteProvider (refactor mineur).
Recommandation : strategie A dans ce ticket pour rester minimal. Si un troisieme consommateur apparait (probable), extraire le registry dans un ticket dedie.
Contrat complet
<?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;
final class CurrentSiteProvider
{
private readonly bool $sitesActive;
public function __construct(
private readonly Security $security,
#[Autowire(param: 'kernel.project_dir')]
string $projectDir,
) {
$moduleClasses = require $projectDir.'/config/modules.php';
$this->sitesActive = in_array(SitesModule::class, $moduleClasses, true);
}
public function get(): ?Site
{
if (!$this->sitesActive) {
return null;
}
$user = $this->security->getUser();
if (!$user instanceof User) {
return null;
}
return $user->getCurrentSite();
}
}
7. Extensions API Platform
SiteScopedQueryExtension
Implemente a la fois QueryCollectionExtensionInterface et QueryItemExtensionInterface. La logique est commune et factorisee dans une methode privee applyScope().
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);
}
private function applyScope(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
): void {
// 1) Resource SiteAware ?
if (!is_subclass_of($resourceClass, SiteAwareInterface::class)) {
return;
}
// 2) Bypass admin / permission dediee ?
if ($this->security->isGranted('sites.bypass_scope')) {
return;
}
// 3) Site courant disponible ?
$currentSite = $this->currentSiteProvider->get();
if ($currentSite === null) {
// Decision assumee : no-op plutot que collection vide (cf. section 11 Risque 1).
return;
}
// 4) Applique WHERE site = :currentSite
$rootAlias = $queryBuilder->getRootAliases()[0];
$parameterName = $queryNameGenerator->generateParameterName('currentSite');
$queryBuilder
->andWhere(sprintf('%s.site = :%s', $rootAlias, $parameterName))
->setParameter($parameterName, $currentSite);
}
Ordre de priorite
L'extension doit s'executer apres les filtres natifs API Platform (Pagination, Order, Search). Priorite par defaut (0) convient, mais si un autre filtre custom est ajoute plus tard, verifier qu'il ne court-circuite pas. Declarer la priorite explicitement via #[AsTaggedItem(priority: -100)] est une option pour s'executer en dernier et etre robuste a l'ordre d'ajout d'autres extensions.
JSON-LD totalItems
API Platform execute un COUNT(*) separe pour produire le totalItems dans la reponse Hydra. Ce count passe par les memes extensions → le totalItems reflete automatiquement le filtrage. A verifier par un test dedie (cf. section 11).
applyToItem et 404
Quand un GET /api/suppliers/{id} cible un supplier qui existe en base mais appartient a un autre site, la requete SELECT ... WHERE id = :id AND site = :currentSite retourne null → API Platform converti en 404. Comportement desire : l'user ne doit pas pouvoir distinguer "cet item n'existe pas" de "cet item existe mais pas dans mon site" (anti-enumeration).
8. Processor d'injection automatique SiteAwareInjectionProcessor
Pattern decorator
Le plus propre en API Platform est de decorer le processor de persistance Doctrine :
<?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\CurrentSiteProvider;
use App\Shared\Domain\Contract\SiteAwareInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
#[AsDecorator('api_platform.doctrine.orm.state.persist_processor')]
final class SiteAwareInjectionProcessor implements ProcessorInterface
{
public function __construct(
private readonly ProcessorInterface $inner,
private readonly CurrentSiteProvider $currentSiteProvider,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if ($data instanceof SiteAwareInterface && $data->getSite() === null) {
$currentSite = $this->currentSiteProvider->get();
if ($currentSite === null) {
throw new BadRequestHttpException(
'Impossible de creer l\'enregistrement : aucun site selectionne.',
);
}
$data->setSite($currentSite);
}
return $this->inner->process($data, $operation, $uriVariables, $context);
}
}
Effets de bord et compatibilite
- S'applique a TOUS les processors qui heritent du persist processor natif API Platform. Si un processor custom (ex:
UserRbacProcessor) delegue aapi_platform.doctrine.orm.state.persist_processorvia autowire, il passe aussi par ce decorator — transparent pour User (non SiteAware). - N'ecrase jamais un site deja positionne : un admin qui POST un supplier avec
site: '/api/sites/2'garde cette valeur, meme si soncurrentSiteest 1. La regle metier "site different autorise uniquement si l'user a plusieurs sites" du ticket n'est pas implementee dans ce decorator : c'est au voter de securite (hors scope de ce ticket) de l'enforcer si necessaire. - Erreur explicite si pas de site : BadRequestHttpException plutot qu'un
nullsilencieux. Le user comprend que l'operation necessite un site actif.
Alternative rejetee — EventListener Doctrine prePersist
Un listener Doctrine intercepterait toutes les persistances, y compris hors HTTP (CLI, fixtures). Rejetee car :
CurrentSiteProviderdepend deSecurity, indisponible en CLI.- Les fixtures doivent positionner explicitement le site (cf.
AppFixturesticket 2), ce qui est plus correct metier. - Les commandes batch peuvent vouloir creer des entites sans site actif (backoffice multi-sites) — un listener silencieux les bloquerait.
Le decorator HTTP-only est plus aligne avec le principe "opt-in controle".
9. Permission sites.bypass_scope
Déclaration
Dans SitesModule::permissions() :
public static function permissions(): array
{
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)'],
];
}
Semantique
- User avec
sites.bypass_scope→ le filtreWHERE site = :currentSiten'est pas applique. La collection retournee est globale (toutes les lignes). - User admin (
isAdmin = true) →is_granted()retourne toujours true pour toute permission → le bypass est automatique. Pas besoin d'assignation explicite. - Cas typique d'attribution : un admin financier qui veut consolider les suppliers a l'echelle groupe.
Absence de bypass sur le processor
Le processor d'injection ne respecte pas sites.bypass_scope : meme un user avec bypass verra son currentSite injecte si le payload n'en precise pas. Justification : l'injection n'est pas une restriction, c'est un default value. Le user bypass peut toujours envoyer un site explicite different.
10. Documentation développeur — docs/modules/site-aware.md
Le fichier livre 5 sections :
10.1 Quand adopter SiteAwareInterface
- Entite qui existe "par site" : chaque ligne appartient a un et un seul site, les users ne doivent voir que celles de leur site courant.
- Exemples :
Supplier(chaque site a ses fournisseurs),Order,StockEntry,Employee(si chaque site a sa propre equipe).
10.2 Quand NE PAS adopter
- Entites globales :
Role,Permission,User(les users sont transverses, rattaches a plusieurs sites). - Catalogues partages : produits, categories, taxes — s'ils sont mutualises entre sites.
- Entites transversales :
Invoiceglobale,Contractmulti-site. - Entites dont la portee naturelle est "par tenant" plus large que "par site" : utiliser
TenantAwareInterface(si pertinent pour le projet multi-tenant futur).
10.3 Comment adopter (check-list)
- Entite :
- Implementer
App\Shared\Domain\Contract\SiteAwareInterface. - Ajouter la relation
#[ORM\ManyToOne(targetEntity: Site::class)] #[ORM\JoinColumn(name: 'site_id', nullable: false, onDelete: 'CASCADE')] private Site $site. - Implementer
getSite()etsetSite().
- Implementer
- Migration :
- Creer une migration dediee au module concerne (ou racine si init critique, voir
CLAUDE.md). ALTER TABLE <table> ADD COLUMN site_id INT NOT NULL.- Gestion legacy : si des lignes existent deja, la colonne ne peut pas etre NOT NULL directement. Strategie :
- Ajouter la colonne nullable.
- Backfill manuel ou par script (ex: tout rattacher au site "Chatellerault" par defaut, ou laisser l'admin arbitrer).
- Rendre la colonne NOT NULL via une seconde migration.
- Index :
CREATE INDEX IDX_<table>_site ON <table> (site_id). Obligatoire — le filtreWHERE site_id = ?genere un full-scan sinon.
- Creer une migration dediee au module concerne (ou racine si init critique, voir
- Serialisation : ajouter
siteau groupe de lecture API (#[Groups(['<resource>:read'])]) pour que le front voie a quel site appartient la ligne. - Processor custom : si le module a deja un processor sur l'operation POST/PATCH, s'assurer qu'il delegue a
api_platform.doctrine.orm.state.persist_processor(et nonObjectManager::persistdirect) pour que le decorator d'injection s'applique.
10.4 Comportement en mode degrade
- Module Sites desactive (
config/modules.php) :CurrentSiteProvider::get()retournenull→ le filtre ne s'applique plus → toutes les lignes sont visibles, comme avant l'adoption. L'app reste fonctionnelle, juste sans segmentation. Mais : la colonnesite_idNOT NULL reste en place, et le processor d'injection leve une 400 sur tout POST/PATCH sans site explicite. Consequence : un module adopte ne peut pas vivre sans Sites active pour les operations d'ecriture, sauf a envoyer systematiquement unsiteexplicite dans le payload. A documenter fortement. - User sans site (sites.length = 0, currentSite = null) : meme comportement → no-op en lecture, 400 en ecriture. Le module doit documenter l'UX degradee.
10.5 Gotchas et anti-patterns
- Sous-collections (
/api/clients/{id}/contacts) : l'extension s'applique a la resource chargee, iciContact. SiContactest SiteAware, le filtre passe. Si seulClientest SiteAware (et Contact herite du scope via son parent), le filtre ne se propage pas automatiquement : il faut soit rendre Contact SiteAware aussi (redondance), soit ajouter un filtre custom qui verifiecontact.client.site == currentSite. Ce ticket ne couvre pas le second cas. - Jointures : si un repository custom fait une requete sans passer par API Platform (ex:
findByX()appele depuis un processor), le filtre ne s'applique pas. Responsabilite du developpeur du module d'ajouter->andWhere('x.site = :currentSite')manuellement ou de passer par leCurrentSiteProvider. - Tests d'integration : les tests existants d'un module adopte devront soit logger un user avec un site actif, soit utiliser
sites.bypass_scopepour voir toute la donnee. La suite de fixtures devra positionner un site coherent sur les entites de test. - Cascade delete d'un site : le ticket 2 met
user.current_site_ida NULL si le site est supprime. Si une entite adoptee declareonDelete: CASCADEsur sa FK site, elle perdra toutes ses lignes au delete d'un site. Choisir explicitement : cascade (aligne sur l'invariant "une ligne SiteAware a toujours un site") ou blocage (empecher la suppression d'un site s'il reste des lignes adoptees).
11. Risques et points d'attention
Risque 1 — Comportement "no-op si pas de site courant"
La spec choisit no-op plutot que collection vide quand CurrentSiteProvider::get() === null. Arbitrage :
- No-op (retenu) : un user sans site voit tout, un admin sans site aussi. Risque de fuite de donnees d'un site a l'autre, mais l'app reste utilisable.
- Collection vide : un user sans site ne voit rien. Plus strict, mais bloque un admin qui consulterait l'app avant d'avoir configure un site.
Le ticket retient no-op car l'app reste utilisable. La permission sites.bypass_scope est explicite pour les admins qui veulent voir tout. Si la decision metier evolue, le changement est localise dans SiteScopedQueryExtension::applyScope().
Risque 2 — Fuite de donnees entre sites
Si un module adopte SiteAwareInterface mais qu'un repository custom court-circuite API Platform, le filtre ne s'applique pas. Consequence : un endpoint custom (GET /api/suppliers/top-rated) pourrait exposer tous les suppliers sans filtrage.
Mitigation : la doc insiste sur la responsabilite du developpeur d'adopter le filtre manuellement dans les repositories custom. Un test d'integration par module adopte est fortement recommande.
Risque 3 — FakeSiteAwareEntity en tests
L'entite fictive doit etre mappee par Doctrine pour que le QueryBuilder fonctionne. Trois options :
- Declaration via
when@test: ajouterconfig/packages/doctrine.yamldans un blocwhen@testavec un mapping dedie pointant verstests/Fixtures/SiteAware/. Propre mais ajoute un fichier de config. - Attribute Doctrine dans le fichier de test : fonctionne si le kernel de test decouvre le namespace. Pas elegant.
- Mock integral du QueryBuilder : pas d'entite reelle, on mock Doctrine. Tests plus unitaires mais moins realistes.
Recommandation : option 1 (mapping when@test). La classe reste dans tests/ et ne pollue jamais la prod.
Risque 4 — Pas de Doctrine SQL Filter
Un Doctrine SQLFilter appliquerait le filtrage a toutes les requetes Doctrine, y compris hors API Platform (CLI, fixtures, cron, reports). Plus defensif mais plus risque :
- Les commandes batch devraient l'activer/desactiver explicitement.
- Les fixtures devraient le desactiver pour seeder plusieurs sites.
- Les tests d'integration devraient le gerer.
Le ticket retient la strategie API Platform only car le site courant n'a de sens que dans un contexte HTTP authentifie. Si un besoin emerge (rapport automatique scope par site, webhook multi-site, etc.), le refactor vers un SQL filter sera localise.
Risque 5 — Priorite des extensions
Si un autre module introduit plus tard une extension avec une clause HAVING ou un setMaxResults qui suppose que le filtre de base n'est pas modifie, il peut y avoir des surprises. Declarer explicitement une priorite negative (priority: -100) sur SiteScopedQueryExtension via #[AsTaggedItem] la fait s'executer apres la plupart des filtres natifs, ce qui est generalement souhaitable pour un filtre applicatif.
Risque 6 — UserRbacProcessor et les autres processors custom
Le decorator SiteAwareInjectionProcessor decore api_platform.doctrine.orm.state.persist_processor. Si un module declare un processor custom qui ne delegue pas au persist processor (ex: fait $em->persist($data); $em->flush() directement), l'injection de site n'a pas lieu. Le module doit explicitement passer par le persist processor pour beneficier du pattern.
A mitiger par un test qui genere une entite FakeSiteAwareEntity via un POST api_platform.doctrine.orm.state.persist_processor mocke et verifie que le decorator a bien injecte le site.
Risque 7 — Performance du require au boot
CurrentSiteProvider fait un require 'config/modules.php' au constructeur. Le fichier est un simple return [...] → l'overhead est minimal et le resultat est opcache par PHP. Meme pattern que ModulesProvider, sans regression perf documentee.
Risque 8 — Doc developpeur en francais vs anglais
Le fichier docs/modules/site-aware.md s'adresse aux developpeurs de Coltura. Il est redige en francais, aligne sur la convention projet (CLAUDE.md : "commentaires en francais, code en anglais"). Aucun extrait de code ne doit etre traduit, seules les explications.
12. Plan de tests
Tests unitaires (TestCase pur)
CurrentSiteProviderTest
testReturnsNullIfSitesModuleInactive: config/modules.php de test ne contient pas SitesModule → null meme si user + site fixent.testReturnsNullIfNoUser: Security::getUser() = null → null.testReturnsNullIfUserHasNoCurrentSite: user.currentSite = null → null.testReturnsSiteIfAllConditionsMet: user + currentSite set → retourne le Site.
SiteAwareInjectionProcessorTest
testInjectsCurrentSiteOnNewSiteAwareData: $data SiteAware + getSite() = null + provider retourne Site → setSite appele avec le bon site.testDoesNotOverrideExistingSite: $data SiteAware + getSite() non-null → pas d'appel a setSite, delegation directe.testSkipsNonSiteAwareData: $data qui n'implemente pas SiteAwareInterface → aucune modification, delegation.testThrowsBadRequestIfNoCurrentSite: $data SiteAware + getSite() = null + provider retourne null → BadRequestHttpException 400.testDelegatesToInnerAlways: inner->process est appele dans tous les cas (sauf quand 400 throw).
Tests d'intégration (KernelTestCase)
SiteScopedQueryExtensionTest
Fixture : 2 sites (siteA, siteB), 3 FakeSiteAwareEntity (2 sur siteA, 1 sur siteB), 1 user rattache a siteA.
testCollectionFilteredByCurrentSite: user avec currentSite=siteA → collection retourne 2 entites (celles de siteA).testCollectionNotFilteredIfNoCurrentSite: user sans currentSite → collection retourne 3 entites (no-op).testCollectionNotFilteredIfResourceNotSiteAware: query sur une entite non SiteAware → aucune clause additionnelle.testCollectionNotFilteredIfBypassPermission: user avecsites.bypass_scope→ 3 entites.testCollectionNotFilteredIfSitesModuleInactive: desactiver SitesModule → provider null → no-op, 3 entites.testItemNotFoundIfWrongSite: GET sur un id dont le site est siteB alors que user sur siteA → 404 (ounullretourne par le QueryBuilder).testItemFoundIfCorrectSite: GET sur un id du site courant → 200.testTotalItemsReflectsFilter: collection HydratotalItems: 2(et non 3) quand le filtre s'applique.
Tests de non-régression
Apres implementation, re-jouer toute la suite existante en mode module Sites active et en mode module desactive. Aucun test existant ne doit changer.
13. Ordre d'exécution recommandé
- Contrat —
SiteAwareInterfacedansShared/Domain/Contract/. - Provider —
CurrentSiteProvider+ tests unitaires. - Processor decorator —
SiteAwareInjectionProcessor+ tests unitaires avec mocks. - Entite de test —
FakeSiteAwareEntity+ mappingwhen@testsi retenu. - Query extension —
SiteScopedQueryExtension+ tests d'integration. - Permission bypass — ajout dans
SitesModule::permissions(),make sync-permissions, verifier en base. - Tests exhaustifs — faire passer la matrice des 8 cas d'integration.
- Tests non-regression —
make testavec SitesModule actif puis inactif. - Documentation — rediger
docs/modules/site-aware.md(5 sections). - CS fixer —
make php-cs-fixer-allow-risky. - DoD — valider la check-list section 14.
14. Critères d'acceptation (DoD)
App\Shared\Domain\Contract\SiteAwareInterfaceexiste avec les deux methodesgetSite(): ?SiteetsetSite(Site $site): void.CurrentSiteProvider::get()retournenulldans les 3 cas : pas d'user, pas de currentSite, module inactif. Retourne le Site sinon.SiteScopedQueryExtensionapplique le WHERE sur les resources SiteAware quand un site courant est resolu et que l'user n'a passites.bypass_scope.SiteAwareInjectionProcessorinjecte automatiquement le site courant sur POST/PATCH d'entites SiteAware sans site explicite.SiteAwareInjectionProcessorleve une 400 si l'entite SiteAware n'a pas de site ET que le provider retourne null.- Permission
sites.bypass_scopedeclaree dansSitesModule::permissions()et presente en base apresapp:sync-permissions. docs/modules/site-aware.mdlivre les 5 sections (quand/comment adopter, anti-patterns, degrade, gotchas).- Tests d'integration : au moins 8 cas couvrant filtrage collection/item, no-op dans les 3 scenarios (pas de site, resource non SiteAware, bypass), et
totalItemsHydra. - Tests unitaires sur
CurrentSiteProvideretSiteAwareInjectionProcessor. - Aucune migration sur des tables metier existantes (
supplier,client,user, ...) — seules les migrations du ticket 1 et 2 sont presentes. Verifier viamake migration-migrate: aucun SQL attendu sur la suite existante. make testpasse avecSitesModule::classactif dansconfig/modules.php.make testpasse avecSitesModule::classdesactive dansconfig/modules.php.make php-cs-fixer-allow-riskypropre sur les fichiers nouveaux.- Aucun module metier (Commercial, Core hors User, etc.) n'a ete modifie par ce ticket — diff ne touche que
src/Shared/,src/Module/Sites/,tests/, etdocs/.