Un client nous a signalé un bug étrange sur son intranet : certains rédacteurs pouvaient modifier des articles qui ne leur appartenaient pas, alors que le code vérifiait pourtant current_user_can('edit_post', $post_id) avant chaque enregistrement. La vérification était bien là, correctement écrite. Le problème venait d’ailleurs : de la façon dont WordPress transforme cette capacité apparente en autorisations réelles.
C’est là qu’intervient map_meta_cap, l’un des mécanismes les plus mal compris de WordPress. Il ne s’agit pas d’une capacité stockée en base auprès de chaque utilisateur, mais d’une traduction dynamique qui dépend du contenu ciblé, de son auteur et de son statut. Comprendre cette mécanique évite des failles de sécurité discrètes et des bugs de permissions difficiles à reproduire.
Capacités primitives contre capacités méta
WordPress distingue deux familles de capacités. Les capacités primitives, comme edit_posts ou edit_others_posts, sont celles réellement stockées dans la table wp_usermeta, associées à un rôle. Les capacités méta, comme edit_post, delete_post ou read_post, n’existent nulle part en base : ce sont des alias contextuels qui n’ont de sens qu’appliqués à un objet précis, identifié par son ID.
Quand le code appelle current_user_can('edit_post', 42), WordPress ne cherche jamais une capacité littéralement nommée edit_post dans le profil de l’utilisateur. La fonction map_meta_cap() intercepte l’appel et le traduit en une ou plusieurs capacités primitives, en fonction de l’article 42 : qui l’a écrit, quel est son statut, à quel type de contenu il appartient.
Ce que fait réellement la traduction
Pour un article standard, la logique interne de map_meta_cap() ressemble à ceci : si l’utilisateur est l’auteur de l’article, la capacité requise devient edit_posts ; s’il n’en est pas l’auteur, elle devient edit_others_posts ; si l’article est publié, s’ajoute edit_published_posts ; s’il est en attente de relecture, la logique diffère encore selon les réglages du type de contenu.

C’est cette cascade de conditions qui explique pourquoi deux appels identiques en apparence, current_user_can('edit_post', 12) et current_user_can('edit_post', 99), peuvent renvoyer des résultats opposés pour le même utilisateur : tout dépend du contenu visé, pas de la capacité nommée.
Le piège des types de contenu personnalisés
Le bug de notre client venait d’une extension tierce enregistrant un type de contenu personnalisé avec 'capability_type' => 'post' mais 'map_meta_cap' => false. Résultat : WordPress ne traduisait plus edit_post en capacités primitives contextuelles, il retombait sur une vérification simplifiée qui ignorait la notion d’auteur. Tout utilisateur ayant edit_posts pouvait alors modifier n’importe quel contenu de ce type, y compris ceux des autres.
register_post_type( 'dossier_client', array(
'capability_type' => 'dossier_client',
'map_meta_cap' => true, // indispensable pour une traduction correcte
'capabilities' => array(
'edit_post' => 'edit_dossier_client',
'read_post' => 'read_dossier_client',
'delete_post' => 'delete_dossier_client',
'edit_posts' => 'edit_dossier_clients',
'edit_others_posts' => 'edit_others_dossier_clients',
'publish_posts' => 'publish_dossier_clients',
'read_private_posts' => 'read_private_dossier_clients',
),
) );
Sans map_meta_cap activé à true, les capacités méta déclarées dans le tableau capabilities ne sont jamais résolues en capacités primitives contextuelles : WordPress compare directement la chaîne à ce que possède l’utilisateur, sans tenir compte de qui a créé le contenu.
Écrire son propre filtre map_meta_cap
Il est possible d’aller plus loin que la traduction par défaut en accrochant le filtre du même nom, par exemple pour restreindre l’édition d’un dossier client à son créateur et aux administrateurs, quel que soit le rôle générique de l’utilisateur :
add_filter( 'map_meta_cap', function( $caps, $cap, $user_id, $args ) {
if ( 'edit_dossier_client' !== $cap || empty( $args[0] ) ) {
return $caps;
}
$post = get_post( $args[0] );
if ( ! $post ) {
return $caps;
}
if ( (int) $post->post_author === (int) $user_id ) {
return array( 'edit_dossier_clients' );
}
return array( 'manage_options' );
}, 10, 4 );
Ce filtre remplace intégralement la liste des capacités requises. C’est un point souvent mal compris : on ne complète pas la traduction par défaut, on la remplace, sauf à appeler la fonction native au préalable et à fusionner les résultats soi-même.
Vérifier la traduction en conditions réelles
- Utiliser
user_can( $user_id, 'edit_post', $post_id )plutôt quecurrent_user_can()lors des tests automatisés, pour cibler un utilisateur précis sans changer le contexte global. - Inspecter le résultat de
map_meta_cap( 'edit_post', $user_id, $post_id )directement dans une commande WP-CLI shell pour voir la liste des capacités primitives réellement exigées. - Se méfier des extensions qui déclarent
'capability_type' => 'post'sans le vouloir : cela mélange les permissions du nouveau type de contenu avec celles des articles standards.
Sur nos projets, la première chose que nous vérifions face à un bug de permissions n’est jamais le rôle de l’utilisateur, mais la déclaration
capability_typedu contenu concerné. C’est là que se cachent neuf pièges sur dix.
En résumé
La capacité edit_post n’a jamais de valeur fixe : c’est un contrat dont la traduction dépend entièrement du contenu ciblé. Ignorer cette mécanique, ou désactiver map_meta_cap par accident sur un type de contenu personnalisé, ouvre la porte à des permissions bien plus larges que ce que le code laisse penser. Avant de blâmer un rôle utilisateur mal configuré, il vaut mieux vérifier ce que map_meta_cap() a réellement produit pour l’objet en question.