Tutoriel Vidéo PHP Créer un système de permissions

Télécharger la vidéo Télécharger les sources

Dans ce tutoriel je vous propose de réfléchir à la mise en place d'un système de permissions en PHP. L'objectif est de mettre en place un système qui nous permettra de vérifier si l'utilisateur est autorisée à effectuer une action spécifique au sein de notre application.

00:00 Présentation des systèmes de permissions existants
09:50 On crée notre propre système

Les stratégies

A travers mon exploration de différents frameworks / technologies j'ai pu découvrir différentes approches du problèmes.

Permissions hiérarchique

Cette stratégie consiste à créer différents rôles en leur attribuant un nombre spécifique (plus le nombre est important plus la permission est élevée).

const ROLE_ADMIN = 100
const ROLE_MODERATEUR = 10
const ROLE_USER = 1

Les utilisateurs se voient alors attribués un niveau en fonction de ces constantes. On peut ensuite utiliser ce niveau pour contrôler l'accès à une fonctionnalité.

if ($user->role < ROLE_MODERATEUR) {
    throw new ForbiddenException();
}
// On fait le traitement

Cette approche suffit pour des cas simples mais s'avère limitée pour des cas plus complexes, surtout lorsque de la logique vient s'ajouter aux vérifications (par exemple un utilisateur ne peut modifier que ses articles, mais un administrateur peut éditer tous les articles) ou lorsque les permissions ne sont pas hiérarchique.

// La logique de permission devient de plus en plus complexe avec le temps
if ($user->role >= ROLE_ADMIN || ($user->role >= ROLE_CONTRIBUTEUR && $post->userId === $user->id)) {
    throw new ForbiddenException();
}
// On fait le traitement

Les roles

Une autre idée est de créer des rôles pour les utilisateurs et d'associer une série de permissions à ces rôles.

$permissions = [
    'ROLE_ADMIN' => [
        'can_edit_post',
        'can_update_post',
        'can_create_post',
        'can_read_post',
    ],
    'ROLE_USER' => [
        'can_read_post'
    ]
]

On peut aussi ajouter des conditions si on souhaite plus de flexibilité dans les conditions d'accès à une certaines permissions.

$permissions = [
    'ROLE_ADMIN' => [
        'can_update_post',
        'can_create_post',
        'can_delete_post',
        'can_read_post',
    ],
    'ROLE_CONTRIBUTEUR' => [
        'can_update_post' => function (User $user, Post $post) { return $post->user->id === $user->id; }
        'can_create_post',
        'can_read_post'
    ]
    'ROLE_USER' => [
        'can_read_post' => function (User $user, Post $post) { return $post->isOnline; }
    ]
    'ROLE_ANONYMOUS' => [
        'can_read_post' => function (User $user, Post $post) { return $post->isPublic; }
    ]
]

Cette approche est déjà beaucoup plus intéréssante car elle permet de gérer des rôles complètements différents et l'utilisateur peut même se voir attribuer plusieurs rôles. En revanche cela implique de créer un objet qui va contenir l'ensemble des permission en amont et lors de la monté en complexité de l'application le nombre de permissions / conditions peut devenir difficile à gérer.

Les capacités

Cette approche est utilisée par la librairie CanCanCan et consiste à définir les capacités offertes aux utilisateur en amont en fonction de son profil ou de son rôle.

class Ability
  include CanCan::Ability

  def initialize(user)
    can :read, Post, public: true

    if user.present?  # additional permissions for logged in users (they can read their own posts)
      can :read, Post, user_id: user.id

      if user.admin?  # additional permissions for administrators
        can :read, Post
      end
    end
  end
end

Cette approche se rapproche beaucoup de l'approche précédante mais la déclaration des permissions se fait de manière différente en associant au nom de la permission l'objet qui est la cible de la demande. Une version PHP ressemblerait à ça :

class Ability {

    public function __construct(?User $user = null) {

        $this->can('read', Post::class, ['public' => true]);

        if ($user !== null) {
            $this->can('read', Post::class, ['user_id' => $user->id]);

            if ($user->isAdmin) {
                $this-can('read', Post::class)
            }
        }
    }

}

Comme pour la méthode précédente le problème est la multiplication des règles lors de la montée en complexité des permissions et cela peut être compliqué de s'y retrouver dans la définition des règles / conditions.

Les politiques

Cette approche est visible sur le framework Laravel et consiste à définir un système de politique d'accès. Cette approche permet de se focaliser sur la cible de la demande de permission plutôt que de centrer les choses autour de l'utilisateur.

<?php

class PostPolicy
{

    public function create(User $user, Post $post)
    {
        return $user->isAdmin || $user->isContributeur;
    }

    public function update(User $user, Post $post)
    {
        return ($user->isContributeur && $user->id === $post->userId) || $user->isAddmin;
    }

    public function read(User $user, Post $post)
    {
        return $post->isPublic;
    }
}

Une fois cette politique définie elle peut être associée à une classe (souvent un modèle) :

class AuthServiceProvider extends ServiceProvider
{
    protected $policies = [
        Post::class => PostPolicy::class,
    ];

    public function boot() {
        $this->registerPolicies();
    }
}

Ainsi, lorsque la cible d'une permission est un article, la méthode correspondant au nom de la permission sera appelée afin de donner (ou non) l'autorisation à l'utilisateur.

Gate::authorize('update', $post); // Renverra une exception si le résultat de PostPolicy::update est false

Cette approche est intéréssante car elle permet de créer des politiques génériques qui peuvent être appliquées à plusieurs modèles. En revanche, des permissions transversales sont toujours problématiques (le super administrateur à accès à tous le site par exemple) et il faudra supplémenter ce système avec un système plus classique similaire à CanCanCan. Laravel propose un système de gate imitant l'approche précédente.

Gate::define('edit-settings', function ($user) {
    return $user->isAdmin;
});

Les voters

Ce système est plus basique que les précédents et consiste à définir la gestion des permissions comme un système de votes. On commence par enregistrer une série de Voter dans notre application. Lorsqu'une permission est demandée l'ensemble des Voter vont être consultés et ils vont indiquer si ils participent ou non au vote. Les Voter qui participent vont ensuite voter pour donner ou non leur accord sur la permission demandée. Enfin, une politique de réconciliation va être utilisée pour définir si la permission est accordée ou non. Il existe plusieurs stratégie de réconciliation

  • Affirmative. La permission est accordée dès lors qu'un Voter vote OUI.
  • Unanime. La permission est accordée si tous les Voter votent OUI.
  • Consensus. La permission est accordée si les Voter votant OUI sont majoritaires.

La mise en place d'un tel système est très simple. L'interface d'un voter lui permet de déclarer sa participation à un vote, et le résultat de son vote

<?php declare(strict_types=1);

interface Voter
{

    public function canVote (string $permission, $subject = null): bool;

    public function vote (User $user, string $permission, $subject = null): bool;

}

La classe permettant de vérifier les permissions (on utilise ici la stratégie Affirmative) sera composée d'une méthode permettant l'enregistrement de Voter et une méthode permettant de les consulter pour une demande d'autorisation.

<?php declare(strict_types=1);

final class Permission
{

    /**
     * @var Voter[]
     */
    private array $voters = [];

    public function can(User $user, string $permission, $subject = null): bool
    {
        foreach($this->voters as $voter) {
            if($voter->canVote($permission, $subject)) {
                $vote = $voter->vote($user, $permission, $subject);
                if($this->debugger) {
                    $this->debugger->debug($voter, $vote, $permission, $user, $subject);
                }
                if ($vote === true) {
                    return true;
                }
            }
        }
        return false;
    }

    public function addVoter(Voter $voter): void
    {
        $this->voters[] = $voter;
    }

}

Cette stratégie offre l'avantage de permettre la définition de permission transversale très simplement

class AdminVoter extends Voter
{

    public function canVote (string $permission, $subject = null): bool
    {
        return true;
    }

    /**
     * @inheritDoc
     */
    public function vote (User $user, string $permission, $subject = null): bool
    {
        return $user->getRole() === Roles::ADMIN;
    }
}

La détection d'une interface peut aussi servir à définir une logique de permission générale.

class OwnerVoter extends Voter
{

    public function canVote (string $permission, $subject = null): bool
    {
        return $subject instanceOf Ownable;
    }

    /**
     * @inheritDoc
     */
    public function vote (User $user, string $permission, $subject = null): bool
    {
        return $user->getId() === $subject->getOwner()->getId();
    }
}

Il est aussi d'adapter cette solution pour la combiner à une autre approche (par exemple un système de permission ACL en base de données).

Ce système est du coup intéressant car il peut servir de base solide pour définir des permissions avec différentes politiques. En revanche, il peut être parfois difficile de comprendre pourquoi une permission a été donnée ou refusée. Il ne faudra donc pas hésiter à greffer à ce système un outil de debug qui permettra de comprendre pourquoi une permission a été attribuée (ou non) en indiquant les Voter qui ont participés et les résultats de leur vote.