Autentikasi menjawab “siapa pemanggilnya”; authorization menjawab “apa yang boleh dilakukannya”. Di web, jawaban kedua biasanya berupa redirect ke halaman error — di API, client membutuhkan status code 403 dengan body JSON yang bisa diprogram. Kabar baiknya: mesin policy dan gate Laravel bekerja sama persis untuk kedua dunia, karena keduanya berakhir pada exception yang sama di lapisan handler. Chapter ini memasang PostPolicy pada endpoint posts kita, membedah bentuk 403 yang dihasilkan, lalu menambahkan dimensi khusus API: token abilities, yaitu membatasi token apa saja yang boleh melakukan aksi tertentu — bukan hanya user-nya.
Policy untuk Resource Post #
Aturan bisnis kita: hanya pemilik post yang boleh mengubah dan menghapus, dan hanya pemilik yang boleh mempublikasikan. Ketiganya berbahasa jelas dalam PostPolicy — Laravel menemukan class ini otomatis berdasarkan konvensi nama App\Policies\PostPolicy untuk model App\Models\Post:
<?php
// app/Policies/PostPolicy.php
namespace App\Policies;
use App\Models\Post;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class PostPolicy
{
public function update(User $user, Post $post): Response
{
if (! $user->tokenCan('posts:update')) {
return Response::deny('Token tidak memiliki ability posts:update.');
}
return $user->id === $post->user_id
? Response::allow()
: Response::deny('Anda bukan pemilik post ini.');
}
public function delete(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
public function publish(User $user, Post $post): Response
{
if ($user->id !== $post->user_id) {
return Response::deny('Anda bukan pemilik post ini.');
}
return $post->status === 'draft'
? Response::allow()
: Response::deny('Post ini sudah published.');
}
}Perhatikan dua gaya balikan yang sengaja ditunjukkan berdampingan. Method update() dan publish() mengembalikan objek Response dari Response::allow()/Response::deny() — gaya yang menyertakan pesan kustom untuk dijadikan isi 403. Method delete() cukup mengembalikan bool — gaya ringkas yang menghasilkan pesan bawaan framework. Method update() juga memeriksa $user->tokenCan('posts:update') — kita bedah maknanya setelah wiring selesai.
$this->authorize di Controller #
Trait AuthorizesRequests menyediakan method $this->authorize() yang menjalankan policy lalu melempar AuthorizationException bila ditolak. Implementasinya pada dua method controller:
<?php
// app/Http/Controllers/Api/PostController.php (cuplikan)
public function update(Request $request, Post $post): JsonResponse
{
$this->authorize('update', $post);
$post->update($request->validate([
'title' => 'sometimes|string|max:100',
'content' => 'sometimes|string',
]));
return response()->json($post);
}
public function destroy(Post $post): Response
{
$this->authorize('delete', $post);
$post->delete();
return response()->noContent();
}Argumen pertama dari $this->authorize() adalah nama method policy, argumen kedua adalah model yang menjadi konteksnya. Di sisi route, kedua endpoint tulis ini dilindungi auth:sanctum agar $user di policy selalu terisi:
<?php
// routes/api.php (di dalam group prefix v1)
use App\Http\Controllers\Api\PostController;
use Illuminate\Support\Facades\Route;
Route::apiResource('posts', PostController::class)->only(['index', 'show', 'store']);
Route::put('/posts/{post}', [PostController::class, 'update'])
->middleware('auth:sanctum');
Route::delete('/posts/{post}', [PostController::class, 'destroy'])
->middleware('auth:sanctum');Anatomi 403 yang Sebenarnya #
Saat policy menolak, exception handler mengonversi AuthorizationException menjadi HTTP 403 dengan satu kunci message. Kita tangkap tiga varian nyatanya dari feature test — token tanpa ability, user bukan pemilik, dan policy bergaya bool:
Output yang Dihasilkan:
### PUT /api/v1/posts/1 oleh pemilik, token tanpa ability posts:update → HTTP 403
{
"message": "Token tidak memiliki ability posts:update."
}
### PUT /api/v1/posts/1 oleh Siti (bukan pemilik), token punya ability → HTTP 403
{
"message": "Anda bukan pemilik post ini."
}
### DELETE /api/v1/posts/1 oleh Siti (policy mengembalikan false) → HTTP 403
{
"message": "This action is unauthorized."
}Dua baris pertama berasal dari Response::deny('...') — pesan kustom kita lolos utuh ke client. Baris ketiga adalah pesan bawaan This action is unauthorized. ketika policy hanya mengembalikan false. Pemanggil tanpa token sama sekali tidak sampai ke policy — middleware auth:sanctum sudah lebih dulu menjawab 401 dengan body {"message": "Unauthenticated."} seperti pada chapter Token Authentication
.
Gate::authorize di Route #
Untuk aksi yang bukan bagian CRUD resource, Gate::authorize() di route closure memberi hasil yang sama persis. Aksi publish menggabungkan gate di dalam dengan middleware di luar:
<?php
// routes/api.php (di dalam group prefix v1)
use App\Models\Post;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Route;
Route::post('/posts/{post}/publish', function (Post $post) {
Gate::authorize('publish', $post);
$post->update(['status' => 'published', 'published_at' => now()]);
return response()->json(['id' => $post->id, 'status' => $post->status]);
})->middleware(['auth:sanctum', 'ability:posts:publish']);Pemilik yang mempublikasikan draft miliknya sendiri mendapat jawaban sukses, sedangkan pemilik yang mengulang penerbitan post yang sudah terbit ditolak policy:
Output yang Dihasilkan:
### POST /api/v1/posts/1/publish oleh pemilik draft → HTTP 200
{
"id": 1,
"status": "published"
}
### POST /api/v1/posts/1/publish untuk post yang sudah published → HTTP 403
{
"message": "Post ini sudah published."
}Token Abilities: Membatasi Token, Bukan User #
Inilah dimensi yang hanya dimiliki API berbasis token. Method createToken() Sanctum menerima daftar abilities — misalnya $user->createToken('mobile-budi', ['posts:update']) — dan method tokenCan() pada user memeriksa apakah token yang sedang dipakai memiliki ability tersebut. Policy update() di atas menggabungkan dua pertanyaan menjadi satu: token ini diizinkan mengubah post (tokenCan), dan user ini berhak atas post tersebut (ownership). Dengan begitu, token milik Budi yang sengaja dibuat hanya untuk membaca tetap ditolak meskipun Budi pemilik postnya.
Dalam feature test, Sanctum menyediakan jalan pintas Sanctum::actingAs($user, ['posts:update']) yang menempelkan token palsu dengan abilities tertanam, tanpa perlu menyentuh tabel personal_access_tokens:
<?php
// tests/Feature/ApiAuthorizationTest.php (cuplikan)
use Laravel\Sanctum\Sanctum;
public function test_token_tanpa_ability_ditolak_403(): void
{
$user = User::factory()->create(['name' => 'Budi', 'email' => '[email protected]']);
$post = Post::factory()->create(['title' => 'Post Budi', 'user_id' => $user->id]);
Sanctum::actingAs($user, ['posts:read']);
$this->putJson("/api/v1/posts/{$post->id}", ['title' => 'Judul baru'])
->assertForbidden()
->assertJsonPath('message', 'Token tidak memiliki ability posts:update.');
}Middleware ability: pada route publish bekerja di lapisan yang berbeda — memeriksa token sebelum controller jalan. Alias ini tidak terdaftar otomatis di Laravel modern, jadi kita daftarkan sendiri di bootstrap/app.php:
<?php
// bootstrap/app.php (cuplikan withMiddleware)
$middleware->alias([
'ability' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
'abilities' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
]);Token yang kekurangan ability ditolak middleware ini dengan pesan generik dari Sanctum — terlihat pada baris terakhir output berikut:
Output yang Dihasilkan:
### POST /api/v1/posts/1/publish oleh pemilik, token tanpa posts:publish → HTTP 403
{
"message": "Invalid ability provided."
}Pesan “Invalid ability provided.” memang kurang deskriptif — Sanctum tidak menyebutkan ability mana yang hilang. Bila pesan yang jelas penting bagi client kita, pemeriksaan tokenCan() di dalam policy (seperti method update()) memberi kendali penuh atas isi pesan 403. Detail abilities lainnya tersedia di dokumentasi sanctum
, sementara konsep policy dan gate dibahas tuntas di dokumentasi authorization
.
Endpoint tulis kita kini punya dua lapis pintu: token yang sah, dan keputusan bisnis di policy. Lapis berikutnya menyangkut koleksi: bagaimana menyajikan ribuan post tanpa membengkakkan response — paginate(), filter, dan sorting adalah topik chapter Pagination dan Filtering
.