メインコンテンツまでスキップ

認可 — Gate と Policy で「誰が何をできるか」を決める

前章でログインできるようになりました。しかし、ログインさえすれば何でもできる状態です。

この章で「誰が何に対して何をしてよいか」を決めます。同時に、注文の一覧と詳細を作ります。この 2 つのエンドポイントには、認可の落とし穴がそれぞれ違う形で現れます。

認証を通っただけでは足りない

注文詳細のエンドポイントを素直に書きます。

app/Http/Controllers/OrderController.php
public function show(Order $order)
{
return new OrderResource($order->load('items'));
}
routes/api.php
Route::middleware('auth:sanctum')->group(function () {
Route::post('/auth/logout', [AuthController::class, 'logout']);
Route::post('/orders', [OrderController::class, 'store']);
Route::get('/orders', [OrderController::class, 'index']); // ← この章で追加
Route::get('/orders/{order}', [OrderController::class, 'show']); // ← この章で追加
});

auth:sanctum があるので、トークンが無ければ 401 で弾かれます。ここまでは前章のとおりです。

問題は、トークンさえあれば誰の注文でも取得できることです。

GET /api/v1/orders/1 ← 自分の注文
GET /api/v1/orders/2 ← 他人の注文。これも 200 で返る
GET /api/v1/orders/3 ← 同上

第4章で書いたとおり、id が連番なら数字を変えるだけで全件を順に取得できます。会員登録さえすれば、他の会員の注文内容とメールアドレスが読めます。

認証は「あなたが誰か」を確かめただけです。「あなたがこれを見てよいか」は別に判断が要ります。

Policy を作る

Laravel は、モデルに対する権限判断を Policy というクラスにまとめます。

php artisan make:policy OrderPolicy --model=Order

--model=Order を付けると、代表的なメソッドの雛形が生成されます1。この連載で使うものだけ残します。

app/Policies/OrderPolicy.php
<?php

namespace App\Policies;

use App\Models\Order;
use App\Models\User;

class OrderPolicy
{
/**
* 注文を閲覧できるか
*/
public function view(User $user, Order $order): bool
{
return $user->id === $order->user_id;
}

/**
* 注文をキャンセルできるか
*/
public function cancel(User $user, Order $order): bool
{
return $user->id === $order->user_id;
}
}

メソッドは第 1 引数にユーザー、第 2 引数に対象のモデルを取り、bool を返します1

「発送済みならキャンセル不可」を Policy に書かない

cancel&& $order->status === OrderStatus::Pending を足したくなります。書けば動きますが、返るコードが 403 になります

403 は「あなたにはこの操作をする権限がない」という意味です。実際に起きているのは「この注文はすでに発送済みなので変更できない」で、権限とは無関係です。第5章の練習問題で、Form Request の authorize() に同じことを書くと原因を取り違えさせると述べました。Policy でも事情は同じです。

判断はステータスコードで分けます。

  • 403 が適切 (権限の問題) → Policy
  • 409 が適切 (状態の問題) → サービスクラスで例外を投げ、第9章の変換で 409 にする

Policy は「所有者かどうか」だけを見ます。状態の判定は OrderCancellationService のような処理側に置きます。第3章で Order::isCancellable() を書いたのは、その判定をモデルが自分のデータだけで答えられるようにするためです。

Policy の登録

Laravel は命名規約から Policy を見つけます。app/Policies/OrderPolicy.phpApp\Models\Order に対応すると解決されます。

規約から外れる場所に置く場合は、明示的に登録します1

app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Gate;

public function boot(): void
{
Gate::policy(Order::class, OrderPolicy::class);
}

コントローラから呼ぶ

Gate::authorize() を使います1

app/Http/Controllers/OrderController.php
use Illuminate\Support\Facades\Gate;

public function show(Order $order)
{
Gate::authorize('view', $order);

return new OrderResource($order->load('items'));
}

第 1 引数が Policy のメソッド名、第 2 引数が対象のモデルです。false が返れば AuthorizationException が投げられ、403 になります。

第9章で作ったエラー変換に乗るので、レスポンスは JSON で返ります。

これで他人の注文は取得できなくなりました。

一覧では Policy が効かない

次に一覧を作ります。ここがこの章で最も重要な箇所です。

❌ Bad: Policy を書いたから大丈夫、ではない
public function index()
{
$orders = Order::paginate(20);

return OrderResource::collection($orders);
}

OrderPolicy を作りました。show では機能しています。しかしこの一覧は全会員の注文を返します

理由は単純です。Policy は「このユーザーがこのモデルに対して」を判断する仕組みなので、判断の対象になるモデルが必要です。一覧を取得する時点では、まだどのモデルを扱うか決まっていません。Gate::authorize('viewAny', Order::class) のような書き方はできますが、それは「一覧を見る権限があるか」であって、どの行を返すかは別の話です。

絞り込みは、クエリで行います。

✅ Good: クエリの時点で自分のものに限定する
public function index(Request $request)
{
$orders = Order::where('user_id', $request->user()->id)
->latest()
->paginate(20);

return OrderResource::collection($orders);
}

認可のロジックが 2 か所に分かれます。 詳細は Policy、一覧はクエリの where。これは Laravel の制約というより、認可という問題の性質です。

危険なのは、この非対称性に気づかないことです。「Policy を書いたから認可は済んだ」と考えると、一覧だけが素通りします。しかも一覧のほうが被害が大きいです。詳細は 1 件ずつしか漏れませんが、一覧は全件が一度に漏れます。

リレーション経由でも同じ

✅ リレーションから引くと自然に絞られる
public function index(Request $request)
{
$orders = $request->user()
->orders()
->latest()
->paginate(20);

return OrderResource::collection($orders);
}

User モデルに orders() リレーションを定義しておけば、この書き方ができます。「自分のもの」という制約が構造として表れるので、where の書き忘れが起きません。

app/Models/User.php
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}

一覧を作るときは、この形を第一候補にしてください。

Gate を使う場面

Policy はモデルに紐づく判断でした。モデルを伴わない判断には Gate を使います1

app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Gate;

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

この例は users テーブルに role カラムがある前提です。この連載では管理機能を作らないので、カラムも追加しません。 Gate の形を示すための例として読んでください。

Gate::authorize('access-admin-panel');

「管理画面に入れるか」「レポートを出力できるか」のような、特定のデータに対してではない権限が対象です。

使い分けの目安です。

判断使うもの
この注文を見てよいかPolicy
この商品を編集してよいかPolicy
管理機能を使ってよいかGate
統計 API を叩いてよいかGate

対象のモデルが引数に現れるなら Policy、現れないなら Gate です。

403 と 404 の使い分け

他人の注文にアクセスされたとき、403 と 404 のどちらを返すべきでしょうか。

403 は「存在するが権限がない」を伝えます。 攻撃する側から見ると、これは情報です。GET /api/v1/orders/500 が 403 なら注文 500 は存在し、404 なら存在しません。順に試せば注文の総数が分かります

第4章で触れた ID 列挙のリスクが、ここで具体化します。

判断の基準は、そのリソースの存在自体が秘密かどうかです。

状況返すもの理由
他人の注文404存在を知られたくない
自分の注文だが状態が不正 (発送済みのキャンセル)409存在は自分が知っている
一般会員が管理機能を叩いた403管理機能の存在は隠す必要がない

他人の注文で 404 を返すには、Policy でなくクエリ側で絞ります。

app/Http/Controllers/OrderController.php
public function show(Request $request, Order $order)
{
if ($order->user_id !== $request->user()->id) {
abort(404);
}

return new OrderResource($order->load('items'));
}

あるいは、ルートモデルバインディングの前に絞る方法もあります。

public function show(Request $request, int $orderId)
{
$order = $request->user()->orders()->findOrFail($orderId);

return new OrderResource($order->load('items'));
}

findOrFail は見つからなければ 404 を投げます。**自分の注文の中から探すので、他人の注文は「存在しない」**という扱いになります。この書き方なら、条件を書き忘れる余地がありません。

この連載での選択と、Policy の行き先

注文の詳細は 404 とします。存在を隠す価値が高いためです。showfindOrFail の形にするので、OrderPolicy::view はこのエンドポイントからは呼ばれなくなります

では Policy は不要かというと、そうではありません。403 が適切な場面では Policy が本来の使い方になります。この連載では注文のキャンセルがその例で、OrderPolicy::cancel が「自分の注文か」を判断します。所有していない注文へのキャンセル要求は 403 です。存在を隠す必要は、この時点ではありません — 相手は既に注文 ID を知っており、詳細を取得しようとすれば 404 で弾かれるためです。

すべてを 404 にするのは行き過ぎです。 管理機能で 404 を返すと、利用者は「URL が間違っている」と考えて問い合わせてきます。403 なら「権限が足りない」と伝わり、管理者に依頼するという次の行動につながります。

テスト

第10章で予告したとおり、認可のテストは必ず書きます

正常系のテストは、認可が壊れていても通ります。誰でもアクセスできる状態なら、自分のデータにもアクセスできるからです。

tests/Feature/OrderAuthorizationTest.php
use App\Models\Order;
use App\Models\User;
use Laravel\Sanctum\Sanctum;

test('自分の注文は取得できる', function () {
$user = User::factory()->create();
$order = Order::factory()->create(['user_id' => $user->id]);

Sanctum::actingAs($user);

$this->getJson("/api/v1/orders/{$order->id}")->assertStatus(200);
});

test('他人の注文は404になる', function () {
$owner = User::factory()->create();
$other = User::factory()->create();
$order = Order::factory()->create(['user_id' => $owner->id]);

Sanctum::actingAs($other);

$this->getJson("/api/v1/orders/{$order->id}")->assertStatus(404);
});

test('注文一覧には自分の注文だけが返る', function () {
$user = User::factory()->create();
$other = User::factory()->create();

Order::factory()->count(3)->create(['user_id' => $user->id]);
Order::factory()->count(5)->create(['user_id' => $other->id]);

Sanctum::actingAs($user);

$this->getJson('/api/v1/orders')
->assertStatus(200)
->assertJsonCount(3, 'data');
});

3 つ目が一覧の穴を検出します。 件数を確認しているので、全件が返ったら 8 件になって落ちます。

この連載では、認可が要るエンドポイントごとに「他人のものにアクセスできない」テストを 1 つ置くことにします。

本番で効く注意点

一覧の認可漏れは全件が漏れる

繰り返しますが、この章で最も注意すべき点です。

Policy を書いた安心感で一覧を素通りさせると、そのエンドポイントは全会員のデータを返す API になります。しかもレスポンスは正常な 200 で返り、エラーログにも残りません。気づくのは、利用者から「他人の注文が見える」と報告されたときです。

新しい一覧エンドポイントを作るたびに、「これは誰のデータを返すのか」を確認してください。

Gate::before を安易に使わない

管理者をすべての認可で素通りさせる書き方があります。

Gate::before(function (User $user) {
return $user->role === 'admin' ? true : null;
});

便利ですが、Policy に書いた条件がすべて無視されます。「発送済みの注文はキャンセルできない」という状態の判定も、管理者には効きません。管理者が誤って発送済みの注文をキャンセルできてしまいます。

権限を通すことと、業務上の制約を外すことは別です。Gate::before を使うなら、状態の判定は Policy でなくサービスクラス側に置いてください。

認可を Resource で代用しない

❌ Bad: 見せない項目を減らして済ませる
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'total_amount' => $this->when($request->user()->id === $this->user_id, $this->total_amount),
];
}

第7章の when() は表示の出し分けであって、認可ではありません。この書き方だと、他人の注文にアクセスできること自体は防げていませんidstatus は返りますし、そもそもリクエストが 200 で通ります。

アクセスしてよいかは Policy かクエリで判断し、Resource は「アクセスが許された人に何を見せるか」だけを扱います。

認可の判断をフロントエンドに任せない

「管理者向けのボタンを画面に出さない」は、認可ではありません。API を直接叩かれれば無関係です。

サーバー側で必ず判断してください。 画面での出し分けは、利用者の体験を良くするためのものです。

まとめ

  • 認証を通っただけでは「誰か」しか分からない。「これをしてよいか」は別に判断が要る
  • Policy はモデルに紐づく判断、Gate はモデルを伴わない判断
  • 一覧では Policy が効かない。クエリの where かリレーション経由で絞る。リレーション経由なら書き忘れの余地がない
  • 一覧の認可漏れは全件が一度に漏れる。しかも 200 で返るのでログに残らない
  • 存在を隠したいリソースは 403 でなく 404。ただし管理機能まで 404 にすると利用者が混乱する
  • 認可が要るエンドポイントごとに「他人のものにアクセスできない」テストを 1 つ置く
  • Gate::before は Policy の条件をすべて無視する。状態の判定と権限の判定を混ぜない

次に読む

次章 ミドルウェアとレート制限 では、リクエストが通る道の入口に処理を挟みます。全リクエスト共通の処理をミドルウェアにまとめ、決済サービスからの Webhook を受け取る署名検証を実装します。前章で作ったログイン API を総当たりから守るレート制限も、そこで入れます。

練習問題

次の実装は詳細では正しく動きますが、別のエンドポイントに穴が残ります。どこですか
class OrderPolicy
{
public function view(User $user, Order $order): bool
{
return $user->id === $order->user_id;
}
}

class OrderController extends Controller
{
public function show(Order $order)
{
Gate::authorize('view', $order);

return new OrderResource($order);
}

public function index()
{
return OrderResource::collection(Order::latest()->paginate(20));
}
}

解答例

index に認可がありません。 Order::latest() は全会員の注文を新しい順に返します。

OrderPolicy は書かれていますし、show では正しく機能しています。だからこそ見落としやすい形です。「Policy を作った」という事実が、認可が済んだ感覚を与えます。

しかし Policy はモデルのインスタンスに対する判断です。一覧は判断の対象になるインスタンスを持ちません。ページネーションで取得した 20 件それぞれに view を通すこともできますが、そうするとページの中身が人によって歯抜けになります。20 件取得して自分のものが 3 件なら、3 件しか表示されません。件数もページ数も意味を持たなくなります。

正しいのは、取得する時点で絞ることです。

public function index(Request $request)
{
return OrderResource::collection(
$request->user()->orders()->latest()->paginate(20)
);
}

被害の大きさも違います。show の穴は 1 リクエストで 1 件です。index の穴は 1 リクエストで 20 件、ページを繰れば全件です。一覧のほうが優先して確認すべきという結論になります。

「他人のリソースには 404 を返す」という方針を、すべてのエンドポイントに適用すべきでしょうか

解答例

適用すべきではありません。 判断は「そのリソースの存在自体が秘密か」で決まります。

404 が適切な場面は、存在を知られること自体が情報になる場合です。

  • 他人の注文 — 注文 ID が連番なら、403 の並びから総注文数が推測できる
  • 他人の下書き記事 — 「その ID に何かがある」だけで、書いていることが伝わる場合がある
  • 招待制のリソース — 存在を知られると、招待を受けていない人が問い合わせを始める

403 が適切な場面は、存在が公知で、権限が足りないだけの場合です。

  • 管理画面 — 存在は誰でも知っている。隠す意味がない
  • 有料プラン限定の機能 — むしろ「契約すれば使える」と伝えたい
  • 承認待ちの機能 — 「上長に依頼してください」という次の行動につながる

404 を返す代償は、利用者が原因を特定できなくなることです。管理機能で 404 が返れば「URL が間違っている」と考えて調べ始めます。実際は権限の問題なので、いくら調べても分かりません。サポートへの問い合わせも「ページが見つかりません」という報告になり、権限の話だと気づくまで時間がかかります。

判断の順序としては、まず 403 を既定にして、存在を隠す理由があるものだけ 404 に落とすのが扱いやすいです。全部を 404 にすると、隠す必要のないものまで分かりにくくなります。


Footnotes

  1. 出典: Authorization(Laravel 公式ドキュメント 13.x)。make:policy --model= によるポリシー生成、ポリシーメソッドの引数と戻り値、Gate::policy による手動登録、Gate::define によるゲートの定義、コントローラからの Gate::authorize について。 2 3 4 5