社内向けの管理画面では、「営業担当は顧客を登録・編集できるが削除はできない」「経理の閲覧担当は見るだけ」のように、人によってできる操作を分けるのが普通です。今回は、Laravel 12 の Policy(=「誰がどのデータに何をしてよいか」を書くクラス)を使って、ロール(役割)ごとの権限を実装します。
「閲覧だけの人には、登録ボタンや削除ボタンを見せなければ十分では?サーバー側で何をすればいいのか、どこまで作れば安全なのかが分からない…」
結論から言うと、ボタンを隠すのは「使いやすさ」のための補助で、権限チェックの本体はサーバー側の認可(Policy)です。ボタンが無くても、URL を直接たたけばリクエストは届きます。Laravel では Policy に「誰が何をできるか」を1か所にまとめ、コントローラー・FormRequest・画面の表示制御のすべてがその判定を使うようにします。
前回(第2回:顧客マスタの一覧と登録)では、顧客マスタの一覧・登録・編集・削除と入力チェックを作りました。今回は、そこに「誰が操作してよいか」を加えます。
ロールと Policy で権限を分ける仕組み
今回決める権限表
最初に、ロールごとにできることを表にします。コードはこの表をそのまま写す形で書きます。
| 操作 | 管理者(admin) | 担当者(staff) | 閲覧のみ(viewer) |
|---|---|---|---|
| 顧客一覧を見る | できる | できる | できる |
| 顧客を登録する | できる | できる | できない(403) |
| 顧客を編集する | できる | できる | できない(403) |
| 顧客を削除する | できる | できない(403) | できない(403) |
403 は「ログインはしているが、その操作の権限が無い」ことを表すHTTPのステータスコードです。
認可の流れ
- ロールはユーザーごとに1つ:users テーブルに role 列を追加し、PHP の enum(=決まった値しか取れない型)で扱います。
- 判定は Policy に集約:
CustomerPolicyに「登録できるのは担当者」のようなルールを書きます。管理者はGate::beforeで一括して許可します。 - サーバー側で必ず判定:コントローラーでは
Gate::authorize()、FormRequest ではauthorize()から Policy を呼び、許可されなければ 403 を返します。 - 画面には判定結果だけを渡す:Vue 側では、サーバーが計算した「登録できるか」「この行を編集できるか」の true / false を見てボタンを出し分けます。画面側でロール名を見て判断するコードは書きません。
📰 出典:Laravel 12.x Authorization
ロールを enum で定義し、users テーブルに追加する
app/Enums/Role.php
app/Enums/Role.php
<?php
declare(strict_types=1);
namespace App\Enums;
/**
* ユーザーのロール。DB には value(admin / staff / viewer)を文字列で保存する。
*/
enum Role: string
{
case Admin = 'admin'; // 管理者:すべての操作
case Staff = 'staff'; // 担当者:登録・編集まで(削除は不可)
case Viewer = 'viewer'; // 閲覧のみ
public function label(): string
{
return match ($this) {
self::Admin => '管理者',
self::Staff => '担当者',
self::Viewer => '閲覧のみ',
};
}
}
ロールを 'admin' のような文字列のまま扱うと、'Admin' や 'adimn' のような書き間違いがあっても気づけません。enum にしておくと、存在しないロールを書いた時点でエラーになります。
マイグレーション:既定は「閲覧のみ」
database/migrations/2025_06_10_000000_add_role_to_users_table.php(up 部分)
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
// 既定は最も弱い権限(閲覧のみ)。権限の付与は管理者が明示的に行う
$table->string('role', 20)->default('viewer')->after('email');
});
}
既定値は、最も権限の弱い viewer にしました。新しく作られたユーザーが、うっかり編集や削除をできる状態で始まらないようにするためです(最小権限の原則)。
User モデル:enum にキャストし、$fillable には入れない
app/Models/User.php(変更部分)
/**
* The attributes that are mass assignable.
* role は含めない(プロフィール更新などの画面から自分の権限を書き換えられないようにする)
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* 新規作成時の既定値(DB の既定値と合わせる。保存直後のインスタンスでも role を参照できるように)
*
* @var array<string, string>
*/
protected $attributes = [
'role' => 'viewer',
];
// $hidden と casts() のコメントはスターターキットのまま(省略)
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'role' => Role::class,
];
}
casts() に Role::class を書くと、$user->role が文字列ではなく Role::Staff のような enum の値として取り出せます。
📰 出典:Laravel 12.x Eloquent: Mutators & Casting(Enum Casting)
大事なのは、role を $fillable に入れないことです。スターターキットのプロフィール更新画面は fill($request->validated()) で保存しますが、もし $fillable に role があり、FormRequest のルールにも誤って role を加えてしまうと、利用者が自分で自分を管理者にできる穴になります。権限を変える処理は、管理者だけが使う専用の処理として別に作るのが安全です。
CustomerPolicy と Gate::before で権限を判定する
app/Policies/CustomerPolicy.php
app/Policies/CustomerPolicy.php
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Enums\Role;
use App\Models\Customer;
use App\Models\User;
/**
* 顧客マスタの認可ルール。
* 管理者(admin)は AppServiceProvider の Gate::before ですべて許可しているため、ここには出てこない。
*/
class CustomerPolicy
{
/** 一覧を見る:ログインしていれば全ロール可 */
public function viewAny(User $user): bool
{
return true;
}
/** 登録する:担当者のみ(閲覧のみは不可) */
public function create(User $user): bool
{
return $user->role === Role::Staff;
}
/** 編集する:担当者のみ */
public function update(User $user, Customer $customer): bool
{
return $user->role === Role::Staff;
}
/** 削除する:管理者のみ(担当者・閲覧のみは不可) */
public function delete(User $user, Customer $customer): bool
{
return false;
}
}
Laravel は、App\Models\Customer に対して App\Policies\CustomerPolicy という名前のクラスがあれば、自動的に対応づけます(登録のコードは不要です)。update と delete が Customer $customer を受け取っているのは、将来「自分が担当する顧客だけ編集できる」のようなルールに広げられるようにするためです。
Gate::before:管理者はすべて許可する
app/Providers/AppServiceProvider.php(boot メソッド)
public function boot(): void
{
// 管理者はすべての操作を許可する。true 以外(null)を返すと、通常どおり Policy の判定に進む
Gate::before(function (User $user, string $ability): ?bool {
return $user->role === Role::Admin ? true : null;
});
}
Gate::before は、すべての権限チェックの前に呼ばれる処理です。true を返すとその時点で許可、null を返すと通常の Policy の判定に進みます。ここで false を返すと、Policy に関係なくすべて拒否になってしまうので注意してください。
📰 出典:Laravel 12.x Authorization(Intercepting Gate Checks)
コントローラーと FormRequest で認可をかける
FormRequest の authorize()
前回「第3回で Policy に置き換える」としていた authorize() を、Policy を呼ぶ形に変えます。false を返すと、入力チェックより前に 403 になります。
app/Http/Requests/StoreCustomerRequest.php(authorize 部分)
/**
* 誰がこの操作をしてよいか。CustomerPolicy::create() で判定し、false なら 403 を返す。
*/
public function authorize(): bool
{
return $this->user()?->can('create', Customer::class) ?? false;
}
app/Http/Requests/UpdateCustomerRequest.php(authorize 部分)
public function authorize(): bool
{
/** @var Customer $customer */
$customer = $this->route('customer');
return $this->user()?->can('update', $customer) ?? false;
}
コントローラーの Gate::authorize()
FormRequest を使わないメソッド(一覧・登録画面の表示・編集画面の表示・削除)では、Gate::authorize() で判定します。許可されなければ、その場で 403 の例外が発生して処理が止まります。
app/Http/Controllers/CustomerController.php(抜粋)
public function index(Request $request): Response
{
Gate::authorize('viewAny', Customer::class);
$user = $request->user();
$customers = Customer::query()
->select(['id', 'code', 'name', 'email', 'phone', 'updated_at'])
->orderByDesc('id')
->paginate(20)
// 行ごとに「編集・削除できるか」を添えて渡す(ボタンの表示制御用。判定の本体はサーバー側)
->through(fn (Customer $customer): array => [
...$customer->toArray(),
'can' => [
'update' => $user->can('update', $customer),
'delete' => $user->can('delete', $customer),
],
]);
return Inertia::render('customers/Index', [
'customers' => $customers,
]);
}
public function edit(Customer $customer): Response
{
Gate::authorize('update', $customer);
return Inertia::render('customers/Edit', [
'customer' => $customer->only(['id', 'code', 'name', 'name_kana', 'email', 'phone', 'note']),
]);
}
public function destroy(Customer $customer): RedirectResponse
{
Gate::authorize('delete', $customer);
$customer->delete();
return to_route('customers.index')->with('success', '顧客を削除しました。');
}
create() にも Gate::authorize('create', Customer::class) を入れています。Laravel 12 の新しいプロジェクトでは、基底の Controller クラスに $this->authorize() 用の仕組みが入っていないため、Gate ファサードを使っています。
一覧では、through() で各行に can.update と can.delete を付けて画面に渡しています。行ごとに Policy を呼んでいるので、将来ルールが「担当顧客だけ」に変わっても、画面側のコードは変えずに済みます。
権限を Vue に渡してボタンを出し分ける
共有プロップに「登録できるか」を追加
「新規登録」ボタンは特定の行に属さないので、すべての画面に渡す共有プロップ(第2回で完了メッセージを追加した場所)に入れます。
app/Http/Middleware/HandleInertiaRequests.php(share の auth 部分)
'auth' => [
'user' => $request->user(),
// 画面のボタン表示に使う権限(Policy の判定結果をそのまま渡す)
'can' => [
'createCustomer' => $request->user()?->can('create', Customer::class) ?? false,
],
],
一覧画面でボタンを出し分ける
resources/js/pages/customers/Index.vue(テンプレートの該当部分)
<Button v-if="page.props.auth.can.createCustomer" as-child>
<Link :href="route('customers.create')">新規登録</Link>
</Button>
<!-- 各行 -->
<td class="space-x-2 py-2 text-right">
<Button v-if="customer.can.update" variant="outline" size="sm" as-child>
<Link :href="route('customers.edit', customer.id)">編集</Link>
</Button>
<Button v-if="customer.can.delete" variant="destructive" size="sm" @click="destroy(customer)">削除</Button>
</td>
TypeScript の型(resources/js/types/index.d.ts)には、Auth に can: { createCustomer: boolean }、User に role、一覧の行に can: { update: boolean; delete: boolean } を持つ CustomerRow を追加しました。
Feature テストで 403 を確かめる
権限のテストで大切なのは、「画面にボタンが無いこと」ではなく「URL を直接たたいても拒否されること」を確かめることです。
tests/Feature/CustomerPolicyTest.php(抜粋)
public function test_viewer_cannot_create_update_or_delete(): void
{
$viewer = User::factory()->viewer()->create();
$customer = Customer::factory()->create();
// 画面にボタンが無くても、URL を直接たたけば届く。サーバー側で 403 を返すことを確かめる
$this->actingAs($viewer)->get('/customers/create')->assertForbidden();
$this->actingAs($viewer)->post('/customers', self::VALID_INPUT)->assertForbidden();
$this->actingAs($viewer)->get("/customers/{$customer->id}/edit")->assertForbidden();
$this->actingAs($viewer)->put("/customers/{$customer->id}", self::VALID_INPUT)->assertForbidden();
$this->actingAs($viewer)->delete("/customers/{$customer->id}")->assertForbidden();
$this->assertDatabaseCount('customers', 1);
$this->assertDatabaseMissing('customers', ['code' => 'C00001']);
}
public function test_role_cannot_be_changed_from_the_profile_form(): void
{
$viewer = User::factory()->viewer()->create();
// プロフィール更新に role を混ぜて送っても、$fillable に無いので保存されない
$this->actingAs($viewer)->patch('/settings/profile', [
'name' => $viewer->name,
'email' => $viewer->email,
'role' => 'admin',
]);
$this->assertSame(Role::Viewer, $viewer->fresh()->role);
}
ほかに、閲覧のみのユーザーでは一覧の auth.can.createCustomer と各行の can がすべて false になること、担当者は登録・編集できるが削除は 403 になること、管理者はすべてできること、会員登録したユーザーの既定が閲覧のみであることの、合計6件を書きました。テスト用のユーザーを作る UserFactory には、admin()・viewer() の状態(state)を追加し、既定は担当者にしています。
動作確認の方法
docker compose run --rm --no-deps app php artisan test --filter=CustomerPolicyTest
docker compose run --rm --no-deps app php artisan test
docker compose up -d mysql app
docker compose exec app php artisan migrate --seed
シーダーで、管理者(test@example.com)・担当者(staff@example.com)・閲覧のみ(viewer@example.com)の3人を作ります。パスワードはいずれもローカル専用の password です。
ロールを変えて挙動を確かめるときは、tinker(=Laravel の対話型の実行環境)を使います。
docker compose exec app php artisan tinker --execute="App\Models\User::where('email','viewer@example.com')->update(['role'=>'staff']);"
筆者の環境では、次のことを確認しました。
CustomerPolicyTestの6件を含め、全42件のテストが成功(npm run buildも成功)- 閲覧のみでログインすると、一覧に「新規登録」「編集」「削除」のボタンが出ず、
POST /customersと編集画面は 403 - tinker で担当者に変えると「新規登録」「編集」が表示されて登録でき、削除は 403 のまま
- 管理者でログインすると削除ボタンが表示され、削除できる
つまずきやすい点・セキュリティ上の注意
- ボタンを隠しただけで安心しない:画面の表示制御は、開発者ツールや直接のリクエストで簡単に回避できます。403 のテストを必ず書きましょう。
- Gate::before で false を返さない:管理者以外に false を返すと、Policy の判定がすべて無視されて全員が拒否されます。「判定しない」は null です。
- 会員登録を誰でもできる状態に注意:スターターキットには誰でも使える会員登録画面があります。今回は登録直後のロールを閲覧のみにしましたが、社内向けの管理画面では、本番では会員登録の画面自体を閉じ、管理者がユーザーを作る運用にするのが一般的です。連載ではロールの変更はシーダーと tinker で行い、ユーザー管理画面は作りません。
- 権限の変更はログインし直さなくても即時に効く:ロールは毎回のリクエストでデータベースから読み直すため、変更は次の操作から反映されます。逆に、画面を開いたままだとボタンの表示は古いままなので、押した時点でサーバーが 403 を返す作りにしておくことが大切です。
発注者向けメモ:権限表を先に決めると工数が読みやすい
権限まわりは、「画面を作ってから考える」と手戻りが最も大きくなる部分です。発注の前に、今回のような権限表(誰が・何に・何をできるか)を作っておくと、開発会社も工数を見積もりやすくなります。
工数が増えやすいのは、次のような条件です。
- データ単位の権限:「自分の部署の顧客だけ見られる」「担当者本人だけ編集できる」など、ロールだけでなくデータの持ち主で判定する場合
- 項目単位の権限:「単価は管理者にしか見せない」など、同じ画面の中で一部の項目だけを隠す場合
- 承認フロー:「担当者が登録し、上長が承認して確定」のように、状態によってできる操作が変わる場合
- ユーザー管理画面・操作ログ:誰がいつ権限を変えたか、誰が何を削除したかを記録する場合
開発会社には、次のように確認してみてください。
- 「権限のチェックは画面の表示だけでなく、サーバー側でも行いますか?そのテストはありますか?」
- 「新しく作ったユーザーの初期の権限は何になりますか?」
- 「ユーザーの追加や権限の変更は、誰がどの画面で行いますか?その操作は記録されますか?」
- 「将来『担当顧客だけ編集可』のようなルールを追加する場合、どのくらい作り直しが必要ですか?」
まとめと次回予告
第3回では、ロールと Policy で「誰が何をできるか」を実装しました。
- ロールは enum(admin / staff / viewer)で定義し、既定は最も弱い viewer。role は
$fillableに入れない - 判定は
CustomerPolicyに集約し、管理者はGate::beforeで一括許可(判定しないときは null) - コントローラーは
Gate::authorize()、FormRequest はauthorize()で Policy を呼び、許可されなければ 403 - Vue には判定結果(true / false)だけを渡してボタンを出し分け、テストでは「URL を直接たたいて 403」を確かめる
次回は「案件管理と一覧画面の作り込み:検索・並べ替え・ページング」です。顧客にひもづく案件(projects)を追加し、N+1 問題の対策、検索条件の入力チェック、並べ替えとページ送り、Inertia 2 の部分リロードを使った一覧画面を作ります。
この連載の記事一覧
この記事は連載「Laravel+Vue.jsで作る管理画面をECSで動かす」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第1回】全体像と開発環境:Laravel 12+Vue 3 スターターキットを Docker で動かす
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第2回】顧客マスタの一覧と登録:FormRequest でバリデーションする
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第3回】ロールと Policy:誰が何をできるかをコードで決める(この記事)
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第4回】案件管理と一覧画面の作り込み:検索・並べ替え・ページング
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第5回】請求と非同期処理:キューとスケジューラをローカルで動かす
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第6回】本番用コンテナイメージを作る:マルチステージ Dockerfile
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第7回】Terraform の土台とネットワーク:state 管理と VPC
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第8回】RDS for MySQL と秘密情報:パスワードをコードに書かない
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第9回】ECR・ECS on Fargate・ALB:管理画面をインターネットに公開する
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第10回】キューワーカーとスケジューラを ECS で動かす
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第11回】GitHub Actions で CI/CD:OIDC でキーを持たずにデプロイ
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第12回】運用編:ログ・監視・スケーリング・切り戻し










コメント
コメント一覧 (1件)
[…] 前回(第3回:ロールと Policy)では、管理者・担当者・閲覧のみの権限を実装しました。今回はそこに案件(projects)を追加し、一覧画面を作り込みます。 […]