Files
Coltura/docs/sites/ticket-04-spec.md
tristan 105574ba2f docs(sites) : specifications des 4 tickets de l'epic Sites
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>
2026-04-17 15:45:54 +02:00

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 minimale getSite(): ?Site / setSite(Site $site): void, place dans Shared/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 de Security::getUser() + User::getCurrentSite(), et renvoie null si : pas d'user authentifie, currentSite null, ou module Sites inactif dans config/modules.php.
  • Creer SiteScopedQueryExtension (module Sites, Infrastructure API Platform) implementant QueryCollectionExtensionInterface et QueryItemExtensionInterface : ajoute la clause WHERE <alias>.site = :currentSite quand la resource cible implemente SiteAwareInterface, le provider retourne un site, et l'user n'a pas sites.bypass_scope.
  • Creer SiteAwareInjectionProcessor (module Sites, decorator de api_platform.doctrine.orm.state.persist_processor) : avant de deleguer la persistance, si $data est une instance de SiteAwareInterface et n'a pas deja de site positionne, injecte le currentSite fourni par le provider.
  • Declarer la permission sites.bypass_scope dans SitesModule::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 FakeSiteAwareEntity declaree 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::class dans config/modules.php avant 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/suppliers et 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 type App\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 retour null : pas d'user, currentSite null, module desactive.

Module Sites — Infrastructure

  • /home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteScopedQueryExtension.php : une seule classe, implementant a la fois QueryCollectionExtensionInterface et QueryItemExtensionInterface. Le comportement est identique pour les deux, modulo que l'item manque retourne 404 (API Platform converti un getOneOrNullResult null 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 $data si 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'entite FakeSiteAwareEntity (declaree uniquement dans le dossier de tests). Verifie :
    • Le filtre s'applique sur une resource SiteAware quand le provider retourne un site.
    • Le filtre est no-op si SiteAware mais provider null.
    • Le filtre est no-op si resource non SiteAware.
    • Le filtre est no-op si user a sites.bypass_scope.
    • totalItems Hydra reflete bien le filtrage.
  • /home/m-tristan/workspace/Coltura/tests/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessorTest.php : tests unitaires (TestCase pur) avec mocks. Verifie :
    • $data SiteAware sans site → injection du site courant.
    • $data SiteAware avec site deja positionne → pas d'overwrite.
    • $data non-SiteAware → delegation directe sans modification.
    • Provider retourne null (module off ou user sans site) ET $data SiteAware sans site → BadRequestHttpException (400) "aucun site selectionne".
  • /home/m-tristan/workspace/Coltura/tests/Module/Sites/Application/Service/CurrentSiteProviderTest.php : tests unitaires TestCase. 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 permission sites.bypass_scope dans permissions() :
    ['code' => 'sites.bypass_scope', 'label' => 'Voir les donnees site-scoped de tous les sites (bypass du filtrage)'],
    
    Note importante : la methode permissions() signale l'existence de la permission mais c'est la commande app:sync-permissions (inchangee) qui la positionne en base.
  • /home/m-tristan/workspace/Coltura/config/services.yaml : aucun changement requis. SiteScopedQueryExtension, SiteAwareInjectionProcessor et CurrentSiteProvider sont autoconfigures via les _defaults du 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. Si FakeSiteAwareEntity necessite un mapping dedie, l'option la plus propre est un doctrine.yaml.test ajoute via when@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 :

  1. Si SitesModule::class n'est pas present dans config/modules.phpnull.
  2. Sinon, si Security::getUser() est null → null.
  3. Sinon, si $user->getCurrentSite() est null → null.
  4. 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 a api_platform.doctrine.orm.state.persist_processor via 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 son currentSite est 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 null silencieux. 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 :

  • CurrentSiteProvider depend de Security, indisponible en CLI.
  • Les fixtures doivent positionner explicitement le site (cf. AppFixtures ticket 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 filtre WHERE site = :currentSite n'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 : Invoice globale, Contract multi-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)

  1. 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() et setSite().
  2. 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 :
      1. Ajouter la colonne nullable.
      2. Backfill manuel ou par script (ex: tout rattacher au site "Chatellerault" par defaut, ou laisser l'admin arbitrer).
      3. Rendre la colonne NOT NULL via une seconde migration.
    • Index : CREATE INDEX IDX_<table>_site ON <table> (site_id). Obligatoire — le filtre WHERE site_id = ? genere un full-scan sinon.
  3. Serialisation : ajouter site au groupe de lecture API (#[Groups(['<resource>:read'])]) pour que le front voie a quel site appartient la ligne.
  4. 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 non ObjectManager::persist direct) pour que le decorator d'injection s'applique.

10.4 Comportement en mode degrade

  • Module Sites desactive (config/modules.php) : CurrentSiteProvider::get() retourne null → le filtre ne s'applique plus → toutes les lignes sont visibles, comme avant l'adoption. L'app reste fonctionnelle, juste sans segmentation. Mais : la colonne site_id NOT 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 un site explicite 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, ici Contact. Si Contact est SiteAware, le filtre passe. Si seul Client est 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 verifie contact.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 le CurrentSiteProvider.
  • Tests d'integration : les tests existants d'un module adopte devront soit logger un user avec un site actif, soit utiliser sites.bypass_scope pour 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_id a NULL si le site est supprime. Si une entite adoptee declare onDelete: CASCADE sur 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 :

  1. Declaration via when@test : ajouter config/packages/doctrine.yaml dans un bloc when@test avec un mapping dedie pointant vers tests/Fixtures/SiteAware/. Propre mais ajoute un fichier de config.
  2. Attribute Doctrine dans le fichier de test : fonctionne si le kernel de test decouvre le namespace. Pas elegant.
  3. 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

  1. testReturnsNullIfSitesModuleInactive : config/modules.php de test ne contient pas SitesModule → null meme si user + site fixent.
  2. testReturnsNullIfNoUser : Security::getUser() = null → null.
  3. testReturnsNullIfUserHasNoCurrentSite : user.currentSite = null → null.
  4. testReturnsSiteIfAllConditionsMet : user + currentSite set → retourne le Site.

SiteAwareInjectionProcessorTest

  1. testInjectsCurrentSiteOnNewSiteAwareData : $data SiteAware + getSite() = null + provider retourne Site → setSite appele avec le bon site.
  2. testDoesNotOverrideExistingSite : $data SiteAware + getSite() non-null → pas d'appel a setSite, delegation directe.
  3. testSkipsNonSiteAwareData : $data qui n'implemente pas SiteAwareInterface → aucune modification, delegation.
  4. testThrowsBadRequestIfNoCurrentSite : $data SiteAware + getSite() = null + provider retourne null → BadRequestHttpException 400.
  5. 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.

  1. testCollectionFilteredByCurrentSite : user avec currentSite=siteA → collection retourne 2 entites (celles de siteA).
  2. testCollectionNotFilteredIfNoCurrentSite : user sans currentSite → collection retourne 3 entites (no-op).
  3. testCollectionNotFilteredIfResourceNotSiteAware : query sur une entite non SiteAware → aucune clause additionnelle.
  4. testCollectionNotFilteredIfBypassPermission : user avec sites.bypass_scope → 3 entites.
  5. testCollectionNotFilteredIfSitesModuleInactive : desactiver SitesModule → provider null → no-op, 3 entites.
  6. testItemNotFoundIfWrongSite : GET sur un id dont le site est siteB alors que user sur siteA → 404 (ou null retourne par le QueryBuilder).
  7. testItemFoundIfCorrectSite : GET sur un id du site courant → 200.
  8. testTotalItemsReflectsFilter : collection Hydra totalItems: 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é

  1. ContratSiteAwareInterface dans Shared/Domain/Contract/.
  2. ProviderCurrentSiteProvider + tests unitaires.
  3. Processor decoratorSiteAwareInjectionProcessor + tests unitaires avec mocks.
  4. Entite de testFakeSiteAwareEntity + mapping when@test si retenu.
  5. Query extensionSiteScopedQueryExtension + tests d'integration.
  6. Permission bypass — ajout dans SitesModule::permissions(), make sync-permissions, verifier en base.
  7. Tests exhaustifs — faire passer la matrice des 8 cas d'integration.
  8. Tests non-regressionmake test avec SitesModule actif puis inactif.
  9. Documentation — rediger docs/modules/site-aware.md (5 sections).
  10. CS fixermake php-cs-fixer-allow-risky.
  11. DoD — valider la check-list section 14.

14. Critères d'acceptation (DoD)

  • App\Shared\Domain\Contract\SiteAwareInterface existe avec les deux methodes getSite(): ?Site et setSite(Site $site): void.
  • CurrentSiteProvider::get() retourne null dans les 3 cas : pas d'user, pas de currentSite, module inactif. Retourne le Site sinon.
  • SiteScopedQueryExtension applique le WHERE sur les resources SiteAware quand un site courant est resolu et que l'user n'a pas sites.bypass_scope.
  • SiteAwareInjectionProcessor injecte automatiquement le site courant sur POST/PATCH d'entites SiteAware sans site explicite.
  • SiteAwareInjectionProcessor leve une 400 si l'entite SiteAware n'a pas de site ET que le provider retourne null.
  • Permission sites.bypass_scope declaree dans SitesModule::permissions() et presente en base apres app:sync-permissions.
  • docs/modules/site-aware.md livre 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 totalItems Hydra.
  • Tests unitaires sur CurrentSiteProvider et SiteAwareInjectionProcessor.
  • Aucune migration sur des tables metier existantes (supplier, client, user, ...) — seules les migrations du ticket 1 et 2 sont presentes. Verifier via make migration-migrate : aucun SQL attendu sur la suite existante.
  • make test passe avec SitesModule::class actif dans config/modules.php.
  • make test passe avec SitesModule::class desactive dans config/modules.php.
  • make php-cs-fixer-allow-risky propre 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/, et docs/.