はじめに
前回は「あなたは誰?」を判定する認証の裏側を見てきました。
今回は「あなたは何ができる?」を判定する認可について見ていきます!
前回記事:Laravelの認証・認可周りを整理してみたい (auth.phpとAuthファサード)
GateとPolicy
Laravelで用意されている認可処理は、主にゲート(Gate)とポリシー(Policy)の2つがあります。
| 名前 | 役割 |
|---|---|
| Gate | 1つの認可処理に名前を付けて、可否を判定する |
| Policy | 特定のモデルに関する複数の認可処理を1つのクラスにまとめる |
Gateというと、通行を許可するか拒否するかをその場で判断する感じです。
Policyというと「セキュリティポリシー」などで聞き馴染みがありますが、要するに「リソースごとのルールの集合」というイメージでしょうか。
なぜ認可をコントローラに直書きしないのか
そもそも、なぜGateやPolicyに切り出すのかというところですが、
こうした認可の判定をコントローラーの処理に直接書いてしまうと、権限チェックがあちこちに散らばって重複し、条件が増えるたびにクラスがどんどん肥大化していきます。
// Bad: 認可をコントローラーに直書きしている例
class PostController extends Controller
{
public function update(Request $request, Post $post)
{
$user = $request->user();
// 権限チェックをアクション内に直書きしている
if ($user->id !== $post->user_id && $user->role !== 'admin') {
abort(403, 'この投稿を編集する権限がありません');
}
$post->update($request->validated());
return redirect()->route('posts.show', $post);
}
public function destroy(Request $request, Post $post)
{
$user = $request->user();
// update と同じ判定をここでもまた書いている(重複)
if ($user->id !== $post->user_id && $user->role !== 'admin') {
abort(403, 'この投稿を削除する権限がありません');
}
// さらに「公開済みは編集者でないと消せない」など条件が増えていく
if ($post->is_published && $user->role !== 'editor' && $user->role !== 'admin') {
abort(403, '公開済みの投稿は削除できません');
}
$post->delete();
return redirect()->route('posts.index');
}
}
本来の「リクエストを受けてレスポンスを返す」処理が、認可ロジックに若干埋もれています。
これくらいだったらいいですが、規模が大きくなるとちょっと嫌になってきますね。
結果として保守性が落ちてしまうので、認可は専用の仕組みに分けて考えよう、ということですね。
この認可ロジックを、GateやPolicyに切り出してみます。
Gate に切り出す
// Good: Gate に切り出す(AppServiceProvider::boot() 内で定義)
use App\Models\Post;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
// AppServiceProvider クラス内
public function boot(): void
{
Gate::define('update-post', function (User $user, Post $post) {
// 所有者本人、または管理者なら許可する
return $user->id === $post->user_id || $user->role === 'admin';
});
}
これで update-post という名前で認可ルールが登録され、アプリのどこからでも Gate::allows('update-post', $post) のように呼び出せるようになります。
Policy に切り出す
// Good: Policy に切り出す(1つのクラスに複数の認可処理をまとめられる)
class PostPolicy
{
public function update(User $user, Post $post): bool
{
// 所有者本人、または管理者なら許可する
return $user->id === $post->user_id || $user->role === 'admin';
}
public function delete(User $user, Post $post): bool
{
// 公開済みの投稿は管理者だけが削除できる
if ($post->is_published) {
return $user->role === 'admin';
}
// 非公開(下書き)の投稿は本人か管理者なら削除できる
return $user->id === $post->user_id || $user->role === 'admin';
}
}
Policyでは、update や delete のように操作ごとにメソッドを分けて、関連する認可処理を1つのクラスにまとめられます。
さらに、それぞれのメソッドの中では、早期returnや条件分岐を使って細かいルールを表現できます。たとえば上の delete では「公開済みなら管理者だけ、非公開なら本人か管理者」というように、状況に応じた判定をそのまま書けます。
コントローラはどう変わるか
コントローラー側は、認可をGateやPolicyに任せることでぐっとスッキリします。
// Good: 認可は外に切り出し、コントローラーは本来の処理に専念する
class PostController extends Controller
{
public function update(Request $request, Post $post)
{
// Gate 'update-post' で認可をチェックする(ルールの中身は Gate 側)
$this->authorize('update-post', $post);
$post->update($request->validated());
return redirect()->route('posts.show', $post);
}
public function destroy(Request $request, Post $post)
{
// Policy の delete メソッドで認可をチェックする
$this->authorize('delete', $post);
$post->delete();
return redirect()->route('posts.index');
}
}
特定モデルに紐付かない横断的な判定なら「Gate」、モデルにまつわる操作が増えていく(まとめておきたい)なら「Policy」と使い分けると整理しやすそうです。
artisan で雛形を作る
Laravelのartisanコマンドに、ポリシークラスの雛形を作成できるコマンドがあります。
php artisan make:policy PostPolicy --model=Post
--model=Post オプションを付けると、viewAny / view / create / update / delete / restore / forceDelete の7メソッドが雛形として入った app/Policies/PostPolicy.php が生成されます。あとは必要なメソッドの中身を書いていくだけです。
ポリシーの登録については、App\Models\Post に対して App\Policies\PostPolicy という命名規則で配置すれば、Laravelが自動でポリシーを検出してくれます。明示的な登録は不要です(命名規則から外れる場合は Gate::policy(Post::class, PostPolicy::class) を AppServiceProvider::boot() で呼んで手動登録できます)。
事例:他人のリソースへのアクセスを 404 にする
いきなり少し毛色が違う話になりますが、実際にあった開発での事例をここで紹介します。
認可に失敗すると、Laravelはデフォルトで403(Forbidden)を返します。ただ、403は「そのリソースは存在するが、あなたには権限がない」ことを暗に伝えてしまうため、リソースの存在自体を隠したい場合は404を返したいことがあります。
実装方法はいくつかありますが、Policy 自体はシンプルな bool のままにして、「認可失敗時に投げられる例外を、アプリ全体で404に変換する」という書き方が便利です。
まず、コントローラ側では Gate::forUser(...)->authorize(...) で認可をチェックします。
class PostController extends Controller
{
public function show(Request $request, Post $post): Response
{
// 他人の投稿IDで存在確認されないよう、認可失敗時は404扱い
Gate::forUser($request->user())->authorize('view', $post);
return view('posts.show', compact('post'));
}
}
Gate::forUser($user)->authorize(...) は、認可に失敗すると例外を投げ、Laravel の標準処理で AccessDeniedHttpException(403)に変換されます。bootstrap/app.php でこの例外をさらに NotFoundHttpException(404)に書き換えると、アプリ全体で「認可失敗 = 404」に統一できます。
// bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
->withRouting(/* ... */)
->withMiddleware(/* ... */)
->withExceptions(function (Exceptions $exceptions) {
// 403(Forbidden)を404に変換し、リソースの存在を漏らさない
$exceptions->render(function (AccessDeniedHttpException $e) {
throw new NotFoundHttpException('Not Found');
});
})->create();
これで、Policy 側は普通に bool を返しているだけなのに、最終レスポンスは 403 ではなく 404 で返るようになります。
Policy の責務は「許可するかしないか」だけに保ったまま、「拒否されたら何を返すか」をアプリ全体の方針として外に切り出せるのがこのパターンの利点です。複数のコントローラに同じ方針を一気に適用できるので、リソース単位で書き分けるよりも漏れが出にくくなります。
なお、Policy のメソッド単位で振る舞いを変えたい場合は、戻り値型を bool から Response に変えて Response::denyAsNotFound() を返す方法もあります。「全体で統一したいときはグローバル変換、特定のメソッドだけ変えたいときは Response」と使い分けると整理しやすいです。
おわりに
Auth::user() の裏側にGuardやProviderがいたように、$user->can() や $this->authorize() の裏側にはGateやPolicyが動いています。設定の意味さえ押さえれば、認可周りの短いコードが、裏でどんな仕組みを使っているのかがイメージしやすくなりそうです。
認証と認可の整理は今回で一区切りとなりますが、何か気づいたことがあればまたまとめてみたいと思います。
最後まで読んでくださり、ありがとうございました!