Chapter 10

Security Voterを使ってユーザー編集・削除の権限を整理

たつきち
たつきち
2022.07.30に更新

この章に対応するコミット

Security Voterを使ってユーザー編集・削除の権限を整理

じっくり解説してきたユーザー周りの実装もこれでやっと最後です。

現状、ユーザー追加・編集・削除画面へのアクセスは、

/**
 * @IsGranted("ROLE_ALLOWED_TO_EDIT_USER")
 */

{% if is_granted('ROLE_ALLOWED_TO_EDIT_USER') %}
  <a href="{{ pathWithReturnTo('user_edit', {id: user.id}) }}" class="ml-sm-3">編集</a>
{% endif %}

のように、 ROLE_ALLOWED_TO_EDIT_USER 権限を持っているユーザーだけに制限されています。

が、逆に言えば ROLE_ALLOWED_TO_EDIT_USER 権限さえ持っていれば誰でもアクセスできるわけで、例えば ROLE_ALLOWED_TO_EDIT_USERしか持っていないユーザーが ROLE_ALLOWED_TO_ADMIN を持っているユーザーを編集してしまうこともできる状態です。

これってちょっと変ですよね。

「ユーザーを編集できる権限」と「管理者権限」はそれぞれ別々の役割ではありますが、そこには明確な上下関係があり、管理者の追加・編集・削除は管理者でないとできないようにしたい という要件が見えてきます。

また、多くの場合、ユーザーが自分自身を削除できてしまうことも問題になります。

ユーザー編集画面にアクセスできるのは ROLE_ALLOWED_TO_EDIT_USER を持っているユーザーだけですから、もしそのユーザーが唯一の ROLE_ALLOWED_TO_EDIT_USER 保有者だった場合、自分を削除できてしまうと他に誰も ROLE_ALLOWED_TO_EDIT_USER を持っているユーザーがいない状況になってしまいます。

これでは困るので、ユーザーは自分自身を削除できないようにしたい という要件も必要そうです。

というわけで、Security Voter を使ってこれらを実装していきます。

なお、Security Voterについては以下の過去記事で詳しく説明していますので、ぜひあわせてご参照ください✋

[Symfony] Security Voterを使って「リソースの所有者でないと編集不可」を実装してみよう

Voterを実装

// src/Security/Voter/UserVoter.php

namespace App\Security\Voter;

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

class UserVoter extends Voter
{
    const EDIT = 'EDIT';
    const DELETE = 'DELETE';

    private RoleManager $rm;

    public function __construct(RoleManager $rm)
    {
        $this->rm = $rm;
    }

    protected function supports($attribute, $subject)
    {
        if (!in_array($attribute, [
            self::EDIT,
            self::DELETE,
        ])) {
            return false;
        }

        if (!$subject instanceof User) {
            return false;
        }

        return true;
    }

    protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
    {
        $user = $token->getUser();

        if (!$user instanceof User) {
            return false;
        }

        switch ($attribute) {
            case self::EDIT:
                return $this->canEdit($subject, $user);
            case self::DELETE:
                return $this->canDelete($subject, $user);
        }

        throw new \LogicException();
    }

    private function canEdit(User $them, User $me)
    {
        if (!$this->rm->isGranted($me, 'ROLE_ALLOWED_TO_EDIT_USER')) {
            return false;
        }

        // 管理者に対する編集は管理者しかできない
        if ($this->rm->isGranted($them, 'ROLE_ALLOWED_TO_ADMIN')) {
            if (!$this->rm->isGranted($me, 'ROLE_ALLOWED_TO_ADMIN')) {
                return false;
            }
        }

        return true;
    }

    private function canDelete(User $them, User $me): bool
    {
        // 編集可能であることは前提として、相手が自分自身でない場合にしか削除はできない
        return $this->canEdit($them, $me) && $them !== $me;
    }
}

これで、ユーザーエンティティに対する編集可否の判定がVoter経由でできるようになりました👍

コントローラの @IsGranted アノテーションの内容を修正

以下のように edit change_password delete アクションのアノテーションを修正します。

  /**
   * @Route("/{id}/edit", name="edit", methods={"GET", "POST"})
-  * @IsGranted("ROLE_ALLOWED_TO_EDIT_USER")
+  * @IsGranted("EDIT", subject="user", statusCode=403)
   */
  public function edit(Request $request, User $user)
  /**
   * @Route("/{id}/change_password", name="change_password", methods={"GET", "POST"})
-  * @IsGranted("ROLE_ALLOWED_TO_EDIT_USER")
+  * @IsGranted("EDIT", subject="user", statusCode=403)
   */
  public function changePassword(Request $request, User $user)
  /**
   * @Route("/delete/{id}", name="delete", methods={"GET", "DELETE"})
-  * @IsGranted("ROLE_ALLOWED_TO_EDIT_USER")
+  * @IsGranted("EDIT", subject="user", statusCode=403)
   */
  public function delete(Request $request, User $user)

ビューの {% if is_granted() %} の内容を修正

以下のようにユーザー編集画面へのリンクを囲っている if 文を修正します。

  # user/index.html.twig
  
- {% if is_granted('ROLE_ALLOWED_TO_EDIT_USER') %}
+ {% if is_granted('EDIT', user) %}
    <a href="{{ pathWithReturnTo('user_edit', {id: user.id}) }}" class="ml-sm-3">編集</a>
  {% endif %}
  # user/show.html.twig
  
- {% if is_granted('ROLE_ALLOWED_TO_EDIT_USER') %}
+ {% if is_granted('EDIT', user) %}
    <li class="nav-item">
      <a href="{{ path('user_edit', {id: user.id}) }}" class="nav-link">編集</a>
    </li>
  {% endif %}

また、ユーザー削除画面へのリンクを新たに if 文で囲います。

+ {% if is_granted('DELETE', user) %}
    <div class="float-left">
      <a href="{{ path('user_delete', {id: user.id}) }}" class="btn btn-outline-danger">削除...</a>
    </div>
+ {% endif %}

これで、対応完了です!🙌