遅くなりすみません🙇♂️
Symfony Advent Calendar 2025の24日目の記事です!
目次
概要
Symfonyでは#[IsGranted]を使うことで、Controllerで簡単に権限チェックを行うことができます。
私自身もこれまでは、既存のVoterを「使う側」として利用してきました。
今回はある機能実装をきっかけにVoterを作成する機会があり、その過程で
- Voterが守ってくれる範囲
- UseCase側で考えるべき安全性
について、改めて考えるきっかけになりました。
この記事ではそのときに感じたことを整理してみたいと思います。
まずは簡単にVoterについてと作成方法をまとめます。
すでにご存知の方は「Voterは入口しか守らない」 まで飛ばしていただいても大丈夫です。
Voterとは?
SymfonyのVoterは、「ある操作をそのユーザーが実行してよいか」を判定するための仕組みです。
ControllerやテンプレートなどからisGranted()を呼ぶと対応するVoterが実行され、許可(true)か拒否(false)が返されます。
Voterは、
- 誰が
- どのリソースに対して
- どの操作をしてよいか
を判断してくれます。
今回はControllerのattributeを例にします。
#[IsGranted('EDIT', subject: 'resource')]
Voterの作成方法
#[IsGranted('EDIT', subject: 'resource')]と記述するとSymfonyは、
-
EDITというattribute resource(subject)
を元に該当するVoterを探して評価します。
VoterはSymfony\Component\Security\Core\Authorization\Voter\Voterを拡張して、主に2つのメソッドを実装します。
-
supports():このVoterが対象とするattributeとsubjectかどうかを判断する -
voteOnAttribute():実際に許可するかどうかの判定する
Controller側(呼び出し側)
更新用のControllerで、対象リソースを受け取りアノテーションでEDITを要求します。
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class Controller extends AbstractController
{
#[IsGranted('EDIT', subject: 'resource', statusCode: 403)]
#[Route(path: '/update/{id}', methods: ['PUT'])]
public function update(Resource $resource): Response
{
// ここに来た時点で「EDIT」が可能と判定されている
// メソッド内の更新処理はUseCaseに持たせる事とする
return new Response(status: Response::HTTP_NO_CONTENT);
}
}
更新処理に入る前にEDITの判定が走り、許可されない場合は403が返されます。
Voter側
VoterではResourceを対象に、「ログインユーザーがそのリソースを編集できるか」を判定します。
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ResourceVoter extends Voter
{
public const EDIT = 'EDIT';
protected function supports(mixed $attribute, mixed $subject): bool
{
return $attribute === self::EDIT && $subject instanceof Resource;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
/** @var Resource $resource */
$resource = $subject;
// 例:所有者のみ編集可
return $resource->getUser() === $user;
}
}
上記では、以下の処理をしています。
-
supports()で対象を絞る-
EDIT以外は処理しない -
Resource以外は処理しない
-
-
voteOnAttribute()でユーザーとリソースから判定する - ログインユーザーが取得できない場合は拒否する
- 所有者かどうかを判定する
これによってControllerは#[IsGranted('EDIT', subject: 'resource')]と書くだけで更新処理の前に権限の許可が走るようになります。
Voterは入口しか守らない
ここまでで、「更新は EDIT を通過した人だけが実行できる」という Controller(入口)での安全性 は実現できました。
ただ、今回の実装ではResourceの状態(status)を持っている仕様でした。
そのため、ControllerでEDITを通過してもUseCaseが常に安全とは限らないのでは? という疑問が生まれました。
EDITは通っているけど、業務的には更新してはいけない例
例えば、下記のようなUseCaseを考えます。
class UpdateResourceUseCase
{
// ControllerでEDITは通っている前提
public function handle(Resource $resource): void
{
$resource->updateSomething();
$this->repository->save($resource);
}
}
このUseCaseはシンプルですが、「Resourceのstatusがどの状態か」を確認せずに更新しています。
もし仕様として、「statusがfalseの場合は更新不可」というルールがあったとしても、Voterはすでに通過しているのでそのまま更新処理が実行されてしまいます。
なぜこの問題が起きるのか
ここで意識しておく必要があるのは、「Voterがどこで使われ、どこまでを守る仕組みなのか」という点です。
Voterの役割を整理すると、下記のようになります。
- Voterが評価されるのは、Controllerを通過するタイミング
- 入口での権限の確認を担当する
- 実際の業務処理やデータ更新の中身には関与しない
つまりVoterは、「このリクエストを通してよいか」を判断するための仕組みであり、
- この処理が業務的に正しいか
- どこから呼ばれても安全か
までは保証しません。
VoterとUseCaseの役割
今回の実装を通して、改めて下記のように整理できました。
Voterは、
- 誰がその操作をしてよいか
- Controllerの入口を守る
UseCaseは、
- 操作した結果が業務的に正しいか
- 状態や不変条件を保証する
まとめ
今回はVoterを新しく実装したことをきっかけに、
- Voterが守っている範囲
- UseCase側で考えるべき安全性
について整理してみました。
VoterはControllerで権限の確認を行うための仕組みですが、それだけで業務処理の安全性が保証されるわけではなく、「誰が操作できるか」(Voter)と「操作した結果が正しいか」(UseCase)の役割分担を意識することで破綻しにくい設計になると感じました。
Voterを「使う側」から「実装する側」になったことで、設計について改めて考える良いきっかけになりました。