fix(review) : resout la regression drawers RBAC + race snapshot + stale-data admin
Issues remontees par la seconde passe de review de la PR #9 : - Regression `GET /api/permissions` 403 silencieux sur les drawers RBAC (UserRbacDrawer, RoleDrawer) apres le fix precedent qui imposait `core.permissions.view`. Les users porteurs de `core.users.manage` / `core.roles.manage` ne voyaient plus le catalogue pour hydrater leurs checkboxes. Elargit la security expression sur Permission en OR avec ces deux codes : les gestionnaires ont par nature besoin du catalogue (codes/libelles seuls, pas de secret expose). - Race condition dans UserRbacProcessor : `restoreAbsentCollections()` lisait le snapshot Doctrine hors transaction, puis `wrapInTransaction()` flushait plus tard. Fenetre courte mais reelle ou une modification concurrente aurait pu etre annulee par une restauration depuis un snapshot stale. Deplace l'appel a l'interieur de la transaction. - Stale-data sur les pages admin users / roles / sites : meme pattern try/finally sans catch que sur audit-log (deja corrige). Aligne les trois pages avec un catch qui reset la liste locale. - Tests manquants : garde de non-regression sur PATCH /rbac sans `sites` (assure que la collection elle-meme est preservee, pas seulement le currentSite). Couverture positive sur GET /api/permissions pour les trois branches OR de la security expression (permissions.view, users.manage, roles.manage) via des users non-admin. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -114,6 +114,10 @@ async function loadRoles() {
|
||||
{ toast: false },
|
||||
)
|
||||
roles.value = data.member
|
||||
} catch {
|
||||
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
|
||||
// requete reussie avant une perte reseau ou 403).
|
||||
roles.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -78,6 +78,11 @@ async function loadUsers() {
|
||||
try {
|
||||
const usersData = await api.get<{ member: UserListItem[] }>('/users', {}, { toast: false })
|
||||
users.value = usersData.member
|
||||
} catch {
|
||||
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
|
||||
// requete reussie avant une perte reseau ou 403). Pas de toast par
|
||||
// design ici : on laisse la liste vide parler d'elle-meme.
|
||||
users.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -118,6 +118,10 @@ async function loadSites() {
|
||||
{ toast: false },
|
||||
)
|
||||
sites.value = data.member
|
||||
} catch {
|
||||
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
|
||||
// requete reussie avant une perte reseau ou 403).
|
||||
sites.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
22
makefile
22
makefile
@@ -73,8 +73,10 @@ seed-e2e:
|
||||
|
||||
# Bootstrap one-time pour les tests E2E sur un nouveau poste :
|
||||
# 1. Telecharge Chromium dans ~/.cache/ms-playwright
|
||||
# 2. Installe les deps systeme (libnss3, libasound, libatk, etc.) via
|
||||
# la liste officielle Playwright — demande sudo.
|
||||
# 2. Installe les deps systeme (libnss3, libasound, libatk, etc.) :
|
||||
# - Ubuntu/Debian : `playwright install-deps` (officiel)
|
||||
# - Fedora/RHEL : liste dnf equivalente (playwright ne gere pas dnf)
|
||||
# - Autre : avertissement, a faire a la main.
|
||||
#
|
||||
# Le `sudo env "PATH=$$PATH"` est necessaire car avec NVM, `sudo npx` ne
|
||||
# trouve pas npx (le PATH de sudo est vide par defaut). On preserve
|
||||
@@ -84,7 +86,21 @@ seed-e2e:
|
||||
# bouger entre versions majeures).
|
||||
install-e2e-deps:
|
||||
cd frontend && npx playwright install chromium
|
||||
cd frontend && sudo env "PATH=$$PATH" npx playwright install-deps chromium
|
||||
@if command -v apt-get >/dev/null 2>&1; then \
|
||||
echo ">> Detected apt-get — using playwright install-deps"; \
|
||||
cd frontend && sudo env "PATH=$$PATH" npx playwright install-deps chromium; \
|
||||
elif command -v dnf >/dev/null 2>&1; then \
|
||||
echo ">> Detected dnf — installing Chromium deps via dnf"; \
|
||||
sudo dnf install -y \
|
||||
nss nspr dbus-libs atk at-spi2-atk at-spi2-core cups-libs \
|
||||
libdrm libX11 libXcomposite libXdamage libXfixes libXrandr \
|
||||
libXext libXtst libxkbcommon mesa-libgbm alsa-lib \
|
||||
pango cairo libwayland-client; \
|
||||
else \
|
||||
echo ">> No supported package manager detected (apt-get / dnf)."; \
|
||||
echo ">> Install Chromium system libs manually, then re-run test-e2e."; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# Lance les tests E2E Playwright sur l'host. Pre-requis :
|
||||
# - `make install-e2e-deps` (une fois par poste)
|
||||
|
||||
@@ -18,13 +18,20 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
// Guard RBAC du catalogue de permissions : accepte les gestionnaires
|
||||
// de users et de roles en plus du code dedie `core.permissions.view`.
|
||||
// Justification : les drawers `UserRbacDrawer`/`RoleDrawer` fetchent
|
||||
// systematiquement ce catalogue pour afficher les checkboxes de
|
||||
// permissions ; exiger uniquement `core.permissions.view` casserait
|
||||
// ces workflows pour tout gestionnaire non-admin. L'endpoint n'expose
|
||||
// que des codes/libelles (pas de secret), le bypass reste acceptable.
|
||||
new GetCollection(
|
||||
normalizationContext: ['groups' => ['permission:read']],
|
||||
security: "is_granted('core.permissions.view')",
|
||||
security: "is_granted('core.permissions.view') or is_granted('core.users.manage') or is_granted('core.roles.manage')",
|
||||
),
|
||||
new Get(
|
||||
normalizationContext: ['groups' => ['permission:read']],
|
||||
security: "is_granted('core.permissions.view')",
|
||||
security: "is_granted('core.permissions.view') or is_granted('core.users.manage') or is_granted('core.roles.manage')",
|
||||
),
|
||||
],
|
||||
)]
|
||||
|
||||
@@ -93,19 +93,6 @@ 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 ?
|
||||
@@ -164,6 +151,20 @@ final class UserRbacProcessor implements ProcessorInterface
|
||||
$originalCurrentSiteId,
|
||||
&$result,
|
||||
): void {
|
||||
// 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.
|
||||
//
|
||||
// Execute dans la transaction (et non avant) : garantit que le snapshot
|
||||
// Doctrine lu pour restauration reflete le meme etat BDD que celui sur
|
||||
// lequel le persist va operer. Evite toute fenetre de race entre la
|
||||
// lecture du snapshot et le flush.
|
||||
$this->restoreAbsentCollections($data);
|
||||
|
||||
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
|
||||
// Garde coherence currentSite (ticket 2 module Sites).
|
||||
|
||||
@@ -191,6 +191,41 @@ final class PermissionApiTest extends AbstractApiTestCase
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testNonAdminWithPermissionViewCanListPermissions(): void
|
||||
{
|
||||
// Chemin positif : un user non-admin qui porte la permission
|
||||
// `core.permissions.view` (via un role dedie) doit recevoir 200 sur
|
||||
// le catalogue. Couvre l'habilitation sans bypass isAdmin.
|
||||
$creds = $this->createUserWithPermission('core.permissions.view');
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
$client->request('GET', '/api/permissions');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testNonAdminWithUsersManageCanListPermissions(): void
|
||||
{
|
||||
// Bypass pragmatique : les gestionnaires d'users ont besoin du
|
||||
// catalogue pour les drawers RBAC. Couvre la branche OR de la
|
||||
// security expression `core.users.manage`.
|
||||
$creds = $this->createUserWithPermission('core.users.manage');
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
$client->request('GET', '/api/permissions');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testNonAdminWithRolesManageCanListPermissions(): void
|
||||
{
|
||||
// Meme logique que ci-dessus pour les gestionnaires de roles
|
||||
// (branche OR `core.roles.manage` de la security expression).
|
||||
$creds = $this->createUserWithPermission('core.roles.manage');
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
$client->request('GET', '/api/permissions');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
private function cleanupTestPermissions(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
@@ -166,6 +166,12 @@ final class UserRbacSitesApiTest extends AbstractApiTestCase
|
||||
$reloaded = $em->getRepository(User::class)->find($aliceId);
|
||||
self::assertNotNull($reloaded->getCurrentSite());
|
||||
self::assertSame('Chatellerault', $reloaded->getCurrentSite()->getName());
|
||||
|
||||
// Garde non-regression : la collection `sites` elle-meme doit etre
|
||||
// preservee (cf. fix restoreAbsentCollections + initialize LAZY). Un
|
||||
// PATCH /rbac sans cle `sites` ne doit en aucun cas vider la relation.
|
||||
self::assertCount(1, $reloaded->getSites());
|
||||
self::assertSame('Chatellerault', $reloaded->getSites()->first()->getName());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user