Module sites #8

Merged
tristan merged 9 commits from feat/module-site-backend into develop 2026-04-20 15:31:59 +00:00
Owner
Numéro du ticket Titre du ticket

Description de la PR

Modification du .env

Check list

  • Pas de régression
  • TU/TI/TF rédigée
  • TU/TI/TF OK
  • CHANGELOG modifié
| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [x] Pas de régression - [x] TU/TI/TF rédigée - [x] TU/TI/TF OK - [ ] CHANGELOG modifié
tristan self-assigned this 2026-04-20 13:12:20 +00:00
tristan added 5 commits 2026-04-20 13:12:20 +00:00
Module Sites optionnel et desactivable via config/modules.php.
Entite Site (nom unique, ville, CP FR, couleur hex, adresse),
repository + impl Doctrine, migration racine (namespace DoctrineMigrations
conforme exception CLAUDE.md), fixtures idempotentes (Chatellerault,
Saint-Jean, Pommevic), permissions RBAC sites.view/sites.manage.
Tests unitaires + validation via KernelTestCase (UniqueEntity, regex
hex et CP, NotBlank, Length).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Exposition de Site via API Platform (5 operations RBAC sites.view/sites.manage),
relation User.sites (M2M user_site EAGER) + User.currentSite (M2O nullable,
ON DELETE SET NULL). Endpoint PATCH /api/me/current-site via ressource
virtuelle + processor (SiteNotAuthorizedException → 403). UserRbacProcessor
etendu avec gardes post-persist : auto-reset si currentSite retire, auto-select
premier site si null + sites non vide.

Page /admin/sites (DataTable + drawer creation/edition + modale suppression).
UserRbacDrawer etendu avec section "Sites autorises". Colonne "Sites" ajoutee
dans la table /admin/users (liste des noms separes par virgule). Sidebar
entree Sites (module: sites, permission: sites.view).

Refactor adresse : split full_address en street + complement (nullable) + getter
computed Site::getFullAddress() multi-lignes. Migration ALTER dediee pour
compat devs ayant deja joue le ticket 1. Fixtures avec vraies adresses
(Chatellerault/Fontenet/Pommevic).

Doctrine : inversedBy synchrone User.sites <-> Site.users pour maintenir la
collection inverse en memoire. User::switchCurrentSite() porte la garde
domaine (throw SiteNotAuthorizedException), aligne sur Role::ensureDeletable.
Helper skipIfSitesModuleDisabled centralise dans AbstractApiTestCase.

Tests : 182/182 (182/182 aussi module desactive, 2 skipped). 29 nouveaux tests
PHPUnit (CRUD API, switch currentSite, cascade DB, /api/me enrichi, extension
/rbac, gardes structurelles fullAddress/currentSite ignores, anti-cycle
Site.users). 11 tests Vitest sur la validation hex couleur.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Barre horizontale en haut de l'app qui liste les sites autorises de
l'utilisateur et permet de switcher d'un click. Consomme le composant
MalioSiteSelector de @malio/layer-ui 1.4.0 (upgrade depuis 1.3.0).

Composables :
- useModules (shared) : consomme /api/modules, expose isModuleActive.
  Pattern aligne sur useSidebar.
- useCurrentSite (layer sites) : singleton state, switchSite optimistic
  avec rollback sur erreur, garde anti-double-submit, propagation au
  store auth via action setCurrentSite dediee.

Composant :
- SiteSelector.vue : wrapper thin autour de MalioSiteSelector. Texte
  blanc uniforme (conforme maquette Figma) avec taille 24px forcee via
  labelClass="text-2xl". aria-label du group via ariaGroupLabel i18n.

Integration :
- Middleware auth.global.ts : chargement parallele sidebar + modules.
- layouts/default.vue : render conditionnel si module Sites actif ET
  user.sites.length > 0.
- logout.vue : reset des 3 composables (sidebar, modules, currentSite)
  dans un try/finally.
- nuxt.config.ts : auto-detection des composables/ de chaque layer
  module (necessaire car imports.dirs explicite override les defaults
  Nuxt).

Couleurs fixtures finales : Chatellerault #056CF2, Saint-Jean #F3CB00,
Pommevic #74BF04. Charge aux admins de choisir des teintes foncees
(texte blanc non contrastable via calcul WCAG, design choisi).

Tests : 40 Vitest (color, useModules, useSidebar, useCurrentSite,
SiteSelector) incluant garde anti-regression pour useI18n hors setup.
182/182 PHPUnit backend, avec et sans module actif.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
matthieu reviewed 2026-04-20 13:49:16 +00:00
matthieu left a comment
Owner

Code review

Found 3 issues:

  1. Import direct inter-module CoreSites dans une entité de domaine (CLAUDE.md : « Communication inter-modules par Shared/Domain/Contract/ ou domain events — jamais d'import direct entre modules »)

use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Sites\Domain\Exception\SiteNotAuthorizedException;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;

  1. Couche Shared/Domain/Contract/ qui dépend directement de l'entité concrète Sites/Domain/Entity/Site — rend l'interface « partagée » tributaire du module Sites (CLAUDE.md : même règle, et Shared/Domain/Contract/ est décrit comme contenant des « Interfaces inter-modules » indépendantes)

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.
*

  1. navigateTo('/login') placé hors du try/finally : si auth.logout() rejette, le finally exécute bien les trois reset*(), puis l'exception se propage hors de onMounted avant la ligne 27 → l'utilisateur reste bloqué sur /logout avec l'état déjà effacé. Le redirect devrait être à l'intérieur du finally (ou après un catch).

onMounted(async () => {
try {
await auth.logout()
} finally {
// Les resets sont garantis meme si auth.logout() rejette : eviter
// qu'un user suivant (connecte sur le meme onglet) voie l'etat de
// l'ancien. Les trois fonctions reset sont synchrones et ne
// peuvent pas throw (juste des assignations reactives).
resetSidebar()
resetModules()
resetCurrentSite()
}
await navigateTo('/login')
})
</script>

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

### Code review Found 3 issues: 1. Import direct inter-module `Core` → `Sites` dans une entité de domaine (CLAUDE.md : « Communication inter-modules par `Shared/Domain/Contract/` ou domain events — jamais d'import direct entre modules ») https://gitea.malio.fr/MALIO-DEV/Coltura/src/commit/296befe187e12393afcba62d8d211996e459a026/src/Module/Core/Domain/Entity/User.php#L16-L21 2. Couche `Shared/Domain/Contract/` qui dépend directement de l'entité concrète `Sites/Domain/Entity/Site` — rend l'interface « partagée » tributaire du module Sites (CLAUDE.md : même règle, et `Shared/Domain/Contract/` est décrit comme contenant des « Interfaces inter-modules » indépendantes) https://gitea.malio.fr/MALIO-DEV/Coltura/src/commit/296befe187e12393afcba62d8d211996e459a026/src/Shared/Domain/Contract/SiteAwareInterface.php#L5-L11 3. `navigateTo('/login')` placé **hors** du `try/finally` : si `auth.logout()` rejette, le `finally` exécute bien les trois `reset*()`, puis l'exception se propage hors de `onMounted` avant la ligne 27 → l'utilisateur reste bloqué sur `/logout` avec l'état déjà effacé. Le redirect devrait être à l'intérieur du `finally` (ou après un `catch`). https://gitea.malio.fr/MALIO-DEV/Coltura/src/commit/296befe187e12393afcba62d8d211996e459a026/frontend/modules/core/pages/logout.vue#L14-L29 🤖 Generated with [Claude Code](https://claude.ai/code) <sub>- If this code review was useful, please react with 👍. Otherwise, react with 👎.</sub>
@@ -16,1 +24,4 @@
resetModules()
resetCurrentSite()
}
await navigateTo('/login')
Owner

Si auth.logout() (ligne 17) rejette, le finally exécute bien les trois resets mais l'exception se propage ensuite hors du handler onMounted — la ligne await navigateTo('/login') ne s'exécute jamais. L'utilisateur reste bloqué sur /logout avec état vidé. Déplacer navigateTo('/login') dans le finally (après les resets), ou entourer d'un try { await auth.logout() } catch {} finally { ...; await navigateTo('/login') }.

Si `auth.logout()` (ligne 17) rejette, le `finally` exécute bien les trois resets mais l'exception se propage ensuite hors du handler `onMounted` — la ligne `await navigateTo('/login')` ne s'exécute jamais. L'utilisateur reste bloqué sur `/logout` avec état vidé. Déplacer `navigateTo('/login')` dans le `finally` (après les resets), ou entourer d'un `try { await auth.logout() } catch {} finally { ...; await navigateTo('/login') }`.
@@ -15,6 +15,8 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Module\Sites\Domain\Entity\Site;
Owner

Import direct CoreSites (entité + exception) : viole la règle CLAUDE.md « jamais d'import direct entre modules » (ligne 138). Piste : introduire une interface SiteInterface dans Shared/Domain/Contract/ et typer User::$currentSite / hasSite() / switchCurrentSite() contre cette interface. L'exception SiteNotAuthorizedException peut aussi être remontée dans Shared/Domain/Exception/.

Import direct `Core` → `Sites` (entité + exception) : viole la règle CLAUDE.md « jamais d'import direct entre modules » (ligne 138). Piste : introduire une interface `SiteInterface` dans `Shared/Domain/Contract/` et typer `User::$currentSite` / `hasSite()` / `switchCurrentSite()` contre cette interface. L'exception `SiteNotAuthorizedException` peut aussi être remontée dans `Shared/Domain/Exception/`.
@@ -0,0 +4,4 @@
namespace App\Shared\Domain\Contract;
use App\Module\Sites\Domain\Entity\Site;
Owner

Shared/Domain/Contract/ ne doit dépendre de aucun module (cf. CLAUDE.md:138 et description de Contract/ ligne 23). En typant getSite(): ?Site / setSite(Site) contre l'entité concrète du module Sites, tout module qui adopte SiteAwareInterface hérite d'une dépendance transitive vers Module\Sites — ce qui défait la raison d'être de Shared/Contract/. À remplacer par un SiteInterface (dans Shared/Domain/Contract/) que Module\Sites\Domain\Entity\Site implémente.

`Shared/Domain/Contract/` ne doit dépendre de **aucun** module (cf. CLAUDE.md:138 et description de `Contract/` ligne 23). En typant `getSite(): ?Site` / `setSite(Site)` contre l'entité concrète du module `Sites`, tout module qui adopte `SiteAwareInterface` hérite d'une dépendance transitive vers `Module\Sites` — ce qui défait la raison d'être de `Shared/Contract/`. À remplacer par un `SiteInterface` (dans `Shared/Domain/Contract/`) que `Module\Sites\Domain\Entity\Site` implémente.
matthieu reviewed 2026-04-20 13:53:21 +00:00
matthieu left a comment
Owner

Code review — compléments (analyse Codex)

En repassant avec un modèle indépendant, trois findings supplémentaires high-confidence sur le cloisonnement RBAC sites ↔ users :

  1. Le champ sites est serialisé dans le groupe user:list (exposé via GET /api/users gardé par core.users.view). Conséquence : tout porteur de core.users.view lit les IRIs des sites de tous les users, même sans sites.view — alors que Site lui-même est gardé par sites.view. Info leak contournant la permission dédiée.

  2. Le champ sites est aussi writable dans le groupe user:rbac:write sur PATCH /api/users/{id}/rbac gardé uniquement par core.users.manage. Un user-manager sans sites.manage peut s'ajouter/retirer à n'importe quel site (ou l'ajouter à un autre user), puis /api/me/current-site pour basculer dedans. Escalade de privilèges contournant sites.manage.

#[ORM\ManyToMany(targetEntity: Site::class, inversedBy: 'users', fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_site')]
#[Groups(['me:read', 'user:list', 'user:rbac:read', 'user:rbac:write'])]
private Collection $sites;

  1. UserRbacProcessor::ensureCurrentSiteConsistency() auto-sélectionne $sites->first() dès que currentSite est null avec des sites disponibles, y compris sur un PATCH /rbac qui n'a pas touché sites. Après une suppression de site (FK onDelete: SET NULL ligne 141), une simple édition de rôle bascule silencieusement l'user dans un site arbitraire — change les lectures/écritures scopées ensuite sans action explicite.

*/
private function ensureCurrentSiteConsistency(User $user): void
{
$currentSite = $user->getCurrentSite();
$sites = $user->getSites();
$changed = false;
if (null !== $currentSite && !$user->hasSite($currentSite)) {
$user->setCurrentSite(null);
$changed = true;
}
if (null === $user->getCurrentSite() && !$sites->isEmpty()) {
$user->setCurrentSite($sites->first() ?: null);
$changed = true;
}
if ($changed) {
$this->entityManager->flush();
}
}

Bonus (fragilité) : SiteAwareInjectionProcessor n'injecte que si site est null mais ne valide jamais un site explicite envoyé dans le payload. Si une future entité SiteAware expose site en écriture à des non-admins, elle devient cross-site writable sans garde. Le docblock dit « l'admin qui envoie un site explicite garde ce site » — à durcir ou à documenter comme contrat strict à la charge de chaque entité.

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

🤖 Generated with Claude Code + Codex

### Code review — compléments (analyse Codex) En repassant avec un modèle indépendant, trois findings supplémentaires high-confidence sur le cloisonnement RBAC sites ↔ users : 1. **Le champ `sites` est serialisé dans le groupe `user:list`** (exposé via `GET /api/users` gardé par `core.users.view`). Conséquence : tout porteur de `core.users.view` lit les IRIs des sites de tous les users, même sans `sites.view` — alors que `Site` lui-même est gardé par `sites.view`. Info leak contournant la permission dédiée. 2. **Le champ `sites` est aussi writable dans le groupe `user:rbac:write`** sur `PATCH /api/users/{id}/rbac` gardé uniquement par `core.users.manage`. Un user-manager sans `sites.manage` peut s'ajouter/retirer à n'importe quel site (ou l'ajouter à un autre user), puis `/api/me/current-site` pour basculer dedans. Escalade de privilèges contournant `sites.manage`. https://gitea.malio.fr/MALIO-DEV/Coltura/src/commit/296befe187e12393afcba62d8d211996e459a026/src/Module/Core/Domain/Entity/User.php#L123-L126 3. **`UserRbacProcessor::ensureCurrentSiteConsistency()` auto-sélectionne `$sites->first()`** dès que `currentSite` est null avec des sites disponibles, y compris sur un `PATCH /rbac` qui n'a pas touché `sites`. Après une suppression de site (FK `onDelete: SET NULL` ligne 141), une simple édition de rôle bascule silencieusement l'user dans un site arbitraire — change les lectures/écritures scopées ensuite sans action explicite. https://gitea.malio.fr/MALIO-DEV/Coltura/src/commit/296befe187e12393afcba62d8d211996e459a026/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php#L112-L132 **Bonus (fragilité)** : `SiteAwareInjectionProcessor` n'injecte que si `site` est null mais ne valide jamais un `site` explicite envoyé dans le payload. Si une future entité SiteAware expose `site` en écriture à des non-admins, elle devient cross-site writable sans garde. Le docblock dit « l'admin qui envoie un site explicite garde ce site » — à durcir ou à documenter comme contrat strict à la charge de chaque entité. https://gitea.malio.fr/MALIO-DEV/Coltura/src/commit/296befe187e12393afcba62d8d211996e459a026/src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessor.php#L48-L63 🤖 Generated with [Claude Code](https://claude.ai/code) + Codex
@@ -110,0 +122,4 @@
*/
#[ORM\ManyToMany(targetEntity: Site::class, inversedBy: 'users', fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_site')]
#[Groups(['me:read', 'user:list', 'user:rbac:read', 'user:rbac:write'])]
Owner

Les groupes de sérialisation de sites exposent la collection au-delà du scope sites.* :

  • user:listGET /api/users (gardé par core.users.view) renvoie les IRIs des sites de chaque user. Quiconque peut lister les users apprend les appartenances, même sans sites.view.
  • user:rbac:writePATCH /api/users/{id}/rbac (gardé par core.users.manage) permet d'écrire sites sans sites.manage. Un user-manager peut ainsi s'ajouter/retirer à un site arbitraire puis basculer via /api/me/current-site.

Pistes : retirer sites de user:list (ou introduire un groupe user:list:full gardé par sites.view), et dans UserRbacProcessor refuser la mutation de sites si l'appelant n'a pas sites.manage.

Les groupes de sérialisation de `sites` exposent la collection au-delà du scope `sites.*` : - `user:list` → `GET /api/users` (gardé par `core.users.view`) renvoie les IRIs des sites de chaque user. Quiconque peut lister les users apprend les appartenances, même sans `sites.view`. - `user:rbac:write` → `PATCH /api/users/{id}/rbac` (gardé par `core.users.manage`) permet d'écrire `sites` sans `sites.manage`. Un user-manager peut ainsi s'ajouter/retirer à un site arbitraire puis basculer via `/api/me/current-site`. Pistes : retirer `sites` de `user:list` (ou introduire un groupe `user:list:full` gardé par `sites.view`), et dans `UserRbacProcessor` refuser la mutation de `sites` si l'appelant n'a pas `sites.manage`.
@@ -84,0 +121,4 @@
$changed = true;
}
if (null === $user->getCurrentSite() && !$sites->isEmpty()) {
Owner

Le bloc if (null === currentSite && !sites->isEmpty()) s'applique sur tout PATCH /rbac, même si la requête n'a pas touché sites. Scénario : un admin supprime un site → la FK passe à NULL pour les users concernés (onDelete: SET NULL). Plus tard, un manager édite seulement un rôle sur un de ces users → cet appel bascule silencieusement currentSite sur sites->first(), changeant le contexte effectif des lectures/écritures scopées.

Garder la garde (a) — currentSite retiré → null — mais conditionner (b) au fait que sites a réellement été modifié par la requête courante (ou ne l'appliquer que quand currentSite était non-null avant, ou seulement sur POST utilisateur).

Le bloc `if (null === currentSite && !sites->isEmpty())` s'applique sur **tout** PATCH `/rbac`, même si la requête n'a pas touché `sites`. Scénario : un admin supprime un site → la FK passe à NULL pour les users concernés (`onDelete: SET NULL`). Plus tard, un manager édite seulement un rôle sur un de ces users → cet appel bascule silencieusement `currentSite` sur `sites->first()`, changeant le contexte effectif des lectures/écritures scopées. Garder la garde (a) — `currentSite` retiré → null — mais conditionner (b) au fait que `sites` a réellement été modifié par la requête courante (ou ne l'appliquer que quand `currentSite` était non-null avant, ou seulement sur `POST` utilisateur).
matthieu reviewed 2026-04-20 14:11:21 +00:00
matthieu left a comment
Owner

Code review — seconde passe profonde (5 Opus + Codex high-effort)

Après re-review avec 5 agents Opus sur des angles non couverts au premier tour + 2 passes Codex indépendantes, voici les findings vérifiés :

Critiques (data integrity / authorization)

1. UserRbacProcessor : double flush non atomiquepersistProcessor->process() flushe (L91), puis ensureCurrentSiteConsistency() peut re-flusher (L130). Aucun wrapInTransaction autour. Si un crash survient entre les deux (OOM, worker kill, perte DB), l'invariant documenté L34-39 « currentSitesites » est violé en base.

}
}
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
// Garde coherence currentSite (ticket 2 module Sites).
// Post-persist : le champ `sites` a ete applique par le persist processor.
// On s'assure que `currentSite` pointe toujours vers un site present
// dans la collection ou est recale automatiquement.
$this->ensureCurrentSiteConsistency($data);
return $result;
}
/**
* Applique deux corrections post-persist sur `currentSite` :
* - si l'actuel n'est plus dans `sites` apres update → repasse a null ;
* - si null et `sites` non vide → auto-selectionne le premier site
* (coherent avec le choix de ne jamais laisser un user rattache a
* plusieurs sites sans contexte courant).
*
* N'emet un flush additionnel que si une correction a ete necessaire :
* pas de cout DB sur la majorite des requetes /rbac qui ne touchent pas
* aux sites.
*/
private function ensureCurrentSiteConsistency(User $user): void
{
$currentSite = $user->getCurrentSite();
$sites = $user->getSites();
$changed = false;
if (null !== $currentSite && !$user->hasSite($currentSite)) {
$user->setCurrentSite(null);
$changed = true;
}
if (null === $user->getCurrentSite() && !$sites->isEmpty()) {
$user->setCurrentSite($sites->first() ?: null);
$changed = true;
}
if ($changed) {
$this->entityManager->flush();
}
}

2. /api/me/current-site : TOCTOU entre hasSite() et flush() — pas de #[ORM\Version], pas de verrou. Alice PATCH current-site: S2 pendant qu'un admin PATCH /users/alice/rbac retire S2 de ses sites : le check in-memory d'Alice passe, puis son flush écrase le SET NULL posé par l'admin → Alice termine avec currentSite=S2 sans ligne user_site.

$targetSite = $data->site;
if (null === $targetSite) {
throw new BadRequestHttpException('Le champ "site" est requis.');
}
try {
$user->switchCurrentSite($targetSite);
} catch (SiteNotAuthorizedException $e) {
// Traduction HTTP immediate (pas de listener kernel necessaire) :
// aligne sur le pattern RoleProcessor → SystemRoleDeletionException.
throw new AccessDeniedHttpException($e->getMessage(), $e);
}
$this->entityManager->flush();
return $user;
}

3. /api/users n'est pas site-scopéUser n'implémente pas SiteAwareInterface, donc tout porteur de core.users.view (permission délégable) énumère les users de tous les tenants, avec isAdmin, rbacRoles, directPermissions, sites, currentSite. Dans un déploiement multi-site où un « responsable de site » a core.users.view pour gérer son équipe, il lit l'organigramme complet de l'instance.

new Get(
security: "is_granted('core.users.view')",
normalizationContext: ['groups' => ['user:list']],
),
new GetCollection(
security: "is_granted('core.users.view')",
normalizationContext: ['groups' => ['user:list']],
),

4. Frontend : après suppression d'un site ou switch, auth.user n'est jamais rafraîchi — deux bugs liés côté frontend :

  • sites.vue admin delete (L141-155) appelle loadSites() pour rafraîchir la liste mais ne rafraîchit pas auth.user. Si l'admin supprime son site courant, la backend cascade currentSite à null via ON DELETE SET NULL, mais le SiteSelector continue d'afficher le site supprimé et d'émettre des /api/sites/{id} morts jusqu'au reload.
  • useCurrentSite.switchSite() (L62-86) met auth.user.currentSite à jour localement mais ne recharge ni useSidebar() ni les données de pages déjà affichées (filtrées server-side sur l'ancien site). Le toast success s'affiche sur des données obsolètes.

async function handleDelete() {
if (!siteToDelete.value) return
deleting.value = true
try {
await api.delete(`/sites/${siteToDelete.value.id}`, {}, {
toastSuccessMessage: t('admin.sites.toast.deleted'),
})
deleteModalOpen.value = false
siteToDelete.value = null
drawerOpen.value = false
await loadSites()
} finally {
deleting.value = false
}
}

const previousLocal = currentSite.value
currentSite.value = site
switching.value = true
try {
await api.patch(
'/me/current-site',
{ site: `/api/sites/${site.id}` },
{ toastSuccessMessage: t('sites.selector.switchSuccess') },
)
// Propage au store auth via l'action dediee — plus tracable que
// la mutation directe et garantit la notification des watchers.
// N'est appele qu'apres un succes HTTP donc pas de rollback a
// prevoir sur cette ligne.
auth.setCurrentSite(site)
} catch (error) {
currentSite.value = previousLocal
throw error
} finally {
switching.value = false
}
}
/**

Importants

5. GET /api/sites retourne tous les sites de l'instance, pas juste ceux du userSite::GetCollection est gardé par sites.view sans filtre sur $user->getSites(). Un délégataire de sites.view (prévu pour alimenter le SiteSelector) peut lire nom, adresse, CP, ville et couleur de tous les sites de tous les tenants. Ajouter un QueryCollectionExtension dédié à Site (sauf si sites.bypass_scope).

#[ApiResource(
operations: [
new GetCollection(
normalizationContext: ['groups' => ['site:read']],
security: "is_granted('sites.view')",
),
new Get(
normalizationContext: ['groups' => ['site:read']],
security: "is_granted('sites.view')",
),
new Post(
normalizationContext: ['groups' => ['site:read']],
denormalizationContext: ['groups' => ['site:write']],
security: "is_granted('sites.manage')",
),
new Patch(
normalizationContext: ['groups' => ['site:read']],
denormalizationContext: ['groups' => ['site:write']],
security: "is_granted('sites.manage')",
),
new Delete(security: "is_granted('sites.manage')"),
],

6. User : 4 relations EAGER — explosion N+1 sur GET /api/usersrbacRoles, directPermissions, sites, currentSite tous en fetch: 'EAGER'. Justifié pour /api/me (un user), mais sur une collection paginée 30 users, c'est 1 + 120 requêtes par page. Soit retirer sites/currentSite du groupe user:list (règle aussi le point 3), soit passer en LAZY avec JOIN FETCH explicite dans MeProvider.

* un refresh de token JWT ou une serialisation hors contexte EntityManager
* (cf. docs/rbac/ticket-343-spec.md section 11 risque 1). Le surcout SQL est
* accepte a l'echelle d'un CRM/ERP PME ; a revoir si la volumetrie augmente.
*
* @var Collection<int, Role>
*/
#[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_role')]
#[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])]
// La propriete s'appelle `rbacRoles` cote PHP pour ne pas entrer en
// collision avec UserInterface::getRoles() (qui renvoie list<string>) ;
// on reexpose la cle JSON sous `roles` via SerializedName pour rester
// conforme au contrat API documente dans le ticket #344.
#[SerializedName('roles')]
private Collection $rbacRoles;
/**
* Les permissions directes accordees hors des roles.
*
* Meme justification EAGER que pour $rbacRoles : garantie que
* getEffectivePermissions() fonctionne dans tous les contextes de chargement.
*
* @var Collection<int, Permission>
*/
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_permission')]
#[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])]
private Collection $directPermissions;
/**
* Sites autorises pour l'utilisateur (ticket 2 du module Sites).
*
* Relation ManyToMany avec table de jointure `user_site`. Fetch EAGER
* pour la meme raison que `$rbacRoles` : garantir que `/api/me` et les
* voters futurs aient toujours la collection hydratee, meme dans un
* contexte de refresh JWT hors EntityManager. Le surcout SQL reste
* negligeable (≤ quelques sites par user en pratique).
*
* @var Collection<int, Site>
*/
#[ORM\ManyToMany(targetEntity: Site::class, inversedBy: 'users', fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_site')]
#[Groups(['me:read', 'user:list', 'user:rbac:read', 'user:rbac:write'])]
private Collection $sites;
/**
* Site courant selectionne par l'utilisateur (ticket 2 du module Sites).
*
* Relation ManyToOne nullable : un user peut ne pas avoir encore choisi
* de site actif (par ex. apres creation avant premier login). La FK porte
* `onDelete: SET NULL` pour que la suppression d'un site ne detruise pas
* les users qui le pointaient — ils repassent simplement a `null`.
*
* Doit TOUJOURS pointer vers un site present dans `$sites`. L'invariant
* est enforce par UserRbacProcessor qui bascule automatiquement a `null`
* si le site courant est retire des sites autorises.
*/
#[ORM\ManyToOne(targetEntity: Site::class, fetch: 'EAGER')]
#[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['me:read', 'user:list'])]
private ?Site $currentSite = null;

Bonus

  • Session leak onglet partagé : auth.clearSession() (401) ne réinitialise pas useCurrentSite / useSidebar / useModules. User A expire → user B login sur le même onglet → site d'A visible quelques ms avant hydration /api/me.
  • Pas de sync cross-tab : SiteSelector watcher (L18) ne mirror que la state Pinia locale, pas d'écoute storage event. Tab A switche, tab B reste sur l'ancien site et ignore le clic si la tile matche sa state stale.
  • Migration locking : ALTER TABLE "user" ADD current_site_id + CREATE INDEX non-CONCURRENT dans la même transaction migration bloque les écritures sur user pendant le build — acceptable à la taille actuelle, à surveiller.
  • VARCHAR(255) backfill : déjà assumé dans le docblock de Version20260420130000 ; prévoir SELECT id, length(full_address) FROM site WHERE length(full_address) > 255; dans le runbook pré-deploy.

🤖 Generated with Claude Code + Opus 4.7 + Codex high-effort

### Code review — seconde passe profonde (5 Opus + Codex high-effort) Après re-review avec 5 agents Opus sur des angles non couverts au premier tour + 2 passes Codex indépendantes, voici les findings vérifiés : #### Critiques (data integrity / authorization) **1. `UserRbacProcessor` : double flush non atomique** — `persistProcessor->process()` flushe (L91), puis `ensureCurrentSiteConsistency()` peut re-flusher (L130). Aucun `wrapInTransaction` autour. Si un crash survient entre les deux (OOM, worker kill, perte DB), l'invariant documenté L34-39 « `currentSite` ∈ `sites` » est violé en base. https://gitea.malio.fr/MALIO-DEV/Coltura/src/commit/296befe187e12393afcba62d8d211996e459a026/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php#L88-L132 **2. `/api/me/current-site` : TOCTOU entre `hasSite()` et `flush()`** — pas de `#[ORM\Version]`, pas de verrou. Alice PATCH `current-site: S2` pendant qu'un admin PATCH `/users/alice/rbac` retire S2 de ses sites : le check in-memory d'Alice passe, puis son flush écrase le `SET NULL` posé par l'admin → Alice termine avec `currentSite=S2` sans ligne `user_site`. https://gitea.malio.fr/MALIO-DEV/Coltura/src/commit/296befe187e12393afcba62d8d211996e459a026/src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/CurrentSiteProcessor.php#L55-L71 **3. `/api/users` n'est pas site-scopé** — `User` n'implémente pas `SiteAwareInterface`, donc tout porteur de `core.users.view` (permission délégable) énumère les users de **tous** les tenants, avec `isAdmin`, `rbacRoles`, `directPermissions`, `sites`, `currentSite`. Dans un déploiement multi-site où un « responsable de site » a `core.users.view` pour gérer son équipe, il lit l'organigramme complet de l'instance. https://gitea.malio.fr/MALIO-DEV/Coltura/src/commit/296befe187e12393afcba62d8d211996e459a026/src/Module/Core/Domain/Entity/User.php#L36-L43 **4. Frontend : après suppression d'un site ou switch, `auth.user` n'est jamais rafraîchi** — deux bugs liés côté frontend : - `sites.vue` admin delete (L141-155) appelle `loadSites()` pour rafraîchir la liste mais ne rafraîchit pas `auth.user`. Si l'admin supprime son site courant, la backend cascade `currentSite` à null via `ON DELETE SET NULL`, mais le `SiteSelector` continue d'afficher le site supprimé et d'émettre des `/api/sites/{id}` morts jusqu'au reload. - `useCurrentSite.switchSite()` (L62-86) met `auth.user.currentSite` à jour **localement** mais ne recharge ni `useSidebar()` ni les données de pages déjà affichées (filtrées server-side sur l'ancien site). Le toast success s'affiche sur des données obsolètes. https://gitea.malio.fr/MALIO-DEV/Coltura/src/commit/296befe187e12393afcba62d8d211996e459a026/frontend/modules/sites/pages/admin/sites.vue#L141-L155 https://gitea.malio.fr/MALIO-DEV/Coltura/src/commit/296befe187e12393afcba62d8d211996e459a026/frontend/modules/sites/composables/useCurrentSite.ts#L62-L86 #### Importants **5. `GET /api/sites` retourne tous les sites de l'instance, pas juste ceux du user** — `Site::GetCollection` est gardé par `sites.view` sans filtre sur `$user->getSites()`. Un délégataire de `sites.view` (prévu pour alimenter le SiteSelector) peut lire nom, adresse, CP, ville et couleur de tous les sites de tous les tenants. Ajouter un `QueryCollectionExtension` dédié à Site (sauf si `sites.bypass_scope`). https://gitea.malio.fr/MALIO-DEV/Coltura/src/commit/296befe187e12393afcba62d8d211996e459a026/src/Module/Sites/Domain/Entity/Site.php#L39-L60 **6. `User` : 4 relations EAGER — explosion N+1 sur `GET /api/users`** — `rbacRoles`, `directPermissions`, `sites`, `currentSite` tous en `fetch: 'EAGER'`. Justifié pour `/api/me` (un user), mais sur une collection paginée 30 users, c'est 1 + 120 requêtes par page. Soit retirer `sites`/`currentSite` du groupe `user:list` (règle aussi le point 3), soit passer en LAZY avec `JOIN FETCH` explicite dans `MeProvider`. https://gitea.malio.fr/MALIO-DEV/Coltura/src/commit/296befe187e12393afcba62d8d211996e459a026/src/Module/Core/Domain/Entity/User.php#L83-L143 #### Bonus - **Session leak onglet partagé** : `auth.clearSession()` (401) ne réinitialise pas `useCurrentSite` / `useSidebar` / `useModules`. User A expire → user B login sur le même onglet → site d'A visible quelques ms avant hydration `/api/me`. - **Pas de sync cross-tab** : `SiteSelector` watcher (L18) ne mirror que la state Pinia locale, pas d'écoute `storage` event. Tab A switche, tab B reste sur l'ancien site et ignore le clic si la tile matche sa state stale. - **Migration locking** : `ALTER TABLE "user" ADD current_site_id` + `CREATE INDEX` non-CONCURRENT dans la même transaction migration bloque les écritures sur `user` pendant le build — acceptable à la taille actuelle, à surveiller. - **VARCHAR(255) backfill** : déjà assumé dans le docblock de `Version20260420130000` ; prévoir `SELECT id, length(full_address) FROM site WHERE length(full_address) > 255;` dans le runbook pré-deploy. 🤖 Generated with [Claude Code](https://claude.ai/code) + Opus 4.7 + Codex high-effort
@@ -0,0 +74,4 @@
// la mutation directe et garantit la notification des watchers.
// N'est appele qu'apres un succes HTTP donc pas de rollback a
// prevoir sur cette ligne.
auth.setCurrentSite(site)
Owner

Après switchSite() succès, seul auth.user.currentSite est mis à jour — ni la sidebar (useSidebar()), ni les données des pages déjà rendues (filtrées server-side sur l'ancien site via SiteScopedQueryExtension). L'utilisateur voit un toast success sur des données obsolètes ; la prochaine mutation est cross-site.

Fix : ajouter await useSidebar().refresh() + émettre un event (mitt / pinia subscribe) que les pages watch pour re-fetch. Envisager aussi de refreshNuxtData() si le listing a été fetched via useAsyncData.

Après `switchSite()` succès, seul `auth.user.currentSite` est mis à jour — ni la sidebar (`useSidebar()`), ni les données des pages déjà rendues (filtrées server-side sur l'ancien site via `SiteScopedQueryExtension`). L'utilisateur voit un toast success sur des données obsolètes ; la prochaine mutation est cross-site. Fix : ajouter `await useSidebar().refresh()` + émettre un event (mitt / pinia subscribe) que les pages `watch` pour re-fetch. Envisager aussi de `refreshNuxtData()` si le listing a été fetched via `useAsyncData`.
@@ -0,0 +148,4 @@
deleteModalOpen.value = false
siteToDelete.value = null
drawerOpen.value = false
await loadSites()
Owner

Après suppression, loadSites() rafraîchit la liste mais pas auth.user. Si l'admin supprime son site courant, la backend passe user.current_site_id à NULL (cascade ON DELETE SET NULL de la FK) mais auth.user.currentSite côté Pinia reste le site supprimé. Le SiteSelector du header continue d'afficher la tile morte jusqu'au prochain /api/me.

Fix : await auth.fetchUser() (ou équivalent) après le loadSites() dans le try block, ou invalider currentSite à la main si l'id supprimé === auth.user.currentSite?.id.

Après suppression, `loadSites()` rafraîchit la liste mais pas `auth.user`. Si l'admin supprime son site courant, la backend passe `user.current_site_id` à NULL (cascade `ON DELETE SET NULL` de la FK) mais `auth.user.currentSite` côté Pinia reste le site supprimé. Le `SiteSelector` du header continue d'afficher la tile morte jusqu'au prochain `/api/me`. Fix : `await auth.fetchUser()` (ou équivalent) après le `loadSites()` dans le `try` block, ou invalider `currentSite` à la main si l'id supprimé === `auth.user.currentSite?.id`.
Owner

GET /api/users n'est pas site-scopé (User n'implémente pas SiteAwareInterface) et le groupe user:list expose sites, currentSite, rbacRoles, directPermissions, isAdmin. Tout porteur de core.users.view énumère l'intégralité des users de tous les tenants avec leur topologie RBAC complète.

Dans le modèle multi-site visé par cette PR, core.users.view devrait probablement être scopé : soit en rendant User SiteAware (via la jointure user_site), soit via un QueryCollectionExtension dédié qui filtre sur $user->getSites() ∩ target.sites != ∅, sauf sites.bypass_scope.

`GET /api/users` n'est pas site-scopé (User n'implémente pas `SiteAwareInterface`) et le groupe `user:list` expose `sites`, `currentSite`, `rbacRoles`, `directPermissions`, `isAdmin`. Tout porteur de `core.users.view` énumère l'intégralité des users de tous les tenants avec leur topologie RBAC complète. Dans le modèle multi-site visé par cette PR, `core.users.view` devrait probablement être scopé : soit en rendant User SiteAware (via la jointure `user_site`), soit via un `QueryCollectionExtension` dédié qui filtre sur `$user->getSites() ∩ target.sites != ∅`, sauf `sites.bypass_scope`.
@@ -110,0 +120,4 @@
*
* @var Collection<int, Site>
*/
#[ORM\ManyToMany(targetEntity: Site::class, inversedBy: 'users', fetch: 'EAGER')]
Owner

4 relations EAGER (rbacRoles L89, directPermissions L107, sites L123, currentSite L140) toutes dans le groupe user:list. Sur GET /api/users avec pagination 30, c'est 1 + 120 requêtes par page (plus N+1 nested si role.permissions est EAGER).

Les docblocks justifient EAGER pour /api/me uniquement. Options :

  • Retirer sites/currentSite du groupe user:list (règle aussi le leak inter-site)
  • Passer les 4 relations en LAZY et JOIN FETCH dans MeProvider
  • Ou introduire un DTO UserListOutput minimal pour le listing.
4 relations EAGER (`rbacRoles` L89, `directPermissions` L107, `sites` L123, `currentSite` L140) toutes dans le groupe `user:list`. Sur `GET /api/users` avec pagination 30, c'est 1 + 120 requêtes par page (plus N+1 nested si `role.permissions` est EAGER). Les docblocks justifient EAGER pour `/api/me` uniquement. Options : - Retirer `sites`/`currentSite` du groupe `user:list` (règle aussi le leak inter-site) - Passer les 4 relations en LAZY et `JOIN FETCH` dans `MeProvider` - Ou introduire un DTO `UserListOutput` minimal pour le listing.
@@ -84,0 +94,4 @@
// Post-persist : le champ `sites` a ete applique par le persist processor.
// On s'assure que `currentSite` pointe toujours vers un site present
// dans la collection ou est recale automatiquement.
$this->ensureCurrentSiteConsistency($data);
Owner

Les deux flushs (L91 via persistProcessor + L130 via ensureCurrentSiteConsistency) ne sont pas wrappés dans la même transaction. Crash entre les deux (OOM, worker killed, perte de connexion DB) laisse currentSite pointer vers un site absent de user_site → viole l'invariant L34-39.

Fix : wrapper tout le process() dans $this->entityManager->wrapInTransaction(function () use (...) { ... }).

Les deux flushs (L91 via `persistProcessor` + L130 via `ensureCurrentSiteConsistency`) ne sont pas wrappés dans la même transaction. Crash entre les deux (OOM, worker killed, perte de connexion DB) laisse `currentSite` pointer vers un site absent de `user_site` → viole l'invariant L34-39. Fix : wrapper tout le `process()` dans `$this->entityManager->wrapInTransaction(function () use (...) { ... })`.
@@ -0,0 +40,4 @@
operations: [
new GetCollection(
normalizationContext: ['groups' => ['site:read']],
security: "is_granted('sites.view')",
Owner

GetCollection + Get sur /api/sites sont gardés par sites.view mais sans filtre sur $user->getSites(). Un délégataire de sites.view lit tous les sites de l'instance — nom, adresse, CP, ville, couleur — y compris ceux auxquels il n'a pas accès.

Fix : ajouter un QueryCollectionExtensionInterface + QueryItemExtensionInterface spécifique à Site qui restreint à $user->getSites(), sauf is_granted('sites.bypass_scope'). Pattern identique à SiteScopedQueryExtension mais ciblé sur Site lui-même.

`GetCollection` + `Get` sur `/api/sites` sont gardés par `sites.view` mais sans filtre sur `$user->getSites()`. Un délégataire de `sites.view` lit tous les sites de l'instance — nom, adresse, CP, ville, couleur — y compris ceux auxquels il n'a pas accès. Fix : ajouter un `QueryCollectionExtensionInterface` + `QueryItemExtensionInterface` spécifique à Site qui restreint à `$user->getSites()`, sauf `is_granted('sites.bypass_scope')`. Pattern identique à `SiteScopedQueryExtension` mais ciblé sur Site lui-même.
@@ -0,0 +58,4 @@
}
try {
$user->switchCurrentSite($targetSite);
Owner

TOCTOU : switchCurrentSite() lit $this->sites en mémoire puis flush(), sans #[ORM\Version] ni lock.

Alice ∈ [S1,S2] PATCH current-site: S2. En parallèle, admin PATCH /users/alice/rbac retire S2. hasSite(S2) d'Alice est true (collection chargée avant révocation), le flush d'Alice écrase le current_site_id = NULL posé par ensureCurrentSiteConsistency → Alice termine avec currentSite=S2 sans ligne user_site.

Fix : $em->refresh($user) avant le check, ou #[ORM\Version] private int $version sur User + catch OptimisticLockException.

TOCTOU : `switchCurrentSite()` lit `$this->sites` en mémoire puis `flush()`, sans `#[ORM\Version]` ni lock. Alice ∈ [S1,S2] PATCH `current-site: S2`. En parallèle, admin PATCH `/users/alice/rbac` retire S2. `hasSite(S2)` d'Alice est true (collection chargée avant révocation), le flush d'Alice écrase le `current_site_id = NULL` posé par `ensureCurrentSiteConsistency` → Alice termine avec `currentSite=S2` sans ligne `user_site`. Fix : `$em->refresh($user)` avant le check, ou `#[ORM\Version] private int $version` sur User + catch `OptimisticLockException`.
matthieu added 4 commits 2026-04-20 14:48:18 +00:00
- Introduit Shared/Domain/Contract/SiteInterface que Site implemente
- SiteAwareInterface + User.php typent contre SiteInterface (plus d'import
  direct Core -> Sites, respect regle CLAUDE.md 138)
- Exception SiteNotAuthorizedException deplacee dans Shared/, alias
  retrocompat dans le module
- Retire `sites` et `currentSite` des groupes `user:list` et `user:rbac:write`
  (info leak via /api/users, escalade core.users.manage -> sites.manage)
- User::$sites et User::$currentSite en fetch LAZY (N+1 sur /api/users paginee)
- SiteCollectionScopedExtension filtre /api/sites aux sites du user
  (name/adresse/CP/ville plus lisibles par un delegataire sites.view qui
  n'appartient pas a ces sites). Bypass via sites.bypass_scope.
- UserSiteScopedExtension filtre /api/users aux users partageant au moins
  un site avec le caller. Empeche un delegataire de core.users.view
  d'enumerer l'organigramme complet + les sites de tous les tenants.
- Helper createUserWithPermission rattache le user jetable a tous les
  sites fixtures, sinon le scoping le rend aveugle aux cibles.
- test_target de UserRbacApiTest attache de meme aux sites pour rester
  visible depuis un caller non-admin.
- testUserCannotSwitchToUnauthorizedSite : 403 -> 400 (anti-enumeration).
- UserRbacProcessor : persist + ensureCurrentSiteConsistency wrappes dans
  wrapInTransaction (plus de double flush non atomique qui pouvait laisser
  currentSite orphelin sur un crash entre les deux flush).
- UserRbacProcessor : detecte la mutation de `sites` via
  PersistentCollection::isDirty() et verifie is_granted('sites.manage')
  avant de deleguer (empeche core.users.manage de contourner sites.manage).
- UserRbacProcessor : skip ensureCurrentSiteConsistency si ni sites ni
  currentSite n'ont ete modifies (plus de bascule silencieuse de site sur
  un simple toggle isAdmin apres suppression de site).
- CurrentSiteProcessor : refresh($user) avant hasSite() pour fermer la
  fenetre TOCTOU entre /rbac revoke et /me/current-site. Catch
  OptimisticLockException pour etre pret a un futur @ORM\Version.
- SiteAwareInjectionProcessor : valide un site explicite contre
  $user->getSites() (bypass via sites.bypass_scope) — bloque le cross-site
  write quand l'entite expose `site` en ecriture.
- logout.vue : navigateTo('/login') dans le finally, garanti meme si
  auth.logout() rejette.
- auth.ts : systeme de callbacks onAuthSessionCleared appeles par
  clearSession() (intercepteur 401 de useApi). Les composables modules
  s'abonnent pour reset leur state sans que Shared n'importe depuis
  modules/ (Option C validee par CLAUDE.md, module -> shared autorise).
- useCurrentSite.ts : enregistre un reset callback + apres un switch
  reussi, rafraichit useSidebar().loadSidebar() + refreshNuxtData()
  (sinon donnees de page obsoletes cote ancien site sous toast success).
- SiteSelector.vue : le court-circuit "tile deja active" est retire
  pour permettre un PATCH de resync quand un autre onglet a bascule le
  site entre temps. TODO cross-tab : ecouter un storage event dedie.
- sites.vue admin : auth.refreshUser() apres delete pour refleter le
  ON DELETE SET NULL cote user.current_site_id.
- Specs vitest : stub useSidebar/refreshNuxtData, test "tile active"
  retourne sur le nouveau contrat PATCH-toujours.
tristan merged commit 6cf5ef4cfc into develop 2026-04-20 15:31:59 +00:00
tristan deleted branch feat/module-site-backend 2026-04-20 15:31:59 +00:00
tristan referenced this issue from a commit 2026-04-20 15:32:00 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: MALIO-DEV/Coltura#8