Symfony switch_user : limiter l’impersonation avec Voter et isGranted

Guide pratique pour limiter l’impersonation dans Symfony 6.4 : Voter IMPERSONATE, SwitchUserEvent subscriber, endpoints POST+CSRF, journaux et bonnes pratiques PrestaShop.

Interface graphique de sécurité montrant un processus d'authentification avec des icônes et des schémas.

Table des matières :

  1. switch_user en Symfony 6.4 : ce qui se passe réellement dans la stack HTTP
  2. Le problème : ROLE_ALLOWED_TO_SWITCH est un marteau, pas une politique d’accès
  3. Définir une autorisation fine avec un Voter (IMPERSONATE) et des règles métier
  4. Bloquer l’impersonation au bon endroit : SwitchUserEvent Subscriber + isGranted()
  5. Exposer une UX et des endpoints sûrs : POST + CSRF, restrictions firewall, et _exit
  6. Audit, journalisation et garde-fous opérationnels (PrestaShop inclus)

switch_user en Symfony 6.4 : ce qui se passe réellement dans la stack HTTP

switch_user (impersonation) n’est pas une “feature UI”, c’est un mécanisme de la couche Security HTTP. Dans Symfony 6.4, l’activation se fait au niveau du firewall via switch_user, et l’implémentation repose sur le SwitchUserListener qui intercepte la requête avant que votre contrôleur ne s’exécute. Par défaut, l’activation s’appuie sur un paramètre de requête _switch_user (query string, POST, etc.) et un simple check de rôle (ROLE_ALLOWED_TO_SWITCH ou le rôle configuré).

Concrètement, côté pipeline Symfony, on peut résumer le chemin “impersonation” comme suit :

  1. le firewall “matche” la requête (par ex. /admin/...) ;
  2. le SwitchUserListener détecte la présence du paramètre (par défaut _switch_user) ;
  3. il vérifie que l’utilisateur courant possède le rôle autorisé (par défaut ROLE_ALLOWED_TO_SWITCH) ;
  4. il charge l’utilisateur cible via le user provider (souvent par identifiant / email / username selon votre config) ;
  5. il fabrique un SwitchUserToken (qui contient le token original) ;
  6. le token de la requête courante est remplacé dans le TokenStorage (et sera généralement persisté en session via le ContextListener).

Quand l’impersonation démarre, Symfony remplace le token courant par un SwitchUserToken qui encapsule le token original. Un rôle virtuel ROLE_PREVIOUS_ADMIN est injecté dans le token d’impersonation ; c’est le seul “signal” fiable côté application pour afficher un bandeau “vous êtes en train d’usurper…” ou conditionner un bouton de sortie (_switch_user=_exit). Ce point est important pour éviter les “fausses” détections (ex. si l’utilisateur cible a des rôles admin), et pour faire la différence entre :

  • un vrai admin connecté “normalement” ;
  • un admin qui opère en tant qu’un autre compte (support, test, reproduction de bug).

Si vous avez du remember-me, du session migration ou des proxies de session, le comportement varie selon la config (ex. invalidation de session). Ce n’est pas un détail : l’impersonation modifie le contexte d’auth et donc toutes les décisions d’accès basées sur isGranted(). Typiquement, toute règle conditionnée au compte courant (rôles, “tenant/shop”, restrictions par équipe, feature flags, etc.) va immédiatement s’appliquer… mais sur l’identité usurpée.

Dans l’écosystème PrestaShop, c’est pertinent surtout pour le back-office Symfony (PrestaShop 9 embarque Symfony 6.4 : voir les implications techniques dans l’article PrestaShop 9 : nouveautés techniques Symfony 6.4). Attention : la front office “legacy” n’est pas 100% Symfony, donc l’impersonation ne couvre que ce qui passe par le firewall Symfony concerné (souvent admin). Prérequis conseillés pour ce qui suit : PrestaShop 9.x, PHP 8.2+ (la 9.1 supporte jusqu’à PHP 8.5 : PrestaShop 9.1 : compatibilité PHP 8.1-8.5), et accès au code + config security.yaml.

Un point “terrain” en BO e-commerce : l’impersonation sert souvent à reproduire un problème “comme le voit” un employé (droits, menus, modules). Si votre environnement est multi-boutique / multi-marque, la bascule de contexte (shop) peut aussi reposer sur des variables de session ou des paramètres applicatifs : assurez-vous de savoir ce qui est porté par l’identité (token) vs ce qui est porté par le contexte (session, cookie, paramètres), sinon vous risquez des diagnostics biaisés (“en impersonation, ça marche” / “sans, ça casse”).

Le problème : ROLE_ALLOWED_TO_SWITCH est un marteau, pas une politique d’accès

Le cœur du mécanisme est volontairement simple : “si l’utilisateur courant a le rôle X, il peut switcher vers Y”. C’est fonctionnel, mais trop grossier pour un back-office réel : l’ACL de l’impersonation devrait dépendre de qui est ciblé, du contexte (tenant/shop), du niveau de privilège du compte cible, d’une contrainte d’auth forte (2FA), voire d’un processus d’audit. Symfony ne fait pas ce job à votre place : le listener ne consulte pas vos Voters au moment de l’acte.

Côté risques, c’est typiquement une porte d’entrée “Broken Access Control” : si un employé support possède ROLE_ALLOWED_TO_SWITCH, il peut (par erreur ou abus) usurper un compte plus privilégié (ex. super-admin), accéder à des secrets, modifier des moyens de paiement, ou déclencher des actions irréversibles. Dans une boutique multi-tenant/multi-marques, l’impersonation “cross-tenant” est souvent un incident de sécurité/contractuel, même si c’est “juste pour dépanner”. Et comme la bascule se fait très tôt dans le pipeline, beaucoup de contrôles applicatifs habituels ne s’exécutent pas.

Pour situer le niveau de priorité : dans OWASP Top 10 (2021), Broken Access Control est classé A01 (catégorie #1). Dans un BO PrestaShop exposé sur Internet, une impersonation trop permissive peut transformer un simple compte support en “super-pouvoir” transversal.

Mini-scénario réaliste (et fréquent) en support :

  • un agent N1 a ROLE_ALLOWED_TO_SWITCH “pour gagner du temps” ;
  • un client VIP a un compte employé plus privilégié (ou un compte “owner”) ;
  • un ticket arrive : “je ne vois pas tel menu / je n’arrive pas à exporter” ;
  • l’agent switch sur le compte VIP, reproduit… et voit aussi des écrans sensibles (moyens de paiement, clés API, comptes bancaires, données perso exportables), alors que ce n’était pas nécessaire pour résoudre le ticket.

Deux rappels de base cadrent le problème. Le premier, académique mais toujours actuel :

“The principle of least privilege states that every program and every user of the system should operate using the least set of privileges necessary to complete the job.” — Saltzer & Schroeder, The Protection of Information in Computer Systems (1975)

Traduction concrète : activer switch_user n’est jamais “fini”. Il faut une politique d’accès fine, des garde-fous techniques, et de la traçabilité.

Checklist rapide (utile avant même d’écrire du code) :

  • Qui a besoin d’impersoner, exactement (support N2 uniquement ? devops ? intégrateur ?) ?
  • Quelles cibles sont impersonables (clients internes, employés, comptes techniques) ?
  • Qu’est-ce qui est strictement interdit (super-admin, owner, comptes finance/paiement) ?
  • Quel périmètre est autorisé (même boutique / même tenant / même entité légale) ?
  • Quel prérequis d’authentification (2FA obligatoire, réseau/VPN, SSO) ?
  • Quelle trace vous devez conserver (audit interne, incident response, conformité) ?

Définir une autorisation fine avec un Voter (IMPERSONATE) et des règles métier

Le bon outil Symfony pour exprimer une décision “peut-on faire X sur cet objet Y ?” est le Voter. Un Voter reçoit un attribute (ex. IMPERSONATE) et un subject (ex. l’utilisateur cible), puis rend ACCESS_GRANTED | DENIED | ABSTAIN. isGranted() (dans un contrôleur, un template Twig, un service via AuthorizationCheckerInterface) interroge la chaîne de voters. L’intérêt : vous encodez une règle métier testable, versionnée, et réutilisable.

Un modèle de règle réaliste (à adapter) pour limiter l’impersonation :

  • l’acteur doit avoir un rôle “support” explicite (ROLE_SUPPORT), distinct de l’admin global ;
  • l’acteur doit avoir un second facteur actif (ou un marqueur “auth forte” si vous avez une brique SSO) ;
  • la cible ne doit jamais avoir ROLE_SUPER_ADMIN (ou le rôle “owner” de votre plateforme) ;
  • l’acteur et la cible doivent être dans le même périmètre : même boutique / même shop group / même tenant ;
  • éventuellement : interdiction d’impersoner un compte inactif, verrouillé, ou un compte technique.

Pour garder la règle lisible, il est souvent utile de formaliser ce que vous cherchez à protéger (exemple de matrice) :

Condition Pourquoi Exemple de mise en œuvre
ROLE_SUPPORT requis éviter la dérive “tout admin peut switch” rôle dédié, assignation nominative
même tenant/shop éviter le cross-tenant tenantId / shopId sur acteur+cible
cible non super-admin éviter l’élévation totale blocage dur côté Voter
2FA activé réduire le risque d’abus de compte flag user, claim SSO, ou facteur TOTP
cible active éviter les contournements / comptes dormants isActive(), isLocked()

Exemple de Voter Symfony 6.4 (PHP 8.2+). Ici, on suppose une entité User qui expose getRoles(), getTenantId(), isTwoFactorEnabled(), etc. Dans PrestaShop, vous devrez mapper ça à votre modèle (ex. employés / permissions BO) ou à vos entités Doctrine de module.

<?php

namespace App\Security\Voter;

use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;

final class ImpersonateVoter extends Voter
{
    public const ATTRIBUTE = 'IMPERSONATE';

    protected function supports(string $attribute, mixed $subject): bool
    {
        return $attribute === self::ATTRIBUTE && $subject instanceof User;
    }

    /** @param User $subject */
    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
    {
        $actor = $token->getUser();
        if (!$actor instanceof UserInterface) {
            return false;
        }

        // 1) rôle de base
        if (!in_array('ROLE_SUPPORT', $token->getRoleNames(), true)) {
            return false;
        }

        // 2) pas d’auto-impersonation (souvent inutile, parfois source de bugs)
        if (method_exists($actor, 'getId') && method_exists($subject, 'getId') && $actor->getId() === $subject->getId()) {
            return false;
        }

        // 3) ne jamais impersoner un super-admin
        if (in_array('ROLE_SUPER_ADMIN', $subject->getRoles(), true)) {
            return false;
        }

        // 4) périmètre/tenant
        if (method_exists($actor, 'getTenantId') && method_exists($subject, 'getTenantId')) {
            if ($actor->getTenantId() !== $subject->getTenantId()) {
                return false;
            }
        }

        // 5) auth forte (exemple)
        if (method_exists($actor, 'isTwoFactorEnabled') && !$actor->isTwoFactorEnabled()) {
            return false;
        }

        return true;
    }
}

Deux nuances pratiques :

  • Retour par défaut = refus : sur une feature aussi sensible, évitez les “ABSTAIN” implicites. Si vous ne pouvez pas évaluer une condition (ex. tenantId absent), préférez refuser et forcer un modèle de données cohérent.
  • Rôle “support” ≠ rôle “switch” : gardez ROLE_ALLOWED_TO_SWITCH comme verrou technique minimal, puis utilisez votre Voter pour la granularité. Ça permet de désactiver globalement l’impersonation en prod (retirer ROLE_ALLOWED_TO_SWITCH) sans toucher à la logique métier.

Bloquer l’impersonation au bon endroit : SwitchUserEvent Subscriber + isGranted()

Point critique : le Voter ne sera jamais appelé si vous vous contentez de protéger un lien ou un bouton. Pourquoi ? Parce que l’attaque n’a pas besoin de passer par votre UI : une requête contenant _switch_user=... suffit, et le switch se produit avant que votre contrôleur n’évalue isGranted(). C’est précisément la raison pour laquelle beaucoup d’implémentations “on cache le bouton” sont des fausses sécurités.

Le correctif propre consiste à écouter l’événement SwitchUserEvent (Symfony Security HTTP) et à veto la bascule si isGranted('IMPERSONATE', $targetUser) renvoie false. À ce stade, Symfony vous donne le token, l’utilisateur cible et le type d’action (switch/exit). Sur un refus, vous jetez une AccessDeniedException (ou une exception dérivée) : la requête est stoppée avant d’atteindre votre applicatif.

À valider dans votre contexte : selon la configuration (session, listeners, gestion d’exceptions), testez bien que le refus ne persiste pas un token “switché” en session. En pratique, un AccessDeniedException au moment du switch doit empêcher une impersonation effective, mais écrivez un test fonctionnel pour le garantir.

<?php

namespace App\Security\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Http\Event\SwitchUserEvent;
use Symfony\Component\Security\Http\SecurityEvents;

final class SwitchUserAuthorizationSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private readonly AuthorizationCheckerInterface $auth,
    ) {}

    public static function getSubscribedEvents(): array
    {
        return [
            SecurityEvents::SWITCH_USER => 'onSwitchUser',
        ];
    }

    public function onSwitchUser(SwitchUserEvent $event): void
    {
        // Quand on sort d’une impersonation, on ne bloque pas (sinon lock-in)
        if ($event->getRequest()->get('_switch_user') === '_exit') {
            return;
        }

        $target = $event->getTargetUser();
        if (!$this->auth->isGranted('IMPERSONATE', $target)) {
            throw new AccessDeniedException('Impersonation refusée par la politique de sécurité.');
        }
    }
}

Astuce utile en exploitation : loguez aussi les refus (sans révéler trop d’informations). Un pic de refus d’impersonation peut indiquer un script, une mauvaise UI, ou une tentative de contournement.

Dans un module PrestaShop 9, ce subscriber se déclare comme service Symfony (YAML/XML/PHP DI). Si vous n’êtes pas à l’aise avec l’enregistrement de services dans un module, l’article Module PrestaShop 9 : structure, services et bonnes pratiques Symfony est la base : testez aussi le cas _exit, et le comportement avec des comptes supprimés/désactivés (selon votre provider, la cible peut exister ou non, et le listener peut échouer avant votre subscriber).

Exposer une UX et des endpoints sûrs : POST + CSRF, restrictions firewall, et _exit

Une fois la décision d’accès réellement enforce côté serveur, vous pouvez vous permettre une UI propre. Par défaut, Symfony accepte _switch_user sur une query string : c’est pratique, mais ça ouvre des scénarios “clickjacking / lien piégé” si vos admins cliquent n’importe où. La mitigation de base : n’exposez pas d’URL GET d’impersonation ; créez une route dédiée en POST, protégée par CSRF, qui redirige vers une URL interne contenant _switch_user (ou déclenche l’action sur une requête POST).

Bon compromis “pragmatique” : gardez la mécanique native de Symfony (paramètre _switch_user) mais rendez-la inaccessible depuis l’extérieur sauf via une action POST maîtrisée.

Exemple contrôleur (Symfony 6.4) :

<?php

namespace App\Controller\Admin;

use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

final class ImpersonationController extends AbstractController
{
    #[Route('/admin/impersonate/{id}', name: 'admin_impersonate', methods: ['POST'])]
    public function impersonate(Request $request, User $user): RedirectResponse
    {
        $this->denyAccessUnlessGranted('IMPERSONATE', $user);
        $this->denyAccessUnlessGranted('ROLE_ALLOWED_TO_SWITCH');

        if (!$this->isCsrfTokenValid('impersonate_'.$user->getId(), (string) $request->request->get('_token'))) {
            throw $this->createAccessDeniedException('CSRF invalide');
        }

        // Redirection vers une URL du firewall où switch_user est actif
        return $this->redirect('/admin/dashboard?_switch_user='.$user->getUserIdentifier());
    }
}

Deux durcissements simples (souvent oubliés) :

  • encodez l’identifiant dans l’URL si besoin (rawurlencode) ; selon votre user_identifier, certains caractères peuvent casser la query string ;
  • évitez de rediriger vers une URL “ouverte” (open redirect) : redirigez vers une route interne fixe ou une whitelist.

Dans Twig, vous conditionnez l’affichage du bouton via le même Voter, ce qui évite les fuites d’UX (et limite les tentatives “au hasard”, mais ne remplace pas le subscriber) :

{% if is_granted('IMPERSONATE', targetUser) %}
  <form method="post" action="{{ path('admin_impersonate', {id: targetUser.id}) }}">
    <input type="hidden" name="_token" value="{{ csrf_token('impersonate_' ~ targetUser.id) }}">
    <button type="submit">Impersonate</button>
  </form>
{% endif %}

{% if is_granted('ROLE_PREVIOUS_ADMIN') %}
  <a href="?_switch_user=_exit">Sortir de l’impersonation</a>
{% endif %}

Enfin, verrouillez la surface d’attaque dans security.yaml : (1) un rôle explicite pour switcher, (2) un paramètre non standard si vous voulez réduire le bruit, (3) des access_control stricts sur les routes d’impersonation, et (4) HTTPS obligatoire sur le back-office. Exemple (adaptation à votre projet) :

security:
  firewalls:
    admin:
      # ...
      switch_user:
        role: ROLE_ALLOWED_TO_SWITCH
        parameter: _switch_user # ou un nom moins standard si vous préférez

Pour la partie durcissement infra/app (cookies SameSite, headers, WAF), vous avez déjà un socle dans Sécurité PrestaShop : mises à jour, SSL et durcissement .htaccess.

Documentation externe utile (référence canonique Symfony) :

Pour cadrer le risque “contrôle d’accès cassé” (et parler la même langue que vos audits sécu), une référence utile est aussi OWASP :

Audit, journalisation et garde-fous opérationnels (PrestaShop inclus)

L’impersonation sans audit est une dette. Le minimum viable : loguer systématiquement les événements SWITCH_USER (qui switch, vers qui, quand, IP, user-agent, route, tenant/shop). Faites-le dans le même subscriber (ou un autre) en branchant Monolog, et envoyez ces logs vers votre pipeline (ELK, Loki, SIEM). Dans un contexte e-commerce, la traçabilité se recoupe souvent avec des exigences RGPD et des runbooks internes ; l’article Automatisation PrestaShop : sécurité RGPD, moindre privilège et journaux d’audit donne un cadre opérationnel pour les journaux et le modèle de privilèges.

Un format de log utile (sans sur-collecter de données) ressemble à ceci :

Champ Exemple Pourquoi
actor_id / actor_identifier 42 / support@… attribution
target_id / target_identifier 314 / client@… traçabilité
action switch / exit distinguer entrée/sortie
firewall admin périmètre
tenant/shop shop_1 éviter cross-tenant invisible
ip / user_agent 203.0.113.10 détection d’anomalies
request_uri /admin/... investigation

Ajoutez des garde-fous pragmatiques : rate limiting sur l’endpoint d’impersonation (si votre support tente 200 users à la suite, c’est suspect), obligation de 2FA/SSO pour les rôles autorisés, et éventuellement un “break-glass” (un rôle temporaire qui expire) au lieu d’un rôle permanent. Si vous exposez des APIs back-office, n’autorisez jamais switch_user sur un firewall API stateless (tokens/bearer) : vous mélangez des modèles d’auth incompatibles. Pour un back-office exposé sur Internet, couplez avec des contrôles réseau (WAF, allowlist, MFA), cf. Sécurité PrestaShop : protéger API backoffice, WAF et journalisation SIEM.

Côté validation, ne laissez pas ça “au feeling”. Écrivez : (1) des tests unitaires sur le Voter (actor support / non support, tenant mismatch, cible super-admin), (2) un test fonctionnel sur le subscriber (requête avec _switch_user doit renvoyer 403 si interdit), (3) un test d’intégration sur le chemin POST+CSRF. Un bon test “anti-régression” est de simuler une requête directe du type :

  • connecté en support ;
  • appel d’une URL admin avec ?_switch_user=superadmin ;
  • attendu : 403, et pas de token d’impersonation persistant.

Et monitoriez : l’impersonation change la fréquence d’accès à certains contrôleurs et peut masquer des anomalies ; ayez de la visibilité (CPU, erreurs 403/500, pics de sessions) via un outil type Netdata : Netdata — monitoring. Si vous stockez des audits en base, surveillez aussi l’impact (index, purge) — la routine de maintenance est souvent négligée, puis ça explose en prod.


À lire aussi