## Résumé
Implémente le journal d'audit append-only couvrant les 5 tickets de `doc/audit-log.md` et embarque au passage plusieurs corrections périphériques (sidebar Admin/Mon compte, drawer RBAC, Swagger, schema_filter Doctrine) ainsi que l'initialisation de la suite e2e Playwright. Toutes les mutations Doctrine sur les entités portant `#[Auditable]` sont tracées dans une table PostgreSQL dédiée, exposée en lecture seule via API Platform et consultable par les admins dans une page dédiée.
## Ce qui change
### Audit log — cœur de la PR
**Backend**
- Migration : table `audit_log` (UUID v7 natif Postgres en PK, `jsonb changes`, 3 index pour tri chrono, par entité et par utilisateur).
- `AuditLogWriter` : service bas-niveau, écrit via une connexion DBAL dédiée `audit` (même DSN que `default`, service séparé) pour sortir de la transaction ORM en batch. Blacklist defense-in-depth `password`/`plainPassword`/`token`/`secret`.
- `RequestIdProvider` : UUID v4 généré au `kernel.request` principal, injecté dans chaque ligne d'audit de la requête.
- Attributs `#[Auditable]` / `#[AuditIgnore]` dans `src/Shared/Domain/Attribute/` (accessibles par tous les modules).
- `AuditListener` : capture `onFlush` / écriture `postFlush` avec pattern swap-and-clear contre les flushes ré-entrants. Erreurs loguées, jamais propagées. Entité `User` annotée (password / plainPassword ignorés).
- API Platform read-only `/api/audit-logs` (permission RBAC `core.audit_log.view`) : `GET` collection paginée + `GET` item, pas de POST/PUT/PATCH/DELETE. Filtres `entity_type`, `entity_id`, `action`, `performed_by`, `performed_at[after]`/`[before]`.
- `DbalPaginator` implémentant `PaginatorInterface` : `hydra:view` généré automatiquement par API Platform, pas de construction manuelle.
- Ressource `AuditLogEntityTypesResource` + provider dédié pour peupler le filtre par type d'entité côté UI (réponse cachée, pas de requête à chaque ouverture du drawer).
- Permission `core.audit_log.view` déclarée dans `CoreModule::permissions()`.
- `audit_log` exclu du `schema_filter` Doctrine : plus de faux diff sur `make migration-diff`.
**Frontend**
- Page admin `/admin/audit-log` : tableau paginé, filtres locaux (état dans le composant, non persistés dans l'URL — conforme règle CLAUDE.md « Tableaux : pas de persistance URL »), drawer de détail (diff + timeline complète de l'entité), badges colorés par action.
- Composable partagé `useAuditLog` avec `resetAuditLog()` auto-enregistré sur `onAuthSessionCleared` (règle CLAUDE.md composables singletons).
- Composant réutilisable `<AuditTimeline :entity-type :entity-id>` : garde permission (pas d'appel API sans le droit), lazy loading (10 items + bouton « Voir plus »), dates relatives FR via `Intl.RelativeTimeFormat`, skeleton loader.
- Entrée sidebar « Journal d'audit » gated sur `core.audit_log.view` + clés i18n imbriquées dans `fr.json`.
### Fixes embarqués
- **Review fixes audit-log** (commits `37eafd2`, `1505e84`, `99c77eb`) : précision des timestamps, `ESCAPE` sur les `LIKE`, plafond pagination, diverses remarques du 1er tour de review.
- **Sidebar** (`701a480`, `e2fbf51`) : nouvelle section « Administration » + groupe « Mon compte », gate de section sur permissions, « Tableau de bord » déplacé dans « Mon compte ». Convention admin documentée.
- **Drawer RBAC utilisateurs** (`617ee31`, `5f5afcc`) : corrige l'affichage des sites et l'écrasement via merge-patch (garde anti-écrasement + spec `GET /users/{id}/rbac` documentée).
- **Swagger UI** (`6db955f`) : réactivé en ajoutant `symfony/twig-bundle` aux deps (régression depuis l'arrivée d'API Platform 4.2).
- **`phpunit.dist.xml`** : `<env APP_ENV=dev>` forçait la suite à tourner sous `framework.test=false` (→ `test.service_container` introuvable) ; `JWT_PASSPHRASE` ne matchait pas les clés de dev. Corrigés pour débloquer la suite.
### E2E Playwright (nouveau, commit `4603ab2`)
- `playwright.config.ts` + structure `frontend/tests/e2e/` (personas, helpers `loginAs`, page objects `LoginPage` + `SidebarComponent`).
- Specs : `auth/login.spec.ts` + `permissions/sidebar-visibility.spec.ts` (vérifie la visibilité de la sidebar par rôle RBAC).
- Commande `SeedE2ECommand` pour préparer un jeu de données déterministe côté backend.
- `make e2e` ajouté au Makefile.
## Décisions techniques
- **UUID v7 natif Postgres** (16 octets vs 36 en varchar) : index `performed_at` ~40 % plus petit sur une table append-only à croissance infinie.
- **`entity_type` format `module.Entity`** (ex: `core.User`) : évite les collisions si deux modules ont des entités de même nom.
- **`performed_by` dénormalisé** (string, pas FK) : le nom persiste même après suppression de l'utilisateur.
- **Connexion DBAL dédiée `audit`** : évite l'entanglement transactionnel entre audit et ORM en batch.
- **`ManyToMany` non audité** : limitation connue (`getEntityChangeSet()` ne couvre pas les collections) ; extension future via `getScheduledCollectionUpdates()` si besoin.
- **Filtres locaux non persistés dans l'URL** : choix assumé (cf. CLAUDE.md) pour éviter le couplage table ↔ routeur.
## Test plan
- [x] `make test` : 218 tests passent (writer unitaires + listener intégration + API fonctionnels + UserRbacProcessor).
- [x] `npm run lint` + `npm run test` + `npm run build` (frontend).
- [x] Migration appliquée sur dev + test, `audit_log` ignoré par `schema_filter`.
- [x] Permissions synchronisées (`app:sync-permissions`).
- [x] Swagger `/api/docs` accessible de nouveau.
- [ ] Playwright : `make e2e` vert en local (login + sidebar-visibility).
- [ ] Vérifier en local : création/modif/suppression d'un user apparaît dans `/admin/audit-log`.
- [ ] Vérifier : user sans `core.audit_log.view` → 403 sur l'endpoint + item absent de la sidebar.
- [ ] Vérifier : expansion d'une ligne affiche la timeline de l'entité avec dates relatives FR.
- [ ] Vérifier : drawer RBAC utilisateur n'écrase plus la liste des sites au `PATCH`.
## Points d'attention pour le review
- `AuditListener` : pattern swap-and-clear sur `postFlush` — relire la gestion des flushes ré-entrants.
- `DbalPaginator` : vérifier que l'absence d'`Iterator` custom ne casse pas la normalisation API Platform sur collections vides.
- `UserRbacProcessor` : logique merge-patch + garde anti-écrasement des sites (régression corrigée dans `617ee31`).
- Playwright : nouvelle dépendance de dev, s'assurer que `make e2e` ne fait pas partie du pipeline CI par défaut (à brancher explicitement).
Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #9
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Co-committed-by: matthieu <matthieu@yuno.malio.fr>
43 KiB
Ticket #02 — 2/4 — Exposition API, rattachement utilisateurs et admin CRUD
1. Objectif
Ce ticket transforme la brique de donnees du ticket 1 en module fonctionnel : il expose la ressource Site via API Platform (CRUD admin avec RBAC), introduit la notion de sites autorises et de site courant sur chaque utilisateur, ouvre un endpoint dedie au basculement du site courant, et livre la page d'administration /admin/sites ainsi que l'assignation des sites dans le drawer RBAC d'un user.
Le resultat attendu est un module Sites utilisable de bout en bout cote admin (creer, editer, supprimer des sites et en assigner aux users), avec une API /api/me enrichie que le ticket 3 consommera pour alimenter le selecteur de site dans la navbar. Le ticket etablit le couplage Core → Sites au niveau modele (la table user gagne deux relations vers site) tout en conservant le contrat "desactiver Sites dans config/modules.php ne casse pas l'app" via des decisions DB/mapping assumees.
2. Périmètre
IN
- Exposer
Sitecomme ressource API Platform avec les operationsGetCollection,Get,Post,Patch,Delete, securisees par les permissionssites.view(lecture) etsites.manage(ecriture). - Ajouter deux relations sur
User(module Core) :$sites(M2M,user_site) : sites autorises.$currentSite(M2O nullable) : site actuellement selectionne.
- Ajouter la relation inverse
$userssurSite(non exposee API). - Generer la migration Doctrine creant la table
user_siteet la colonneuser.current_site_idavec les bonnes strategiesON DELETEpour garantir les cascades attendues (suppression d'un site →user_sitepurge,currentSitemis aNULL). - Etendre
/api/mepour exposersites: Site[]etcurrentSite: Site | nullen objets serialises (pas en IRI), via les groupesme:readsurUseret surSite. - Ajouter un endpoint dedie de switch du site courant, implemente comme une ressource API Platform virtuelle
CurrentSiteavec une operationPatch uriTemplate: '/me/current-site'et un processor dedie. Le processor garantit que le site cible fait partie dessitesde l'utilisateur authentifie, sinon il leve une exception traduite en403. - Etendre
UserRbacProcessoret l'operationPATCH /api/users/{id}/rbacpour accepter un champsites: string[](IRIs) en plus des roles et permissions directes. Cas limite : si lecurrentSitedu user cible n'est plus dans la liste, le processor le bascule aNULL. - Etendre l'exception metier Core pour couvrir "site non autorise" via une nouvelle exception domaine
SiteNotAuthorizedExceptionplacee dans le module Sites, traduite enForbiddenHttpExceptionau niveau API. - Ajouter l'entree sidebar
sidebar.admin.sitesfiltree parmodule: 'sites'+permission: 'sites.view'dansconfig/sidebar.php, sous la section admin Core existante. - Livrer la page d'administration
/admin/sitescote front (layer Nuxtfrontend/modules/sites/) : DataTable + drawer creation/edition + modale suppression, alignee visuellement et structurellement sur/admin/roleset/admin/users. - Etendre le drawer
UserRbacDrawer.vue(module Core) pour afficher et editer la liste des sites autorises d'un user via un multi-select. - Ajouter les fixtures : rattacher les 3 users existants (
admin,alice,bob) a au moins un site et positionner uncurrentSitecoherent. - Couverture de tests PHPUnit : CRUD
/api/sites, endpoint/me/current-site(cas OK + 403), extension/api/me, cascade DB a la suppression d'un site, extensionUserRbacProcessor(ajout/retrait sites, auto-reset currentSite).
OUT
- Ticket
#03: selecteur de site dans la navbar, persistance du site actif cote front, integration visuelle avec la couleur du site. - Ticket
#04: filtrage metier par site (ex: bloquer l'acces aux ressources Commercial si l'user n'est pas rattache au site de la ressource). - Soft-delete des sites : non introduit.
- Audit trail des modifications : hors scope.
- Color picker avance : un input hex simple avec preview de la puce suffit.
- Recherche / tri server-side sur
/api/sites: non requis, le volume reste <20 sites par instance. - Gestion des site "globaux" ou "par defaut" pour les nouveaux users : non introduite, les users crees via
CreateUserCommandou/api/usersPOST aurontsites: []etcurrentSite: nulljusqu'a rattachement explicite.
3. Fichiers à créer
Backend — Module Sites
/home/m-tristan/workspace/Coltura/src/Module/Sites/Domain/Exception/SiteNotAuthorizedException.php: exception domaine levee si un user tente de switcher vers un site qui ne fait pas partie de ses sites autorises. Porte un message i18n-able et le code du site cible./home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/ApiPlatform/Resource/CurrentSiteResource.php: ressource API Platform virtuelle (pas de mapping Doctrine, pas de#[ORM\Entity]). Sert uniquement a porter l'operationPatch/me/current-site. Expose une proprietesite: Siteen denormalisation pour recevoir l'IRI du site cible, et re-expose l'user courant en normalisation via le groupeme:read./home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/CurrentSiteProcessor.php: processor dedie a l'operation de switch. Valide l'appartenance du site auxuser.sites, positionneuser.currentSite, flush, retourne l'user./home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/ApiPlatform/EventListener/SiteNotAuthorizedExceptionListener.php: listener Kernel qui convertitSiteNotAuthorizedExceptionenForbiddenHttpException(403) avec un code i18n stable (cf. patternSystemRoleDeletionExceptiondu module Core dans les tickets RBAC precedents).
Backend — Migration
/home/m-tristan/workspace/Coltura/migrations/Version<timestamp2>.php: migration au namespace racineDoctrineMigrations(cf. exception Doctrine documentee dansCLAUDE.md). Cree la tableuser_siteet la colonneuser.current_site_idavec les FKs et cascades appropriees.
Backend — Tests API
/home/m-tristan/workspace/Coltura/tests/Module/Sites/Api/SiteApiTest.php: CRUD complet/api/sitesavec matrices RBAC (admin, user avecsites.view, user avecsites.manage, user sans permission)./home/m-tristan/workspace/Coltura/tests/Module/Sites/Api/CurrentSiteSwitchApiTest.php: PATCH/me/current-site(OK avec site autorise, 403 avec site non autorise, 400 avec IRI invalide)./home/m-tristan/workspace/Coltura/tests/Module/Sites/Api/MeEndpointSitesTest.php:/api/meexpose biensitesetcurrentSiteen objets. User sans site :sites: [],currentSite: null./home/m-tristan/workspace/Coltura/tests/Module/Sites/Api/SiteCascadeTest.php: suppression d'un siteX→ toutes les lignesuser_sitereferencantXsont supprimees, tous les users ayantXencurrentSitevoient leurcurrentSiterepasser aNULL./home/m-tristan/workspace/Coltura/tests/Module/Core/Api/UserRbacSitesApiTest.php: extension du endpoint/api/users/{id}/rbac— ajout desites: []dans le payload, retrait ducurrentSitequand le site retire etait le courant.
Frontend — Module Sites (nouveau layer)
/home/m-tristan/workspace/Coltura/frontend/modules/sites/nuxt.config.ts: marker de layer Nuxt (vide). Declenche l'auto-detection parnuxt.config.tsracine./home/m-tristan/workspace/Coltura/frontend/modules/sites/pages/admin/sites.vue: page/admin/sites. Reutilise les composants Malio UI (MalioDataTable,MalioButton,MalioInputText,MalioInputTextArea). Pattern identique afrontend/modules/core/pages/admin/roles.vue./home/m-tristan/workspace/Coltura/frontend/modules/sites/components/SiteDrawer.vue: drawer creation/edition. Formulaire 5 champs (nom, ville, CP, couleur avec preview puce, adresse). Valide cote front sur le submit avant d'envoyer./home/m-tristan/workspace/Coltura/frontend/modules/sites/components/SiteDeleteModal.vue: modale de confirmation suppression. Pattern aligne surRoleDeleteModal.vue.
Frontend — Types partages
/home/m-tristan/workspace/Coltura/frontend/shared/types/sites.ts: typesSite,SiteInput. Pattern identique afrontend/shared/types/rbac.ts.
Tests frontend (optionnels mais recommandes)
/home/m-tristan/workspace/Coltura/frontend/modules/sites/pages/admin/sites.spec.ts: smoke test Vitest (rendu + clic bouton "Nouveau site" ouvre le drawer).
4. Fichiers à modifier
Backend — Module Core
/home/m-tristan/workspace/Coltura/src/Module/Core/Domain/Entity/User.php:- Ajouter
private Collection $sites;(M2M,fetch: EAGER,JoinTable: user_site), groupesme:read,user:list,user:rbac:read,user:rbac:write. - Ajouter
private ?Site $currentSite = null;(M2O,fetch: EAGER,onDelete: 'SET NULL'), groupeme:read. - Initialiser
$this->sites = new ArrayCollection();dans le constructeur. - Ajouter les accesseurs
getSites(),addSite(Site),removeSite(Site),hasSite(Site),getCurrentSite(),setCurrentSite(?Site). - Important :
importdirectApp\Module\Sites\Domain\Entity\Site. Ce ticket assume le couplage Core → Sites au niveau code PHP (cf. Risque 1).
- Ajouter
/home/m-tristan/workspace/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php:- Etendre le contrat d'entree pour accepter le champ
sites(collection d'IRIs denormalisees enCollection<Site>). - Apres l'application des roles et permissions directes, detecter si
currentSitedu user cible n'est plus dans la nouvelle collectionsites→ basculercurrentSiteanull. - Conserver toutes les gardes existantes (auto-suicide admin, dernier admin global).
- Etendre le contrat d'entree pour accepter le champ
/home/m-tristan/workspace/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php:- Declarer l'implementation
DependentFixtureInterfaceavecgetDependencies(): [SitesFixtures::class](inversion de l'ordre actuel : AppFixtures doit tourner apres SitesFixtures pour pouvoir reference les sites). - Rattacher chaque user a au moins un site :
admina tous les sites (Chatellerault,Saint-Jean,Pommevic),aliceaChatellerault,bobaSaint-Jean. - Positionner
currentSite:admin.currentSite = Chatellerault,alice.currentSite = Chatellerault,bob.currentSite = Saint-Jean.
- Declarer l'implementation
Backend — Module Sites
/home/m-tristan/workspace/Coltura/src/Module/Sites/Domain/Entity/Site.php:- Ajouter les attributs
#[ApiResource]+ operations (cf. section 5 Schema). - Ajouter les groupes de serialisation
site:read,site:write,me:readsur les proprietes scalaires. - Ajouter la relation inverse
private Collection $users;(M2M mappedBy=sites), sans groupe de serialisation (pas d'exposition API cote Site). - Initialiser
$this->users = new ArrayCollection();dans le constructeur. - Ajouter les accesseurs
getUsers()pour les besoins metier (count / cascade manuel si besoin).
- Ajouter les attributs
/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php: aucun changement de contenu, mais verifier que la fixture n'est plus en bout de chaine de dependance (AppFixtures depend d'elle maintenant).
Backend — Configuration
/home/m-tristan/workspace/Coltura/config/sidebar.php: inserer l'entreeSitesdans la sectionsidebar.general.sectionentresidebar.core.usersetsidebar.general.logout:[ 'label' => 'sidebar.core.sites', 'to' => '/admin/sites', 'icon' => 'mdi:domain', 'module' => 'sites', 'permission' => 'sites.view', ],/home/m-tristan/workspace/Coltura/config/services.yaml: aucun changement requis.CurrentSiteProcessor,SiteNotAuthorizedExceptionListenersont autoconfigures.
Frontend
/home/m-tristan/workspace/Coltura/frontend/modules/core/components/UserRbacDrawer.vue:- Charger
GET /api/sites?itemsPerPage=999a l'ouverture du drawer (parallelement aux roles et permissions deja charges). - Ajouter une section
sidebar.admin.usersDrawer.sitesSectionsous la section permissions directes, avec un groupe deMalioCheckboxpar site (ou unMalioMultiSelectsi le composant existe dans@malio/layer-ui). - Etendre le payload
PATCH /api/users/{id}/rbacavecsites: Array<string>(IRIs). - Auto-refresh de l'auth store apres save si
isSelfEdit(deja present, conserver).
- Charger
/home/m-tristan/workspace/Coltura/frontend/shared/types/rbac.ts: ajouter le champsites: string[]aUserListItem(IRIs de sites attaches)./home/m-tristan/workspace/Coltura/frontend/shared/stores/auth.ts: le store auth expose dejauservia/api/me. Aucune modification requise, les nouveaux champssitesetcurrentSitesuivent automatiquement via la typologie — a condition de mettre a jour le typeUserDatadansshared/types/(ajoutersites: Site[]etcurrentSite: Site | null)./home/m-tristan/workspace/Coltura/frontend/i18n/locales/fr.json: clessidebar.core.sites= "Sites".admin.sites.title,admin.sites.newSite,admin.sites.editSite,admin.sites.createSite,admin.sites.noSites.admin.sites.table.{name, city, postalCode, color, fullAddress}.admin.sites.form.{name, city, postalCode, color, fullAddress}.admin.sites.delete.{title, message}.admin.sites.toast.{created, updated, deleted}.admin.users.drawer.sitesSection= "Sites autorises".errors.sites.notAuthorized= "Vous n'etes pas autorise a selectionner ce site.".
5. Schéma cible — ApiResource et Doctrine
Entite Site — attributs ApiResource
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('sites.view')",
normalizationContext: ['groups' => ['site:read']],
),
new Get(
security: "is_granted('sites.view')",
normalizationContext: ['groups' => ['site:read']],
),
new Post(
security: "is_granted('sites.manage')",
normalizationContext: ['groups' => ['site:read']],
denormalizationContext: ['groups' => ['site:write']],
),
new Patch(
security: "is_granted('sites.manage')",
normalizationContext: ['groups' => ['site:read']],
denormalizationContext: ['groups' => ['site:write']],
),
new Delete(security: "is_granted('sites.manage')"),
],
)]
Groupes sur les proprietes de Site :
id:site:read,me:read.name,city,postalCode,color,fullAddress:site:read,site:write,me:read.createdAt,updatedAt:site:readuniquement (pas exposes en embedme:readpour garder le payload /me leger).
Evolution de User — nouvelles relations
/** @var Collection<int, Site> */
#[ORM\ManyToMany(targetEntity: Site::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_site')]
#[Groups(['me:read', 'user:list', 'user:rbac:read', 'user:rbac:write'])]
private Collection $sites;
#[ORM\ManyToOne(targetEntity: Site::class, fetch: 'EAGER')]
#[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['me:read'])]
private ?Site $currentSite = null;
Justification fetch=EAGER :
- Aligne sur les collections
$rbacRoleset$directPermissions(cf.User.php:87). - Critique pour eviter un lazy-load silencieux pendant un refresh JWT (cf. ticket 343 section 11 risque 1).
- Surcout SQL accepte a l'echelle d'un CRM PME (≤20 sites par instance).
Relation inverse sur Site
/** @var Collection<int, User> */
#[ORM\ManyToMany(targetEntity: User::class, mappedBy: 'sites')]
private Collection $users;
Pas de #[Groups] : la collection inverse n'est pas exposee dans la reponse API. Sa seule utilite est metier (compter les users d'un site, iterer pour un cascade applicatif si la cascade DB ne suffisait pas).
Ressource virtuelle CurrentSite
#[ApiResource(
shortName: 'CurrentSite',
operations: [
new Patch(
uriTemplate: '/me/current-site',
security: "is_granted('ROLE_USER')",
normalizationContext: ['groups' => ['me:read']],
denormalizationContext: ['groups' => ['current-site:write']],
processor: CurrentSiteProcessor::class,
read: false,
),
],
)]
final class CurrentSiteResource
{
#[Groups(['current-site:write'])]
public ?Site $site = null;
}
read: false: API Platform ne tente pas de charger une entite existante via un Provider — il se contente de denormaliser le body et de passer la ressource au processor.shortName: 'CurrentSite': evite la collision de nommage avec l'entiteSite.security: "is_granted('ROLE_USER')": tout user authentifie peut tenter un switch ; l'autorisation fine (appartenance du site auxsitesdu user) est verifiee par le processor, pas par la voter RBAC.
6. Plan de migration Doctrine
La migration est placee dans /home/m-tristan/workspace/Coltura/migrations/Version<timestamp2>.php au namespace racine (cf. Risque 2 du ticket 1 et CLAUDE.md).
up() — ordre des instructions
ALTER TABLE "user" ADD current_site_id INT DEFAULT NULL— colonne nullable, pas besoin de backfill.CREATE TABLE user_site (user_id INT NOT NULL, site_id INT NOT NULL, PRIMARY KEY (user_id, site_id)).CREATE INDEX IDX_user_site_user ON user_site (user_id).CREATE INDEX IDX_user_site_site ON user_site (site_id).ALTER TABLE user_site ADD CONSTRAINT FK_user_site_user FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE.ALTER TABLE user_site ADD CONSTRAINT FK_user_site_site FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE CASCADE.CREATE INDEX IDX_user_current_site ON "user" (current_site_id).ALTER TABLE "user" ADD CONSTRAINT FK_user_current_site FOREIGN KEY (current_site_id) REFERENCES site (id) ON DELETE SET NULL.
down() — rollback
ALTER TABLE "user" DROP CONSTRAINT FK_user_current_site.DROP INDEX IDX_user_current_site.ALTER TABLE "user" DROP current_site_id.ALTER TABLE user_site DROP CONSTRAINT FK_user_site_site.ALTER TABLE user_site DROP CONSTRAINT FK_user_site_user.DROP TABLE user_site.
Comportement des cascades
| Action | Effet |
|---|---|
DELETE FROM site WHERE id = X |
Toutes les lignes user_site avec site_id = X sont supprimees (FK ON DELETE CASCADE). Tous les users avec current_site_id = X voient leur current_site_id passer a NULL (FK ON DELETE SET NULL). |
DELETE FROM "user" WHERE id = Y |
Toutes les lignes user_site avec user_id = Y sont supprimees. Pas d'effet sur site. |
DELETE FROM user_site WHERE user_id = Y AND site_id = X |
Aucun effet auto sur user.current_site_id — si X etait le courant de Y, c'est le UserRbacProcessor qui doit le basculer a NULL en Php (cf. section 8). |
Important : la derniere ligne du tableau est la raison pour laquelle la logique de "retirer un site qui etait le courant remet currentSite a null" vit dans UserRbacProcessor cote applicatif et non dans la DB via un trigger. C'est un compromis assume : la regle est metier ("retirer un droit ne doit pas laisser l'user pointer sur un site interdit"), pas purement DB.
7. Algorithme du switch de site courant — CurrentSiteProcessor
Entree
Body JSON envoye par le client :
{ "site": "/api/sites/3" }
API Platform denormalise vers CurrentSiteResource { site: Site } en resolvant l'IRI via son IriConverter.
Algorithme
- Recuperer l'user authentifie via
Security::getUser(). Si absent →LogicException(l'operation exigeROLE_USER, ne doit pas arriver). - Extraire
$targetSite = $resource->site. Sinull→BadRequestHttpException('Le champ "site" est requis.'). - Verifier
$user->hasSite($targetSite):- Implementation :
$this->sites->contains($targetSite)(comparaison par reference ; Doctrine garantit l'identite d'objet dans la meme session). - Si
false→ throwSiteNotAuthorizedException($targetSite->getId()).
- Implementation :
$user->setCurrentSite($targetSite).$this->entityManager->flush().- Retourner
$user— API Platform le normalise via les groupesme:readdefinis sur l'operation.
Mapping d'exception
SiteNotAuthorizedException est convertie en Symfony\Component\HttpKernel\Exception\HttpException avec statut 403 par SiteNotAuthorizedExceptionListener (event kernel.exception, priority standard). Le corps de la reponse porte un code i18n-able errors.sites.notAuthorized pour le front.
8. Évolution du UserRbacProcessor
Nouveau champ en entree
Le payload accepte desormais :
{
"isAdmin": false,
"roles": ["/api/roles/2"],
"directPermissions": [],
"sites": ["/api/sites/1", "/api/sites/3"]
}
Le champ sites est optionnel : si absent, la collection n'est pas touchee (comportement PATCH standard). Si present, il remplace integralement la collection $user->sites.
Garde "currentSite coherent"
Apres application des champs par le persist processor decore, UserRbacProcessor execute un controle final :
$currentSite = $data->getCurrentSite();
if ($currentSite !== null && !$data->hasSite($currentSite)) {
$data->setCurrentSite(null);
}
Justification : si un admin retire un site qui etait le currentSite de la cible, le modele serait incoherent (currentSite pointant vers un site non autorise). Le processor corrige automatiquement.
Variante rejetee : basculer vers "le premier site restant" plutot que null. Rejetee car :
- "Premier restant" n'a pas de semantique metier claire (ordre de la collection non garanti strict).
nullest une valeur deja supportee (user sans site courant) et explicite : le front du ticket 3 devra gerer ce cas de toute facon.
Ordre d'execution dans le processor
- Gardes auto-suicide admin + dernier admin global (code existant, inchange).
$this->persistProcessor->process($data, ...)— applique tous les champs (roles, permissions directes, sites).- Post-persist : controle coherence currentSite (code ajoute par ce ticket), flush si changement.
- Retour du user.
9. Fixtures — évolution de AppFixtures
AppFixtures devient dependant de SitesFixtures (inversion du "pas de dependance dure" declare au ticket 1 — justifie par le passage fonctionnel a la relation User ↔ Site).
class AppFixtures extends Fixture implements DependentFixtureInterface
{
public function getDependencies(): array
{
return [SitesFixtures::class];
}
// ...
}
Dans load(), apres la creation des users et avant le flush final :
$chatellerault = $this->siteRepository->findByName('Chatellerault');
$saintJean = $this->siteRepository->findByName('Saint-Jean');
$pommevic = $this->siteRepository->findByName('Pommevic');
$admin->addSite($chatellerault);
$admin->addSite($saintJean);
$admin->addSite($pommevic);
$admin->setCurrentSite($chatellerault);
$alice->addSite($chatellerault);
$alice->setCurrentSite($chatellerault);
$bob->addSite($saintJean);
$bob->setCurrentSite($saintJean);
Le repository SiteRepositoryInterface est injecte dans le constructeur.
Regle : les 3 sites sont deja en base au moment ou AppFixtures::load() s'execute grace a getDependencies(). Si findByName retourne null, c'est une misconfiguration qui doit faire echouer fort (assertion via \assert).
10. Frontend — Page /admin/sites
Structure
frontend/modules/sites/
├── nuxt.config.ts # marker layer Nuxt (vide)
├── pages/
│ └── admin/
│ └── sites.vue # page listing
└── components/
├── SiteDrawer.vue # creation/edition
└── SiteDeleteModal.vue # confirmation suppression
pages/admin/sites.vue — pattern
Aligne sur frontend/modules/core/pages/admin/roles.vue :
- En-tete : titre + bouton
Nouveau site(visible sican('sites.manage')). MalioDataTable: colonnesname,city,postalCode,color(slot custom pour la puce),fullAddress(tronque).- Row click → ouvre
SiteDraweren mode edition sican('sites.manage'), sinon pas de clic (row-clickable guard). SiteDraweremetsaved→ reload de la liste, etdelete→ ouvreSiteDeleteModal.SiteDeleteModal→ DELETE/api/sites/{id}+ reload.
components/SiteDrawer.vue
Formulaire a 5 champs + preview de la couleur. Pattern RoleDrawer.vue :
MalioInputTextpourname,city,postalCode.MalioInputTextpourcoloravec preview : une puce<span>24×24 arrondie affichant la couleur en temps reel a cote du champ. Valider localement via regex avant submit (ne pas envoyer un hex invalide au backend).MalioInputTextAreapourfullAddress.- Bouton save (variant primary), bouton delete (variant danger, visible uniquement en mode edition, aucune garde system comme pour les roles — tous les sites sont supprimables), bouton cancel (variant tertiary).
components/SiteDeleteModal.vue
Pattern RoleDeleteModal.vue :
- Modal avec message "Supprimer le site {name} ? Cette action est irreversible et retirera ce site a tous les utilisateurs rattaches."
- Bouton cancel (secondary) + bouton delete (danger avec icone poubelle).
- Emet
confirmau clic delete.
Extension de UserRbacDrawer.vue
Ajout d'une nouvelle section entre "Permissions directes" et "Resume des permissions effectives" :
<!-- Section Sites autorises -->
<div>
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
{{ t('admin.users.drawer.sitesSection') }}
</h4>
<div class="flex flex-col gap-2">
<MalioCheckbox
v-for="site in allSites"
:key="site.id"
:id="`site-${site.id}`"
:label="site.name"
:model-value="selectedSiteIds.has(site.id)"
label-class="text-sm text-neutral-600"
@update:model-value="(val: boolean) => toggleSite(site.id, val)"
/>
</div>
</div>
Chargement : ajout a loadData() d'un api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false }).
Le PATCH /api/users/{id}/rbac envoie desormais sites: Array.from(selectedSiteIds).map(id => /api/sites/${id}).
Types TypeScript
frontend/shared/types/sites.ts :
export interface Site {
id: number
name: string
city: string
postalCode: string
color: string
fullAddress: string
}
export interface SiteInput {
name: string
city: string
postalCode: string
color: string
fullAddress: string
}
frontend/shared/types/rbac.ts : ajouter sites: string[] (IRIs) dans UserListItem.
frontend/shared/types/ (fichier utilisateur courant, probablement user.ts ou expose dans l'auth store) : ajouter sites: Site[] et currentSite: Site | null sur le type expose via /api/me.
Sidebar
Entree ajoutee dans config/sidebar.php (cf. section 4). Le SidebarProvider filtre deja par module actif et par permission, aucune modification backend nouvelle.
i18n :
"sidebar": {
"core": {
"sites": "Sites"
}
}
11. Plan de tests PHPUnit
SiteApiTest — CRUD /api/sites
testAdminCanListSites: admin → 200, 3 sites.testUserWithSitesViewCanListSites: user avecsites.view→ 200.testUserWithoutPermissionGetsForbidden: user sanssites.view→ 403.testAdminCanCreateSite: POST → 201, site present en base.testAdminCanPatchSite: PATCHcolor→ 200.testAdminCanDeleteSite: DELETE → 204, site absent en base.testUserWithViewButNotManageCannotDelete: user avecsites.viewmais passites.manage→ 403 sur DELETE.testCreateSiteWithDuplicateNameReturns422: collisionuniq_site_name→ 422 avec message UniqueEntity.testCreateSiteWithInvalidColorReturns422: validation regex → 422.
CurrentSiteSwitchApiTest — PATCH /me/current-site
testUserCanSwitchToAuthorizedSite: alice aChatelleraultdans ses sites → PATCH OK, 200,currentSite.name == 'Chatellerault'.testUserCannotSwitchToUnauthorizedSite: alice n'a pasPommevicdans ses sites → PATCH → 403, pas de modification en base.testSwitchWithMissingSiteFieldReturns400: body{}→ 400.testSwitchWithInvalidIriReturns400: body{"site": "/api/sites/99999"}(site inexistant) → 400 ou 404 (selon API Platform).testAnonymousUserCannotSwitch: client non authentifie → 401.
MeEndpointSitesTest — extension /api/me
testMeExposesSitesAsObjects: alice →sites[0]est un objet avecid,name,city, ... (pas une string IRI).testMeExposesCurrentSiteAsObject: alice →currentSiteest un objet, pasnull.testUserWithoutSitesHasEmptyArrayAndNullCurrent: creer un user jetable sans sites →sites: [],currentSite: null.
SiteCascadeTest — cascade DB a la suppression
testDeletingSitePurgesUserSiteRows: supprimerChatellerault→ les users qui l'avaient danssitesne l'ont plus.testDeletingSiteSetsCurrentSiteToNullOnReferencingUsers: alice.currentSite =Chatellerault, supprimerChatellerault→ alice.currentSite =null.
UserRbacSitesApiTest — extension /rbac
testAdminCanAssignSitesToUser: PATCH/users/{alice}/rbacavecsites: ["/api/sites/2"]→ alice a desormais 1 site (Saint-Jean), plusChatellerault.testRemovingCurrentSiteResetsCurrentSiteToNull: alice.currentSite =Chatellerault, PATCH avecsites: []→ alice.currentSite =null.testEmptySitesPayloadReplacesCollection: alice avait 1 site, PATCH avecsites: []→ 0 site.testSitesPayloadWithDuplicateIrisIsAccepted: PATCH avecsites: ["/api/sites/1", "/api/sites/1"]→ 1 seul site (dedoublonnage viaArrayCollection::contains).
Tests fixtures (sanity check)
Dans AbstractApiTestCase ou dans un test dedie FixturesIntegrityTest : verifier apres make test-db-setup que les 3 users fixtures ont bien leurs sites attendus. Evite qu'un renommage dans la fixture passe inapercu.
12. Risques et points d'attention
Risque 1 — Couplage Core → Sites au niveau code PHP
L'ajout de use App\Module\Sites\Domain\Entity\Site; dans User.php introduit une dependance directe du module Core vers le module Sites. Consequence :
- Desactiver
SitesModule::classdansconfig/modules.phpn'empeche pas Doctrine de charger le mappingSiteniUser, grace au caractere inconditionnel des mappings declares dansdoctrine.yaml(choix assume ticket 1). - En revanche, la contrainte forte introduite ici est que la table
sitedoit exister pour que la tableuserpuisse etre creee (FKuser.current_site_id → site.id). Si la migration Sites (ticket 1) n'a pas ete jouee, la migration de ce ticket echouera. - Conclusion : Sites n'est plus "optionnel au sens strict" apres ce ticket. Le declarer
REQUIRED = falsedansSitesModulereste vrai du point de vue de l'activation fonctionnelle (exposer les permissions et la sidebar), mais faux du point de vue DB. A documenter explicitement dans le docblock deSitesModule::REQUIREDau moment de ce ticket.
Risque 2 — Cascade DB vs regle applicative
La cascade user_site → ON DELETE CASCADE gere la suppression d'un site, mais n'est pas triggered quand on retire un site d'un user (DELETE d'une ligne user_site uniquement). Dans ce cas, user.current_site_id peut rester pointe vers un site que l'user n'a plus — etat incoherent qui serait masque au niveau DB mais visible a l'usage.
La correction vit dans UserRbacProcessor (cf. section 8). Si un autre chemin applicatif modifie user.sites sans passer par ce processor (ex: une commande console custom), il devra dupliquer cette garde. Point d'attention a consigner dans le docblock de User::addSite() / User::removeSite() : "apres modification, verifier la coherence de currentSite".
Risque 3 — Ressource virtuelle et routing API Platform
Le choix d'une ressource virtuelle CurrentSite avec uriTemplate: '/me/current-site' est fragile : si un futur ticket introduit une autre operation sur une URI qui commence par /me/, il faut verifier que le routing API Platform n'entre pas en conflit. Le pattern priority: 1 (cf. CLAUDE.md section Backend) est recommande par prevention sur l'operation Patch. A valider par un test fonctionnel qui appelle explicitement /api/me (GET) et /api/me/current-site (PATCH) dans le meme scenario.
Risque 4 — EAGER loading et payload /api/me
User a deja 3 collections EAGER ($rbacRoles, $directPermissions, plus les permissions de chaque role). Ajouter $sites (EAGER M2M) et $currentSite (EAGER M2O) augmente la taille du payload /api/me et le nombre de requetes SQL a chaque auth.
Mesure : apres implementation, verifier via le profiler Symfony que le nombre de requetes SQL sur /api/me reste raisonnable (≤ 6-8). Si >10, envisager une projection custom (cf. ticket 343 discussion findForSecurity). Pas bloquant dans ce ticket, mais a reverifier.
Risque 5 — Tests fixtures-dependents
Les tests API existants (UserApiTest, RoleApiTest) s'appuient sur les users fixtures. L'evolution de AppFixtures (ajout de sites aux 3 users) modifie l'etat initial de la DB de test. Verifier que les tests existants continuent de passer (chaines d'assertions du type "user a 1 role" ne doivent pas casser). En particulier :
- Les tests qui comptent les lignes d'une collection
member[]sur/api/userspeuvent voir le payload grossir (sites et currentSite ajoutes). - Les tests qui assertent sur la forme stricte d'un user (snapshot-like) devront etre adapter.
Risque 6 — Serialisation infinie User ↔ Site
User::$sites expose Site en me:read. Site::$users est la collection inverse. Si un jour Site::$users recevait le groupe me:read, la serialisation entrerait dans une boucle infinie (User → sites → users → sites → ...). Garde : Site::$users ne doit jamais porter de #[Groups]. A verifier par un test qui serialise /api/me et asserte qu'aucun Site renvoye ne contient de cle users.
Risque 7 — Pas de recours si l'utilisateur se retire tous ses sites
Le ticket autorise un user sans sites (sites: [], currentSite: null). Mais aucune garde ne l'empeche de se retirer tous ses sites via /api/users/{mon_id}/rbac si il porte sites.manage. Consequence : l'user se retrouve bloque sur l'app si le ticket 3 rend un site actif obligatoire pour naviguer. Compromis assume pour ce ticket : on ne bloque pas l'auto-retrait (coherence avec le pattern du ticket RBAC — l'auto-retrait admin est bloque, mais pas le reste). A reevaluer au ticket 3 si le selecteur de navbar devient bloquant.
13. Ordre d'exécution recommandé
- Schema backend — modifier
User.php(ajout$sites,$currentSite,$usersinverse surSite). Ajouter attributsApiResourcesurSite. - Configuration — aucun changement requis a
doctrine.yamlniservices.yamlnimodules.php. - Migration — ecrire
Version<timestamp2>.phpracine. Jouermake migration-migrate. - Fixtures — modifier
AppFixturespour dependre deSitesFixtureset rattacher les users. Jouermake fixtures && make sync-permissions. - Endpoint CRUD sites — verifier via
curl/Postman queGET /api/sites,POST /api/sitesetc. repondent avec les bonnes protections RBAC. - Endpoint switch — creer
CurrentSiteResource,CurrentSiteProcessor,SiteNotAuthorizedException,SiteNotAuthorizedExceptionListener. Tester viacurl. - Extension MeProvider — tester via
curl /api/mequesitesetcurrentSiteapparaissent comme objets. Aucun code a changer dansMeProviderlui-meme, le travail est 100% fait via les groupes. - Extension UserRbacProcessor — ajouter le champ
siteset la gardecurrentSite. Tests d'integration. - Tests API — ecrire et faire passer les 5 suites de tests decrites section 11.
- Sidebar — ajouter l'entree dans
config/sidebar.php+ cle i18n. - Frontend — types — creer
shared/types/sites.ts, etendreshared/types/rbac.tset les types user. - Frontend — page admin — creer
modules/sites/nuxt.config.ts,pages/admin/sites.vue,SiteDrawer.vue,SiteDeleteModal.vue. - Frontend — extension UserRbacDrawer — ajouter la section sites.
- Frontend — i18n — completer
fr.json. - Validation end-to-end — clique-droit sur chaque scenario UX : creer un site, l'editer, le supprimer, assigner sites a un user, switcher le site courant de l'user authentifie.
- Tests front (si Vitest du ticket) — smoke test du rendu de
/admin/sites. - CS fixer —
make php-cs-fixer-allow-riskysur tous les fichiers touches. - DoD — valider les 10 criteres section 14.
14. Critères d'acceptation (DoD)
GET /api/sites,GET /api/sites/{id}retournent 200 pour un user avecsites.view, 403 sinon.POST /api/sites,PATCH /api/sites/{id},DELETE /api/sites/{id}retournent le code attendu pour un user avecsites.manage, 403 sinon.GET /api/meretournesites: Site[](objets complets) etcurrentSite: Site | null, avec les 3 sites pouradmin, 1 pouralice, 1 pourbob.PATCH /api/me/current-siteavec un site autorise → 200,currentSitemis a jour. Avec un site non autorise → 403.DELETE /api/sites/{id}cascade correctement : les lignesuser_sitesont purgees, lescurrent_site_idpointant dessus repassent aNULL.PATCH /api/users/{id}/rbacaccepte le champsites; retirer lecurrentSitede la liste le bascule anull.- Page
/admin/sites: liste, creation, edition, suppression fonctionnelles. UserRbacDrawer.vue: section "Sites autorises" visible et fonctionnelle.- Sidebar : entree "Sites" visible pour un user avec
sites.view, masquee sinon. Disparait siSitesModule::classest retire deconfig/modules.php. make testpasse toutes les suites (les 5 nouvelles + les existantes ajustees aux fixtures).make php-cs-fixer-allow-riskypropre sur les fichiers nouveaux et modifies.- Desactiver
SitesModule::classdansconfig/modules.phpne casse pas les endpoints Core (la DB reste valide, les users conservent leurs sites meme si l'UI ne les expose plus).
10. Evolutions post-livraison — drawer RBAC et defense in depth
Apres la livraison initiale du ticket, un bug utilisateur a revele que le drawer
UserRbacDrawer.vue demarrait toujours avec 0 site coche pour un user qui en
avait en BDD, et que la sauvegarde ecrasait silencieusement les sites
existants. Root cause : l'endpoint GET /api/users utilise le groupe user:list
qui n'expose pas la collection sites (choix assume pour garder le payload
leger et eviter toute fuite croisee site). Le drawer initialisait donc
selectedSiteIds a partir d'un user.sites toujours undefined.
Deux evolutions ont ete apportees pour corriger cela proprement sans elargir la
surface de fuite de /api/users :
10.1 — Nouvelle operation GET /users/{id}/rbac
Une operation API Platform Get est ajoutee sur User, symetrique au Patch
existant, sous la meme URI /users/{id}/rbac :
new Get(
name: 'user_rbac_get',
uriTemplate: '/users/{id}/rbac',
security: "is_granted('core.users.manage')",
normalizationContext: ['groups' => ['user:rbac:read']],
),
Raisons du design :
- Symetrie REST : GET et PATCH partagent la meme URI et le meme groupe de normalisation, documentation OpenAPI et appels clients lisibles.
- Separation list/detail :
/api/users(user:list) reste maigre — pas de collection, pas de fuite./users/{id}/rbac(user:rbac:read) porte le detail riche requis par le drawer d'edition. - Garde de permission plus stricte :
core.users.manage(et non.view) — le detail RBAC est concu pour l'edition, pas la consultation. - Isolation du couplage Sites : la dependance au module Sites reste scopee
a cet endpoint et a
/api/me. Elle n'est pas disseminee dans tous les payloads de liste.
Cote frontend (UserRbacDrawer.vue) :
loadData(userId)fetch desormais/users/{id}/rbacen parallele des referentiels (roles, permissions, sites globaux).- Le watch combine
[modelValue, user.id]recharge le detail a chaque ouverture ou changement de user sans dependance fragile surprops.user. - Le type
UserListItemperdsites(inutilise) ; un nouveau typeUserRbacDetailrepresente le payload du GET dedie. - La colonne "Sites" de
/admin/usersest retiree : l'info est consultee dans le drawer. Cela supprime aussi le second fetch/api/sitessur la page de liste.
10.2 — Garde anti-ecrasement dans UserRbacProcessor
API Platform denormalize les collections ManyToMany comme des ArrayCollection
vides quand la cle JSON correspondante est absente du payload, violant la
semantique merge-patch+json qui impose que les cles absentes ne mutent PAS
les proprietes. Pour un PATCH qui ne veut toucher que isAdmin, cela
detruirait tous les sites/roles/directPermissions du user.
Le processor injecte desormais RequestStack, lit le body JSON brut au debut
de process(), et pour chaque collection absente du payload restaure l'etat
d'origine a partir du snapshot Doctrine :
// Mapping cle JSON → accesseurs PHP (note : 'roles' → getRbacRoles)
private const COLLECTION_MAP = [
'roles' => ['getter' => 'getRbacRoles', ...],
'directPermissions' => ['getter' => 'getDirectPermissions', ...],
'sites' => ['getter' => 'getSites', ...],
];
private function restoreAbsentCollections(User $user): void
{
$payload = json_decode($this->requestStack->getCurrentRequest()?->getContent() ?? '', true);
foreach (self::COLLECTION_MAP as $jsonKey => $accessors) {
if (array_key_exists($jsonKey, $payload)) {
continue; // cle presente = la denormalisation fait foi
}
// cle absente = restaurer le snapshot PersistentCollection
// (voir implementation complete dans UserRbacProcessor.php)
}
}
Semantique finale garantie :
| Payload | Effet sur la collection |
|---|---|
| Cle absente | Preservee (etat BDD inchange) |
"sites": [] |
Collection videe explicitement |
"sites": ["/api/sites/1"] |
Collection remplacee |
La garde ensureCurrentSiteConsistency continue de s'executer apres persist
avec la meme logique : elle est triggered uniquement si la collection a
effectivement mute (detection via PersistentCollection::isDirty() post-restore).
10.3 — Criteres de validation additionnels
GET /users/{id}/rbacretourne 200 pourcore.users.manage, 403 sinon.- Le payload contient
{ id, isAdmin, roles, directPermissions, sites }. GET /api/usersne contient plussites(verification non-regression).- Ouvrir le drawer d'un user avec des sites en BDD affiche les cases pre-cochees correspondantes.
PATCH /users/{id}/rbacavec{ "isAdmin": true }(sans autre cle) ne modifie pas sites/roles/directPermissions.PATCH /users/{id}/rbacavec{ "sites": [] }vide explicitement la collection et basculecurrentSiteaNULLvia la garde existante.PATCH /users/{id}/rbacavec{ "sites": [...] }remplace la collection comme auparavant.