【Laravel】Policy・Gateで認可(権限チェック)を実装する

laravelアイキャッチ Laravel

こんにちは、かつコーチです。

前回はSanctumを使ったAPI認証を紹介しました。

「誰なのか」を確認するのが認証だとすると、今回のテーマは「その人に何ができるのか」を確認する認可です。

Laravelでは、この認可のロジックをPolicyGateという2つの仕組みで整理できます。

「if文でユーザーIDを比較するだけ」の実装から一歩進んで、権限チェックをきちんと設計する方法を見ていきましょう。

認証と認可の違い

そもそも認可とは何か

認可(Authorization)とは、「ログイン済みのユーザーが、特定の操作を行う権限を持っているか」を判定する仕組みです。

似た言葉に認証(Authentication)がありますが、意味が異なります。

  • 認証:あなたは誰ですか?(ログインできるかどうか)
  • 認可:あなたはこの操作をしてよいですか?(権限があるかどうか)

たとえば、ログイン済みのユーザーが自分以外の投稿を削除しようとした場合、認証は通っていても認可はNGにする必要があります。

私が駆け出しの頃に作った掲示板アプリでは、この区別を意識せずに「ログインしていればなんでも編集できる」実装をしてしまい、後から「自分の投稿しか編集できないようにしてほしい」という当たり前の要件に気づいて全面的に作り直した経験があります。

最初から「認証」と「認可」を別物として設計しておくと、こういう手戻りを防げます。

なぜif文の羅列では限界があるのか

権限チェックをコントローラの中に直接書いていくと、最初はシンプルでも徐々に破綻していきます。

<?php
// コントローラに直接書かれた権限チェック(増えると管理が大変になる)

public function update(Request $request, Post $post)
{
    if ($post->user_id !== $request->user()->id) {
        abort(403);
    }

    // 更新処理
}

これ自体は動きますが、Postモデルに関する権限チェックが複数のコントローラメソッドに散らばっていくと、条件を変更したいときに修正漏れが起きやすくなります。

この「権限チェックのロジックを1箇所にまとめる」役割を担うのが、PolicyとGateです。

Gateの基本:シンプルな権限を定義する

Gateを使ったチェックの書き方

Gateは、特定のモデルに紐づかない、単純な権限判定を定義する仕組みです。

AppServiceProviderboot()メソッドで定義します。

<?php
// app/Providers/AppServiceProvider.php

namespace App\Providers;

use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Gate::define('access-admin-panel', function ($user) {
            return $user->role === 'admin';
        });
    }
}

定義したGateは、コントローラやBladeから呼び出せます。

<?php
// コントローラでの利用例

public function index(Request $request)
{
    if (Gate::denies('access-admin-panel')) {
        abort(403, '管理画面へのアクセス権限がありません。');
    }

    // 管理画面の処理
}

Bladeテンプレートでは、@canディレクティブで見た目を出し分けられます。

@can('access-admin-panel')
    <a href="/admin">管理画面へ</a>
@endcan

Gateは「モデルに紐づかない全体的な権限」を扱うのに向いています(管理画面へのアクセス可否など)。

Policyの基本:モデルごとの権限をまとめる

Policyクラスの生成

一方、「この投稿を編集できるか」のように特定のモデルに紐づく権限を扱う場合は、Policyを使うのが定石です。

Artisanコマンドで雛形を生成できます。

php artisan make:policy PostPolicy --model=Post

生成されたPostPolicyに、各操作ごとの権限ロジックを書いていきます。

<?php
// app/Policies/PostPolicy.php

namespace App\Policies;

use App\Models\Post;
use App\Models\User;

class PostPolicy
{
    /**
     * 投稿を更新できるか
     */
    public function update(User $user, Post $post): bool
    {
        return $user->id === $post->user_id;
    }

    /**
     * 投稿を削除できるか
     */
    public function delete(User $user, Post $post): bool
    {
        // 投稿者本人、または管理者なら削除可能
        return $user->id === $post->user_id || $user->role === 'admin';
    }
}

Policyの自動解決とコントローラでの利用

Laravelは、モデル名とPolicy名の命名規則(PostモデルにはPostPolicy)から、自動的に対応するPolicyを見つけてくれます。

コントローラではauthorize()メソッドを使ってチェックします。

<?php
// app/Http/Controllers/PostController.php

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Http\Request;

class PostController extends Controller
{
    public function update(Request $request, Post $post)
    {
        $this->authorize('update', $post);

        $post->update($request->validated());

        return redirect()->route('posts.show', $post);
    }
}

authorize()が権限NGと判定すると、自動的に403エラー(AuthorizationException)が投げられます。

コントローラ側は「どんな条件で権限をチェックするか」を意識せず、「update権限を確認する」という意図だけを書けばよくなります。

つまずきやすいポイント:Policyを登録し忘れる

自動検出されないケースへの対処

Laravel 8以降は多くの場合Policyが自動検出されますが、モデルとPolicyの命名規則がずれていたり、モデルが標準のapp/Models配下にない場合、自動検出が効かず権限チェックがすり抜けてしまうことがあります。

私は一度、Postモデルをapp/Models/Blog/Post.phpのようにサブディレクトリへ移動した際、Policyが自動検出されなくなり、「更新できてはいけないユーザーが更新できてしまう」という重大なバグを本番直前のレビューで発見したことがあります。

❌ Before:Policyが自動検出されず、権限チェックがすり抜ける

<?php
// app/Models/Blog/Post.php にモデルを移動したのに
// Policyの場所や登録方法がずれていて紐付けが認識されない

// AppServiceProviderで何も明示していないため、
// $this->authorize('update', $post) が常にtrueを返してしまう

✅ After:AuthServiceProvider(またはAppServiceProvider)で明示的にマッピングする

<?php
// app/Providers/AppServiceProvider.php

namespace App\Providers;

use App\Models\Blog\Post;
use App\Policies\PostPolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // モデルとPolicyの対応関係を明示的に登録する
        Gate::policy(Post::class, PostPolicy::class);
    }
}

「動いているように見える」ことと「意図通りに権限がチェックされている」ことは別物です。

Policyを新規作成したときは、必ず「権限がない場合に本当に403が返るか」をテストコードやHTTPクライアントで確認する習慣をつけておきましょう。

応用:フォームリクエストと組み合わせる

authorize()メソッドを使った権限チェックの一元化

コントローラに毎回$this->authorize()を書く代わりに、フォームリクエストauthorize()メソッドに権限チェックをまとめる方法もあります。

<?php
// app/Http/Requests/UpdatePostRequest.php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class UpdatePostRequest extends FormRequest
{
    public function authorize(): bool
    {
        // route()でルートパラメータからPostモデルを取得
        return $this->user()->can('update', $this->route('post'));
    }

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'body' => ['required', 'string'],
        ];
    }
}

こうしておくと、コントローラ側はバリデーション済みリクエストを受け取るだけで、権限チェックとバリデーションの両方が保証された状態になります。

<?php
public function update(UpdatePostRequest $request, Post $post)
{
    $post->update($request->validated());

    return redirect()->route('posts.show', $post);
}

「権限チェック忘れ」を構造的に防ぎたい場合、このパターンは特に有効です。

まとめ

この記事のポイント

  • 認証は「誰か」、認可は「何ができるか」を確認する別の概念
  • モデルに紐づかない権限はGate、モデルに紐づく権限はPolicyで整理する
  • コントローラではauthorize()can()で権限チェックの意図を明確に書く
  • Policyの自動検出が効かないケースではGate::policy()で明示的に登録する
  • フォームリクエストのauthorize()に権限チェックを寄せると、チェック漏れを防げる

次に読むべき記事

権限まわりが整理できたら、次はWebアプリのセキュリティの基本であるCSRF対策を見ていきましょう。

→ 次の記事:CSRF対策の仕組みとLaravelでの実装

タイトルとURLをコピーしました