🎻

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

2020/12/12に公開約4,000字

Symfony Advent Calendar 2020 の13日目の記事です!🎄🌙

昨日は @ippey_s さんの ローカル開発でSymfony CLIがすごい でした✨

ちなみに、僕はよく TwitterにもSymfonyネタを呟いている ので、よろしければぜひ フォローしてやってください🕊🤲

はじめに

Symfonyでコントローラのアクションにアクセス制限を設ける場合、よく使うのは @IsGranted アノテーションだと思います。

/**
 * @Route("foo/{id}/edit", name="foo_edit", methods={"GET", "POST"})
 * @IsGranted("ROLE_ALLOWED_TO_EDIT")
 */
public function edit(Request $request, Foo $foo)
{
    // ...
}

では、例えば ユーザー自身が所有するリソースなら編集可能だけど、他人のリソースは編集不可 という要件を実装する場合はどうすればいいでしょうか?

/**
 * @Route("foo/{id}/edit", name="foo_edit", methods={"GET", "POST"})
 */
public function edit(Request $request, Foo $foo)
{
    if (!$foo->user !== $this->getUser()) {
        throw new AccessDeniedHttpException();
    }

    // ...
}

こんな実装を想像した方もいるかもしれません。

別に間違った実装ではないのですが、実はこういうケースでは、Symfonyの Security Voter という機能を使うことでアクセス可否の判定をきれいにモジュール化できます👍

というわけでこのケースを例にSecurity Voterの具体的な使い方を解説してみたいと思います。

1. 実際に判定を行うVoterクラスを実装する

まず、実際にアクセス可否の判定を行うVoterクラスを実装します。

以下のように Symfony\Component\Security\Core\Authorization\Voter\Voter クラスを継承して実装することで、フレームワークが自動でVoterと認識してくれるので、 services.yaml に設定を追記したりする必要はありません👌

公式ドキュメントのサンプルは こちら

<?php
// src/Security/Voter/FooVoter.php

declare(strict_types=1);

namespace App\Security\Voter;

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

class FooVoter extends Voter
{
    const EDIT = 'EDIT';

    protected function supports($attribute, $subject)
    {
        // 定義済みの属性しか指定できないように
        if (!in_array($attribute, [self::EDIT])) {
            return false;
        }

        // この Voter は Foo インスタンスだけを対象とする
        if (!$subject instanceof Foo) {
            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:
                // $subject の中身は Foo インスタンスであることが supports() メソッドによって保証されている
                return $this->canEdit($subject, $user);
        }

        // この行まで到達することがあればcase文が漏れている
        throw new \LogicException();
    }

    // EDIT 属性についての判定処理
    private function canEdit(Foo $foo, User $user)
    {
        return $foo->user === $user;
    }
}

この例では、

  • Foo インスタンスのみを対象とするVoterを作り、
  • 'EDIT' という属性を定義し、
  • 'EDIT' 属性についてのアクセス可否のロジック(インスタンスの所有者かどうか)を実装

しています。

2. @IsGranted アノテーションや is_granted() Twig関数を使って判定

Voterを適切に実装すれば、あとは何もしなくても @IsGranted アノテーションや is_granted() Twig関数で普通に判定ができます。

/**
 * @Route("foo/{id}/edit", name="foo_edit", methods={"GET", "POST"})
 * @IsGranted("EDIT", subject="foo", statusCode=403)
 */
public function edit(Request $request, Foo $foo)
{
    // ...
}
{% if is_granted('EDIT', foo) %}
  <a href="{{ path('foo_edit', {id: foo.id}) }}">編集</a>
{% endif %}

便利!😳

なお、 @IsGrantedsubject="foo""foo" は、アクションメソッドの引数の変数名 $foo に一致させる必要があります。

また、 statusCode=403 は任意の引数です。明示的に指定しない場合のデフォルトの挙動では 302 でログイン画面にリダイレクトされます。

おわりに

Symfonyの便利な Security Voter の使い方について簡単に解説しました。お役に立てば幸いです!

Symfony Advent Calendar 2020、明日は @77web さんです!お楽しみに!

GitHubで編集を提案

Discussion

ログインするとコメントできます