Laravel Policyで認可を正しく実装する

はじめに

APIエンドポイント /api/projects/{id} において、認証(Authentication)は通っているものの、認可(Authorization)の検証が不十分なケースがあります。

例えば、ユーザーAが自身のトークンを用いて、ユーザーBが所有する project_id を指定して GET / PUT / DELETE リクエストを送信した場合、サーバー側で所有権をチェックしていなければ、他人のデータを閲覧・改ざんできてしまいます。

これは典型的な IDOR (Insecure Direct Object Reference) 脆弱性です。

// 脆弱な例
public function update(Request $request, Project $project)
{
    // ログインしていることは確認されているが、
    // $project がログインユーザーのものかは見ていない
    $project->update($request->all());
}

Policyとは

Laravel Policyは、特定のモデルに対する認可ロジックをひとつのクラスに集約する仕組みです。

コントローラーやミドルウェアに if ($project->user_id !== auth()->id()) のようなチェックを散在させる代わりに、Policyクラスに認可ルールを定義し、コントローラーから呼び出します。

これにより以下のメリットが得られます。

  • 認可ロジックの一元管理 ─ 変更時にコントローラーを探し回る必要がありません
  • テストの容易さ ─ Policyクラス単体でユニットテストが書けます
  • 一貫性 ─ authorize メソッドやミドルウェアを通じて、どのエンドポイントでも同じルールが適用されます

Policyで解決する

1. Policyの生成

php artisan make:policy ProjectPolicy --model=Project

2. 認可ロジックの定義

app/Policies/ProjectPolicy.php に各アクションの認可ルールを記述します。

<?php

namespace App\Policies;

use App\Models\Project;
use App\Models\User;

class ProjectPolicy
{
    /**
     * プロジェクトの閲覧権限
     */
    public function view(User $user, Project $project): bool
    {
        return $user->id === $project->user_id;
    }

    /**
     * プロジェクトの更新権限
     */
    public function update(User $user, Project $project): bool
    {
        return $user->id === $project->user_id;
    }

    /**
     * プロジェクトの削除権限
     */
    public function delete(User $user, Project $project): bool
    {
        return $user->id === $project->user_id;
    }
}

ロジックはシンプルで、プロジェクトの user_id とログインユーザーの id が一致するかどうかだけを返します。

false が返れば、Laravelは自動的に 403 Forbidden レスポンスを返します。

3. Policyの登録

Laravel 8以降では、Policyの自動検出が有効です。
App\Models\Project に対して App\Policies\ProjectPolicy という命名規則に従っていれば、手動登録は不要です。

Laravel 10以前では AuthServiceProvider で明示的に登録することも可能です。
なお、Laravel 11以降では AuthServiceProvider がデフォルトのスケルトンから削除されているため、命名規則に従わない場合は AppServiceProviderboot メソッド内で Gate::policy() を使って登録してください。

// Laravel 10以前: AuthServiceProvider
protected $policies = [
    Project::class => ProjectPolicy::class,
];

// Laravel 11以降: AppServiceProvider(命名規則に従わない場合)
use Illuminate\Support\Facades\Gate;

public function boot(): void
{
    Gate::policy(Project::class, ProjectPolicy::class);
}

4. コントローラーでの適用

コントローラーの各メソッドで $this->authorize() を呼ぶだけで適用できます。

class ProjectController extends Controller
{
    public function show(Project $project)
    {
        $this->authorize('view', $project);

        return ProjectResource::make($project);
    }

    public function update(Request $request, Project $project)
    {
        $this->authorize('update', $project);

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

        return ProjectResource::make($project);
    }

    public function destroy(Project $project)
    {
        $this->authorize('delete', $project);

        $project->delete();

        return response()->noContent();
    }
}

これで、ユーザーAがユーザーBのプロジェクトIDを指定した場合、403 Forbidden が返却されます。

補足: authorizeResourceを使う方法

リソースコントローラーであれば、コンストラクタで一括適用もできます。

class ProjectController extends Controller
{
    public function __construct()
    {
        $this->authorizeResource(Project::class, 'project');
    }

    // 各メソッドからauthorize()の呼び出しを削除できます
}

authorizeResource は、コントローラーのメソッド名とPolicyのメソッド名を自動的にマッピングします(showviewupdateupdate など)。

まとめ

認証(Authentication)はリクエストの送信元が「誰か」を特定するだけであり、「そのリソースへのアクセス権があるか」を判定するのは認可(Authorization)の役割です。
この区別を見落とすと、IDORのような脆弱性が生まれます。

Laravel Policyを使えば、モデルごとの認可ルールを一箇所に集約でき、コントローラーでは $this->authorize() を一行追加するだけで済みます。
まだ導入していない場合は、既存のエンドポイントを一度洗い出して、Policyが適用されているか確認してみてください。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください