Compare commits

...

3 Commits

Author SHA1 Message Date
Matthieu
5f5afccac0 docs(specs) : documente GET /users/{id}/rbac et garde anti-ecrasement merge-patch
Ajoute les sections "Evolutions post-livraison" aux specs Sites #02 et RBAC #345
pour refleter les modifs apportees apres la livraison initiale :

- GET /users/{id}/rbac symetrique au PATCH, pour charger le detail d'edition
  sans elargir le groupe user:list (le payload de liste reste leger, la
  dependance Core → Sites reste scopee a cet endpoint et a /api/me).
- Garde restoreAbsentCollections() dans UserRbacProcessor qui respecte la
  semantique merge-patch+json : cle absente = preservee, cle = [] = videe,
  cle = [...] = remplacee. Restauration a partir du snapshot Doctrine des
  PersistentCollection pour roles / directPermissions / sites.
- Nouveaux criteres de validation + matrice de semantique.

Verification archi modular monolith : Commercial et Sites peuvent etre
desactives dans config/modules.php sans casser l'app (sidebar filtree,
switcher masque, endpoints admin rediriges via disabledRoutes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:21:49 +02:00
Matthieu
617ee314b3 fix(users) : corrige l'affichage et l'ecrasement des sites sur le drawer RBAC
Le drawer RBAC de /admin/users initialisait l'etat des sites a partir du payload
/api/users (groupe user:list) qui n'expose pas la collection sites. Consequence :
la section "Sites autorises" affichait toujours 0 case cochee, et la sauvegarde
ecrasait silencieusement les sites existants en BDD.

- Ajout d'une operation GET /users/{id}/rbac (groupe user:rbac:read) dediee au
  chargement du detail pour l'edition : payload list reste leger, detail riche
  sur une URI symetrique au PATCH existant.
- Drawer charge desormais GET /users/{id}/rbac pour initialiser sites, roles
  et directPermissions ; UserListItem ne contient plus sites (inutilise).
- Colonne "Sites" retiree de la table /admin/users : l'info est consultee via
  le drawer, pas la liste (evite aussi la fuite cross-site pour les users avec
  core.users.view mais sans sites.bypass_scope).
- Garde anti-ecrasement dans UserRbacProcessor : respect de la semantique
  merge-patch+json (cle absente = preservee, cle = [] = vidage explicite).
  Restaure les collections ManyToMany absentes du payload a partir du snapshot
  Doctrine. Couvre roles, directPermissions et sites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:17:40 +02:00
Matthieu
6db955f65c fix(api-docs) : reactive swagger ui en ajoutant symfony/twig-bundle
API Platform 4 active swagger_ui/re_doc/scalar uniquement si TwigBundle
est present (les UI de docs sont rendues via Twig). Sans lui les flags
tombaient a false et /api/docs renvoyait 404 "Swagger UI, ReDoc and
Scalar are disabled." sur Accept: text/html.
2026-04-22 11:09:37 +02:00
13 changed files with 657 additions and 46 deletions

View File

@@ -31,6 +31,7 @@
"symfony/runtime": "8.0.*",
"symfony/security-bundle": "8.0.*",
"symfony/serializer": "8.0.*",
"symfony/twig-bundle": "8.0.*",
"symfony/uid": "8.0.*",
"symfony/validator": "8.0.*",
"symfony/yaml": "8.0.*"

274
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "65f8419b8830b250fe461933fe240a14",
"content-hash": "d65a546151abb6b977fbf7f1c86d14fe",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -7226,6 +7226,198 @@
],
"time": "2025-07-15T13:41:35+00:00"
},
{
"name": "symfony/twig-bridge",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/twig-bridge.git",
"reference": "a892d0b7f3d5d51b35895467e48aafbd1f2612a0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/twig-bridge/zipball/a892d0b7f3d5d51b35895467e48aafbd1f2612a0",
"reference": "a892d0b7f3d5d51b35895467e48aafbd1f2612a0",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/translation-contracts": "^2.5|^3",
"twig/twig": "^3.21"
},
"conflict": {
"phpdocumentor/reflection-docblock": "<5.2|>=7",
"phpdocumentor/type-resolver": "<1.5.1",
"symfony/form": "<7.4.4|>8.0,<8.0.4",
"symfony/mime": "<7.4.8|>8.0,<8.0.8"
},
"require-dev": {
"egulias/email-validator": "^2.1.10|^3|^4",
"league/html-to-markdown": "^5.0",
"phpdocumentor/reflection-docblock": "^5.2|^6.0",
"symfony/asset": "^7.4|^8.0",
"symfony/asset-mapper": "^7.4|^8.0",
"symfony/console": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/emoji": "^7.4|^8.0",
"symfony/expression-language": "^7.4|^8.0",
"symfony/finder": "^7.4|^8.0",
"symfony/form": "^7.4.4|^8.0.4",
"symfony/html-sanitizer": "^7.4|^8.0",
"symfony/http-foundation": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/intl": "^7.4|^8.0",
"symfony/mime": "^7.4.8|^8.0.8",
"symfony/polyfill-intl-icu": "^1.0",
"symfony/property-info": "^7.4|^8.0",
"symfony/routing": "^7.4|^8.0",
"symfony/security-acl": "^2.8|^3.0",
"symfony/security-core": "^7.4|^8.0",
"symfony/security-csrf": "^7.4|^8.0",
"symfony/security-http": "^7.4|^8.0",
"symfony/serializer": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0",
"symfony/translation": "^7.4|^8.0",
"symfony/validator": "^7.4|^8.0",
"symfony/web-link": "^7.4|^8.0",
"symfony/workflow": "^7.4|^8.0",
"symfony/yaml": "^7.4|^8.0",
"twig/cssinliner-extra": "^3",
"twig/inky-extra": "^3",
"twig/markdown-extra": "^3"
},
"type": "symfony-bridge",
"autoload": {
"psr-4": {
"Symfony\\Bridge\\Twig\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides integration for Twig with various Symfony components",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/twig-bridge/tree/v8.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/twig-bundle",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/twig-bundle.git",
"reference": "f83767b78e2580ca9fe9a2cf6fcff19cd5389bc1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/twig-bundle/zipball/f83767b78e2580ca9fe9a2cf6fcff19cd5389bc1",
"reference": "f83767b78e2580ca9fe9a2cf6fcff19cd5389bc1",
"shasum": ""
},
"require": {
"composer-runtime-api": ">=2.1",
"php": ">=8.4",
"symfony/config": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/http-foundation": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/twig-bridge": "^7.4|^8.0"
},
"require-dev": {
"symfony/asset": "^7.4|^8.0",
"symfony/expression-language": "^7.4|^8.0",
"symfony/finder": "^7.4|^8.0",
"symfony/form": "^7.4|^8.0",
"symfony/framework-bundle": "^7.4|^8.0",
"symfony/routing": "^7.4|^8.0",
"symfony/runtime": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0",
"symfony/translation": "^7.4|^8.0",
"symfony/web-link": "^7.4|^8.0",
"symfony/yaml": "^7.4|^8.0"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Symfony\\Bundle\\TwigBundle\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides a tight integration of Twig into the Symfony full-stack framework",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/twig-bundle/tree/v8.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/type-info",
"version": "v8.0.8",
@@ -7807,6 +7999,86 @@
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "twig/twig",
"version": "v3.24.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/Twig.git",
"reference": "a6769aefb305efef849dc25c9fd1653358c148f0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/a6769aefb305efef849dc25c9fd1653358c148f0",
"reference": "a6769aefb305efef849dc25c9fd1653358c148f0",
"shasum": ""
},
"require": {
"php": ">=8.1.0",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "^1.8",
"symfony/polyfill-mbstring": "^1.3"
},
"require-dev": {
"php-cs-fixer/shim": "^3.0@stable",
"phpstan/phpstan": "^2.0@stable",
"psr/container": "^1.0|^2.0",
"symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0"
},
"type": "library",
"autoload": {
"files": [
"src/Resources/core.php",
"src/Resources/debug.php",
"src/Resources/escaper.php",
"src/Resources/string_loader.php"
],
"psr-4": {
"Twig\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com",
"homepage": "http://fabien.potencier.org",
"role": "Lead Developer"
},
{
"name": "Twig Team",
"role": "Contributors"
},
{
"name": "Armin Ronacher",
"email": "armin.ronacher@active-4.com",
"role": "Project Founder"
}
],
"description": "Twig, the flexible, fast, and secure template language for PHP",
"homepage": "https://twig.symfony.com",
"keywords": [
"templating"
],
"support": {
"issues": "https://github.com/twigphp/Twig/issues",
"source": "https://github.com/twigphp/Twig/tree/v3.24.0"
},
"funding": [
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/twig/twig",
"type": "tidelift"
}
],
"time": "2026-03-17T21:31:11+00:00"
},
{
"name": "webmozart/assert",
"version": "2.1.6",

View File

@@ -11,6 +11,7 @@ use Nelmio\CorsBundle\NelmioCorsBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\MonologBundle\MonologBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
return [
FrameworkBundle::class => ['all' => true],
@@ -22,4 +23,5 @@ return [
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
LexikJWTAuthenticationBundle::class => ['all' => true],
MonologBundle::class => ['all' => true],
TwigBundle::class => ['all' => true],
];

View File

@@ -0,0 +1,6 @@
twig:
file_name_pattern: '*.twig'
when@test:
twig:
strict_variables: true

View File

@@ -572,3 +572,78 @@ Chaque etape doit etre revue (spec compliance + code quality) avant de passer a
- Branche de travail : `feat/rbac-voter`, tiree de `feat/rbac-api`.
- Pas de PR dediee : les commits #345 s'empilent sur la PR #3 existante ouverte vers `develop`.
- Une fois la PR #3 mergee, la branche finale de l'epic RBAC (`feat/rbac-admin-ui` pour #346) partira de `develop`.
## 18. Evolutions post-livraison — `UserRbacProcessor` defense in depth
Voir aussi : `docs/sites/ticket-02-spec.md` § 10 pour la problematique cote
Sites qui a motive cette evolution.
### 18.1 — Semantique `merge-patch+json` respectee
Le processor originel appliquait telles quelles les mutations produites par la
denormalisation API Platform. Or API Platform reinstancie par defaut une
`ArrayCollection` vide pour chaque propriete ManyToMany absente du payload,
ce qui viole la semantique `application/merge-patch+json` : les cles absentes
ne doivent PAS muter les proprietes correspondantes.
Consequence concrete du bug : un PATCH minimal comme `{ "isAdmin": true }`
detruisait silencieusement toutes les collections (`rbacRoles`,
`directPermissions`, `sites`) du user cible.
La garde `restoreAbsentCollections()` introduite dans `UserRbacProcessor`
resout cela en :
1. Injectant `RequestStack` pour lire le body JSON brut de la requete.
2. Decodant les cles effectivement envoyees par le client.
3. Pour chaque cle RBAC (`roles`, `directPermissions`, `sites`) absente du
payload : restaurant la collection a son etat d'origine a partir du
snapshot Doctrine (`PersistentCollection::getSnapshot()`), puis appelant
`takeSnapshot()` pour marquer la collection comme non-dirty (aucune query
`UPDATE` n'est emise sur les tables de jointure).
4. No-op si la cle est presente (la denormalisation fait foi).
Matrice finale :
| Payload | Effet |
|---------------------------------|-------------------------------------|
| Cle absente | Propriete preservee (BDD inchangee) |
| Cle presente = `[]` | Collection videe (vidage explicite) |
| Cle presente = `[...]` | Collection remplacee |
### 18.2 — Nouvelle operation `GET /users/{id}/rbac`
Le drawer d'edition (`UserRbacDrawer.vue`) ne peut plus dependre du payload
de liste `/api/users` (groupe `user:list`) pour initialiser l'etat `sites`
car ce groupe reste volontairement leger (cf. ticket Sites #02). Une
operation `Get` dediee est ajoutee, symetrique au `Patch` existant :
- URI : `/users/{id}/rbac`
- Security : `is_granted('core.users.manage')` (plus strict que `.view`)
- Groupe : `user:rbac:read` (contient `isAdmin`, `roles`, `directPermissions`,
`sites`).
Le drawer charge desormais ce GET en parallele des referentiels au moment
de l'ouverture, via un watch combine `[modelValue, user.id]` qui recharge
correctement si le user change sans fermeture du drawer entre-temps.
### 18.3 — Impact sur les tests
`UserRbacProcessorTest` : le constructor gagne un argument `RequestStack`.
Les tests existants injectent une `RequestStack` avec une `Request` vide
(body `""`), ce qui rend la garde no-op — le comportement des assertions
existantes est conserve. De nouveaux tests couvrent la garde :
- PATCH sans cle `sites` ne mute pas la collection d'origine.
- PATCH avec `sites: []` vide bien la collection (pas de regression du cas
"vidage explicite").
- PATCH avec `sites: [...]` remplace comme avant.
### 18.4 — Criteres de validation additionnels
- [ ] `GET /users/{id}/rbac` retourne 200 avec `core.users.manage`, 403 sans.
- [ ] Le payload contient `{ id, isAdmin, roles, directPermissions, sites }`.
- [ ] `PATCH /users/{id}/rbac` avec cle absente preserve la collection BDD.
- [ ] `PATCH /users/{id}/rbac` avec `[]` vide la collection et declenche
`ensureCurrentSiteConsistency` (cas sites).
- [ ] Les 228 tests PHPUnit existants passent apres ajout du parametre
`RequestStack` au constructor.

View File

@@ -590,3 +590,112 @@ Le ticket autorise un user sans sites (`sites: []`, `currentSite: null`). Mais a
- [ ] `make test` passe toutes les suites (les 5 nouvelles + les existantes ajustees aux fixtures).
- [ ] `make php-cs-fixer-allow-risky` propre sur les fichiers nouveaux et modifies.
- [ ] Desactiver `SitesModule::class` dans `config/modules.php` ne 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` :
```php
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}/rbac` en 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 sur `props.user`.
- Le type `UserListItem` perd `sites` (inutilise) ; un nouveau type
`UserRbacDetail` represente le payload du GET dedie.
- La colonne "Sites" de `/admin/users` est retiree : l'info est consultee
dans le drawer. Cela supprime aussi le second fetch `/api/sites` sur 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 :
```php
// 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}/rbac` retourne 200 pour `core.users.manage`, 403 sinon.
- [ ] Le payload contient `{ id, isAdmin, roles, directPermissions, sites }`.
- [ ] `GET /api/users` ne contient plus `sites` (verification non-regression).
- [ ] Ouvrir le drawer d'un user avec des sites en BDD affiche les cases
pre-cochees correspondantes.
- [ ] `PATCH /users/{id}/rbac` avec `{ "isAdmin": true }` (sans autre cle) ne
modifie pas sites/roles/directPermissions.
- [ ] `PATCH /users/{id}/rbac` avec `{ "sites": [] }` vide explicitement la
collection et bascule `currentSite` a `NULL` via la garde existante.
- [ ] `PATCH /users/{id}/rbac` avec `{ "sites": [...] }` remplace la
collection comme auparavant.

View File

@@ -112,7 +112,7 @@
</template>
<script setup lang="ts">
import type { Permission, Role, UserListItem, EffectivePermission } from '~/shared/types/rbac'
import type { Permission, Role, UserListItem, UserRbacDetail, EffectivePermission } from '~/shared/types/rbac'
import type { Site } from '~/shared/types/sites'
interface PermissionModule {
@@ -206,39 +206,44 @@ const effectivePermissions = computed<EffectivePermission[]>(() => {
.sort((a, b) => a.code.localeCompare(b.code))
})
// Charger roles, permissions et sites en parallele pour minimiser le TTFB
// a l'ouverture du drawer.
async function loadData() {
const [rolesData, permsData, sitesData] = await Promise.all([
// Charger les referentiels (roles, permissions, sites) + le detail RBAC du user
// en parallele pour minimiser le TTFB a l'ouverture du drawer.
// Le detail RBAC est la seule source de verite pour l'etat initial du formulaire :
// props.user vient de la liste /api/users qui n'expose pas les sites (groupe leger).
async function loadData(userId: number) {
const [rolesData, permsData, sitesData, userRbac] = await Promise.all([
api.get<{ member: Role[] }>('/roles', {}, { toast: false }),
api.get<{ member: Permission[] }>('/permissions', { orphan: false, itemsPerPage: 999 }, { toast: false }),
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false }),
api.get<UserRbacDetail>(`/users/${userId}/rbac`, {}, { toast: false }),
])
allRoles.value = rolesData.member
allPermissions.value = permsData.member
allSites.value = sitesData.member
form.value.isAdmin = userRbac.isAdmin
selectedRoleIds.value = new Set((userRbac.roles ?? []).map(iriToId))
selectedDirectPermissionIds.value = new Set((userRbac.directPermissions ?? []).map(iriToId))
selectedSiteIds.value = new Set((userRbac.sites ?? []).map(iriToId))
}
// Remplir le formulaire quand le user change
watch(() => props.user, (user) => {
if (user) {
form.value.isAdmin = user.isAdmin
selectedRoleIds.value = new Set(user.roles.map(iriToId))
selectedDirectPermissionIds.value = new Set(user.directPermissions.map(iriToId))
selectedSiteIds.value = new Set((user.sites ?? []).map(iriToId))
} else {
form.value.isAdmin = false
selectedRoleIds.value = new Set()
selectedDirectPermissionIds.value = new Set()
selectedSiteIds.value = new Set()
function resetForm() {
form.value.isAdmin = false
selectedRoleIds.value = new Set()
selectedDirectPermissionIds.value = new Set()
selectedSiteIds.value = new Set()
}
// Recharger a l'ouverture OU quand le user change pendant que le drawer est ouvert.
// Le watch combine evite un double fetch si les deux changent dans le meme tick.
watch([() => props.modelValue, () => props.user?.id], ([open, userId]) => {
if (open && userId) {
loadData(userId)
} else if (!open) {
resetForm()
}
}, { immediate: true })
// Charger les donnees quand le drawer s'ouvre
watch(() => props.modelValue, (open) => {
if (open) loadData()
})
function toggleRole(id: number, selected: boolean) {
const ids = new Set(selectedRoleIds.value)
if (selected) ids.add(id)

View File

@@ -38,7 +38,6 @@
<script setup lang="ts">
import type { UserListItem } from '~/shared/types/rbac'
import type { Site } from '~/shared/types/sites'
const { t } = useI18n()
const api = useApi()
@@ -49,24 +48,21 @@ useHead({ title: t('admin.users.title') })
const canManage = computed(() => can('core.users.manage'))
const users = ref<UserListItem[]>([])
const sitesById = ref(new Map<number, Site>())
const loading = ref(false)
const drawerOpen = ref(false)
const selectedUser = ref<UserListItem | null>(null)
// La colonne "Sites" n'est plus affichee dans la liste : le detail des sites
// rattaches est consulte/edite via le drawer (GET /users/{id}/rbac). Garder
// un payload leger sur /api/users facilite la pagination et evite de fuiter
// l'info cross-site aux users partageant juste un site avec l'appelant.
const columns = [
{ key: 'username', label: t('admin.users.table.username') },
{ key: 'admin', label: t('admin.users.table.admin') },
{ key: 'roles', label: t('admin.users.table.roles') },
{ key: 'directPermissions', label: t('admin.users.table.directPermissions') },
{ key: 'sites', label: t('admin.users.table.sites') },
]
// Extraire l'id numerique depuis une IRI API Platform type `/api/sites/3`.
function iriToId(iri: string): number {
return Number(iri.split('/').pop())
}
const userItems = computed(() =>
users.value.map(user => ({
id: user.id,
@@ -74,27 +70,14 @@ const userItems = computed(() =>
admin: user.isAdmin,
roles: user.roles.length,
directPermissions: user.directPermissions.length,
// Affichage : liste des noms de sites separes par virgule. Les IRIs
// du payload /api/users (groupe user:list) sont resolues via la Map
// construite en parallele depuis /api/sites.
sites: (user.sites ?? [])
.map(iri => sitesById.value.get(iriToId(iri))?.name)
.filter((name): name is string => Boolean(name))
.join(', '),
})),
)
async function loadUsers() {
loading.value = true
try {
// Chargement parallele : les sites alimentent la Map de resolution
// IRI→name pour la colonne "Sites" de la table.
const [usersData, sitesData] = await Promise.all([
api.get<{ member: UserListItem[] }>('/users', {}, { toast: false }),
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false }),
])
const usersData = await api.get<{ member: UserListItem[] }>('/users', {}, { toast: false })
users.value = usersData.member
sitesById.value = new Map(sitesData.member.map(s => [s.id, s]))
} finally {
loading.value = false
}

View File

@@ -21,7 +21,19 @@ export interface UserListItem {
isAdmin: boolean
roles: string[]
directPermissions: string[]
/** IRIs des sites autorises (ticket 2 module Sites). */
}
/**
* Detail RBAC d'un user, renvoye par GET /api/users/{id}/rbac (groupe user:rbac:read).
* Utilise par UserRbacDrawer pour initialiser son formulaire avec l'etat complet
* (sites inclus). Le endpoint de liste /api/users reste volontairement leger et
* n'expose pas ces champs.
*/
export interface UserRbacDetail {
id: number
isAdmin: boolean
roles: string[]
directPermissions: string[]
sites: string[]
}

View File

@@ -51,6 +51,16 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
),
new Post(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class),
new Patch(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class),
// Lecture dediee au drawer d'edition RBAC : meme URI que le PATCH pour une
// API symetrique, groupe `user:rbac:read` qui expose sites/roles/directPermissions.
// Garde `core.users.manage` (pas `.view`) car c'est l'endpoint de detail prevu
// pour l'edition, pas la consultation generale (elle passe par GET /users/{id}).
new Get(
name: 'user_rbac_get',
uriTemplate: '/users/{id}/rbac',
security: "is_granted('core.users.manage')",
normalizationContext: ['groups' => ['user:rbac:read']],
),
new Patch(
name: 'user_rbac_patch',
uriTemplate: '/users/{id}/rbac',

View File

@@ -9,11 +9,13 @@ use ApiPlatform\State\ProcessorInterface;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\PersistentCollection;
use LogicException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -51,12 +53,31 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
*/
final class UserRbacProcessor implements ProcessorInterface
{
/**
* Mapping cle-payload → (property-path PHP, accesseur, setter utilise pour
* reattacher les items lors de la restauration). Permet au gardefou
* anti-ecrasement de savoir quelles collections restaurer si elles sont
* absentes du payload JSON.
*
* Note : la cle JSON "roles" correspond a la propriete PHP `rbacRoles`
* (renommee via #[SerializedName] pour eviter la collision avec
* UserInterface::getRoles()).
*
* @var array<string, array{getter: string, remover: string, adder: string}>
*/
private const array COLLECTION_MAP = [
'roles' => ['getter' => 'getRbacRoles', 'remover' => 'removeRbacRole', 'adder' => 'addRbacRole'],
'directPermissions' => ['getter' => 'getDirectPermissions', 'remover' => 'removeDirectPermission', 'adder' => 'addDirectPermission'],
'sites' => ['getter' => 'getSites', 'remover' => 'removeSite', 'adder' => 'addSite'],
];
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
private readonly AdminHeadcountGuardInterface $adminHeadcountGuard,
private readonly RequestStack $requestStack,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
@@ -72,6 +93,19 @@ final class UserRbacProcessor implements ProcessorInterface
));
}
// Garde anti-ecrasement (defense in depth) : PATCH merge-patch+json impose
// que les cles absentes du payload ne mutent PAS les proprietes
// correspondantes. La denormalisation API Platform ne respecte pas cet
// invariant pour les collections ManyToMany — elle reinstancie une
// ArrayCollection vide des que la cle n'est pas presente. Sans cette
// garde, un client qui PATCHe juste `{ "isAdmin": true }` verrait toutes
// ses roles/directPermissions/sites detruits.
//
// On lit le body brut de la requete pour connaitre les cles envoyees,
// puis on restaure les collections absentes a partir de l'etat d'origine
// charge par Doctrine (snapshot des PersistentCollection).
$this->restoreAbsentCollections($data);
$currentUser = $this->security->getUser();
// Calcul partage entre les deux gardes : l'user perdait-il le flag admin ?
@@ -180,4 +214,73 @@ final class UserRbacProcessor implements ProcessorInterface
$this->entityManager->flush();
}
}
/**
* Pour chaque collection RBAC (roles, directPermissions, sites) absente du
* payload JSON, restaure l'etat d'origine a partir du snapshot Doctrine et
* marque la collection comme non-dirty. Idempotent : si la cle est presente
* dans le payload, no-op (la denormalisation fait foi).
*
* Cas d'usage : un client qui PATCHe partiellement (`{ "isAdmin": true }`)
* ne doit pas voir ses autres collections reinitialisees. API Platform
* reinstancie par defaut une collection vide pour les cles absentes, ce
* qui casse la semantique de merge-patch+json.
*
* Pas de fallback si la collection d'origine n'est pas une PersistentCollection
* (ex: User fraichement construit) : dans ce cas aucune restauration n'est
* possible puisqu'il n'y a pas d'etat persiste a restaurer.
*/
private function restoreAbsentCollections(User $user): void
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return;
}
$rawBody = $request->getContent();
if ('' === $rawBody) {
return;
}
/** @var null|array<string, mixed> $payload */
$payload = json_decode($rawBody, true);
if (!is_array($payload)) {
return;
}
foreach (self::COLLECTION_MAP as $jsonKey => $accessors) {
if (array_key_exists($jsonKey, $payload)) {
continue;
}
/** @var Collection<int, object> $currentCollection */
$currentCollection = $user->{$accessors['getter']}();
if (!$currentCollection instanceof PersistentCollection) {
continue;
}
// Snapshot = etat charge depuis la BDD avant denormalisation.
// On restaure en retirant les items actuels et en ajoutant les
// originaux via l'adder/remover pour que les collections inverses
// (ex: Site::users) restent coherentes.
$snapshot = $currentCollection->getSnapshot();
foreach ($currentCollection->toArray() as $currentItem) {
if (!in_array($currentItem, $snapshot, true)) {
$user->{$accessors['remover']}($currentItem);
}
}
foreach ($snapshot as $originalItem) {
if (!$currentCollection->contains($originalItem)) {
$user->{$accessors['adder']}($originalItem);
}
}
// Marquer comme non-dirty pour que Doctrine ne detecte pas de diff
// et n'emette pas de requete UPDATE inutile sur la table de jointure.
$currentCollection->takeSnapshot();
}
}
}

23
templates/base.html.twig Normal file
View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
{% block stylesheets %}
{% endblock %}
{% block javascripts %}
{% endblock %}
{% set frankenphpHotReload = app.request.server.get('FRANKENPHP_HOT_RELOAD') %}
{% if frankenphpHotReload %}
<meta name="frankenphp-hot-reload:url" content="{{ frankenphpHotReload }}">
<script src="https://cdn.jsdelivr.net/npm/idiomorph"></script>
<script src="https://cdn.jsdelivr.net/npm/frankenphp-hot-reload/+esm" type="module"></script>
{% endif %}
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>

View File

@@ -21,6 +21,8 @@ use PHPUnit\Framework\TestCase;
use ReflectionClass;
use stdClass;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
@@ -38,6 +40,7 @@ final class UserRbacProcessorTest extends TestCase
private MockObject&UnitOfWork $unitOfWork;
private MockObject&Security $security;
private AdminHeadcountGuardInterface&MockObject $adminHeadcountGuard;
private RequestStack $requestStack;
private UserRbacProcessor $processor;
protected function setUp(): void
@@ -48,6 +51,12 @@ final class UserRbacProcessorTest extends TestCase
$this->security = $this->createMock(Security::class);
$this->adminHeadcountGuard = $this->createMock(AdminHeadcountGuardInterface::class);
// Request vide par defaut pour les tests existants : la garde
// anti-ecrasement (restoreAbsentCollections) no-op quand le body est ''
// donc elle n'interfere pas avec les assertions deja en place.
$this->requestStack = new RequestStack();
$this->requestStack->push(new Request());
$this->entityManager->method('getUnitOfWork')->willReturn($this->unitOfWork);
// wrapInTransaction doit executer reellement la closure pour que le
@@ -63,6 +72,7 @@ final class UserRbacProcessorTest extends TestCase
$this->entityManager,
$this->security,
$this->adminHeadcountGuard,
$this->requestStack,
);
}