Policy dan gate mendalam

Pola otorisasi kompleks: response pada gate, metode before pada policy, dan kombinasi policy.

Daftar isi

Objek Response, metode before, dan penggabungan kemampuan melengkapi kebutuhan otorisasi yang tidak cukup dijawab oleh satu definisi gate atau satu metode policy. Topik ini membahas ketiga pola tersebut pada aplikasi catatan, yaitu membaca alasan penolakan melalui Gate::inspect(), menambahkan pengecualian pengurus dengan Gate::before() dan metode before pada policy, serta merangkai beberapa kemampuan dengan Gate::any(), Gate::none(), dan policy yang menilai dua model sekaligus. Dasar gate dan policy sudah dibahas pada topik Otorisasi, sehingga contoh di sini langsung menggunakan kedua fasilitas itu pada data yang disiapkan secara deterministik.

Alasan penolakan sebagai objek Response#

Fungsi kemampuan yang hanya mengembalikan true atau false tidak menjelaskan mengapa suatu aksi ditolak, padahal penyebabnya sering perlu ditampilkan, misalnya jumlah catatan yang masih terhubung ke kategori. Untuk kebutuhan tersebut, fungsi kemampuan boleh mengembalikan objek Response milik Illuminate\Auth\Access\Response, yaitu objek yang menyimpan keputusan beserta pesan dan kodenya. Objek ini dibuat melalui Response::allow() untuk penerimaan dan Response::deny() untuk penolakan, sedangkan Gate::inspect() mengembalikannya tanpa mengangkat exception. Metode allowed() mengembalikan nilai boolean, message() membawa pesan penolakannya, dan code() menyimpan kode tambahan yang bermanfaat bagi respons API.

Contoh berikut mendefinisikan gate hapus-kategori, yaitu kemampuan yang menerima kategori dan menghitung catatannya sebelum memutuskan. Kategori kosong diterima melalui Response::allow(), sedangkan kategori yang berisi catatan ditolak dengan pesan yang memuat jumlah catatannya. Kategori contoh beserta dua catatannya dibangun dengan updateOrCreate agar eksekusi ulang menghasilkan keluaran yang sama.

bash
# menyiapkan kategori contoh lalu memeriksa objek Response
cd ~/aplikasi-catatan
php artisan tinker --execute='
use App\Models\Category;
use App\Models\Note;
use App\Models\User;
use Illuminate\Auth\Access\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;

$laporan = Category::updateOrCreate(["nama" => "Laporan"]);
$arsip = Category::updateOrCreate(["nama" => "Arsip"]);
Note::whereIn("category_id", [$arsip->id])->delete();
Note::updateOrCreate(
    ["judul" => "Rekap pengeluaran triwulan"],
    ["isi" => "Merangkum pengeluaran tiap bulan beserta selisihnya.", "category_id" => $laporan->id]
);
Note::updateOrCreate(
    ["judul" => "Ringkasan jam belajar"],
    ["isi" => "Menghitung total jam belajar dan pembagiannya.", "category_id" => $laporan->id]
);

Gate::define("hapus-kategori", function ($pengguna, Category $kategori) {
    $jumlah = $kategori->notes()->count();
    if ($jumlah === 0) {
        return Response::allow();
    }
    return Response::deny("Kategori masih memuat {$jumlah} catatan.");
});
Gate::define("kelola-kategori", function (User $pengguna) {
    return $pengguna->email_verified_at !== null;
});

$ali = User::updateOrCreate(
    ["email" => "[email protected]"],
    ["name" => "Ali", "password" => "sandi-ali"]
);
$ali->forceFill(["email_verified_at" => now()])->save();
$budi = User::updateOrCreate(
    ["email" => "[email protected]"],
    ["name" => "Budi", "password" => "sandi-budi"]
);
$budi->forceFill(["email_verified_at" => null])->save();

$respons = Gate::forUser($ali)->inspect("hapus-kategori", $laporan);
echo "Laporan diterima: ";
var_dump($respons->allowed());
$responsArsip = Gate::forUser($ali)->inspect("hapus-kategori", $arsip);
echo "Arsip diterima: ";
var_dump($responsArsip->allowed());
echo "Pesan penolakan: [" . $respons->message() . "]" . PHP_EOL;
$responsBudi = Gate::forUser($budi)->inspect("kelola-kategori");
echo "Pesan bawaan: [" . $responsBudi->message() . "]" . PHP_EOL;

Auth::setUser($ali);
try {
    Gate::authorize("hapus-kategori", $laporan);
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
    echo "Exception: " . $e->getMessage() . PHP_EOL;
}
'
text Keluaran
Laporan diterima: bool(false)
Arsip diterima: bool(true)
Pesan penolakan: [Kategori masih memuat 2 catatan.]
Pesan bawaan: []
Exception: Kategori masih memuat 2 catatan.

Keluaran tersebut memperlihatkan dua bentuk penolakan yang berbeda. Penolakan hapus-kategori berasal dari Response::deny(), sehingga message() membawa kalimat lengkap beserta jumlah catatan pada saat pemeriksaan dijalankan. Penolakan kelola-kategori hanya berupa nilai false, sehingga objek Response yang dikembalikan inspect() membawa pesan kosong, sedangkan pesan bawaan This action is unauthorized. baru terbentuk ketika Gate::authorize() mengangkat exception. Pesan khusus dari Response::deny() menggantikan pesan bawaan tersebut, sebagaimana terlihat pada baris Exception, dan pesan itu ikut dibawa hingga penangan exception yang merender respons 403.

Aturan yang sama berlaku pada policy karena metode kemampuannya juga boleh mengembalikan objek Response. Bentuk metode policy yang setara dengan gate di atas dituliskan seperti berikut.

php Contoh: metode policy yang mengembalikan Response
// tidak-dijalankan
public function hapus(User $pengguna, Category $kategori): Response
{
    $jumlah = $kategori->notes()->count();

    return $jumlah === 0
        ? Response::allow()
        : Response::deny("Kategori masih memuat {$jumlah} catatan.");
}

Metode before pada gate dan policy#

Pengecualian untuk pengurus sebaiknya tidak dituliskan ulang pada setiap fungsi kemampuan karena aturan yang sama tersebar di banyak tempat. Gate dan policy menyediakan metode before, yaitu pemeriksaan yang dijalankan lebih dahulu sebelum definisi kemampuan mana pun dinilai. Fungsi pada Gate::before() menerima pengguna dan nama kemampuan yang sedang diperiksa, kemudian mengembalikan true untuk menerima seketika, false untuk menolak seketika, atau null untuk meneruskan penilaian kepada aturan berikutnya. Tempat pendaftarannya adalah metode boot() pada AppServiceProvider, sebagaimana definisi gate lainnya.

Contoh berikut memasang Gate::before() yang mengenali pengguna [email protected] sebagai pengurus, kemudian memeriksa kemampuan kelola-kategori pada dua pengguna yang sama-sama belum memverifikasi emailnya.

bash
# menguji Gate::before untuk pengecualian pengurus
cd ~/aplikasi-catatan
php artisan tinker --execute='
use App\Models\User;
use Illuminate\Support\Facades\Gate;

Gate::define("kelola-kategori", function (User $pengguna) {
    return $pengguna->email_verified_at !== null;
});
$admin = User::updateOrCreate(
    ["email" => "[email protected]"],
    ["name" => "Admin", "password" => "sandi-admin"]
);
$admin->forceFill(["email_verified_at" => null])->save();
$budi = User::updateOrCreate(
    ["email" => "[email protected]"],
    ["name" => "Budi", "password" => "sandi-budi"]
);
$budi->forceFill(["email_verified_at" => null])->save();

Gate::before(function ($pengguna, $kemampuan) {
    return $pengguna->email === "[email protected]" ? true : null;
});

echo "Kelola kategori (Admin): ";
var_dump($admin->can("kelola-kategori"));
echo "Kelola kategori (Budi): ";
var_dump($budi->can("kelola-kategori"));
'
text Keluaran
Kelola kategori (Admin): bool(true)
Kelola kategori (Budi): bool(false)

Pengguna bernama Admin diterima meskipun emailnya belum terverifikasi karena metode before mengembalikan true sebelum definisi kelola-kategori sempat dievaluasi. Pengguna Budi menerima null dari fungsi yang sama, sehingga penilaiannya berlanjut ke definisi gate dan berakhir dengan penolakan seperti biasa. Pengecualian yang hanya berlaku pada satu model tidak perlu didaftarkan secara global karena kelas policy dapat menampung metode before sendiri dan metode tersebut dipanggil sebelum metode kemampuannya.

Contoh berikut mendaftarkan kelas policy uji melalui Gate::policy(), yaitu metode pendaftaran manual yang memasangkan model dengan kelas policy-nya. Kelas uji tersebut memuat metode before yang mengenali pengurus beserta metode delete yang memeriksa apakah kategori kosong.

bash
# menguji metode before pada kelas policy
cd ~/aplikasi-catatan
php artisan tinker --execute='
use App\Models\Category;
use App\Models\Note;
use App\Models\User;
use Illuminate\Support\Facades\Gate;

class KebijakanKategoriUji {
    public function before(User $pengguna, string $kemampuan): ?bool {
        return $pengguna->email === "[email protected]" ? true : null;
    }
    public function delete(User $pengguna, Category $kategori): bool {
        return $kategori->notes()->doesntExist();
    }
}

$laporan = Category::updateOrCreate(["nama" => "Laporan"]);
Note::updateOrCreate(
    ["judul" => "Rekap pengeluaran triwulan"],
    ["isi" => "Merangkum pengeluaran tiap bulan beserta selisihnya.", "category_id" => $laporan->id]
);
$admin = User::updateOrCreate(
    ["email" => "[email protected]"],
    ["name" => "Admin", "password" => "sandi-admin"]
);
$admin->forceFill(["email_verified_at" => null])->save();
$ali = User::updateOrCreate(
    ["email" => "[email protected]"],
    ["name" => "Ali", "password" => "sandi-ali"]
);
$ali->forceFill(["email_verified_at" => now()])->save();

Gate::policy(Category::class, KebijakanKategoriUji::class);

echo "Hapus Laporan berisi (Admin): ";
var_dump($admin->can("delete", $laporan));
echo "Hapus Laporan berisi (Ali): ";
var_dump($ali->can("delete", $laporan));
'
text Keluaran
Hapus Laporan berisi (Admin): bool(true)
Hapus Laporan berisi (Ali): bool(false)

Admin diterima pada policy delete meskipun kategori Laporan masih memuat catatan, sedangkan Ali dinilai oleh metode delete dan ditolak karena kategorinya tidak kosong. Apabila pengecualian menyentuh seluruh kemampuan aplikasi, Gate::before() lebih tepat digunakan, sedangkan metode before pada policy cocok ketika pengecualian hanya memengaruhi model tertentu. Urutan penilaian yang melibatkan metode before dan definisi kemampuan dapat diamati tanpa menjalankan kode, yaitu pada demo berikut. Pilih pengguna, ubah keadaan opsi before, kemudian periksa kedua kemampuan dan amati urutan baris yang terbentuk.

Menggabungkan beberapa kemampuan#

Keputusan otorisasi sering dibentuk dari beberapa kemampuan sekaligus, misalnya aksi yang boleh dilakukan oleh pengguna terverifikasi atau oleh pengurus aplikasi. Gate::any() dan Gate::none() menyediakan penggabungan tersebut. Keduanya menerima daftar nama kemampuan dan menilai seluruhnya terhadap satu pengguna, yaitu any() mengembalikan true apabila minimal satu kemampuan diterima, sedangkan none() mengembalikan true hanya apabila seluruh kemampuan ditolak.

Contoh berikut mendefinisikan dua gate, yaitu kelola-kategori yang menilai verifikasi email dan kelola-sistem yang menilai alamat email pengurus. Ketiga pengguna kemudian diperiksa dengan any() dan none() terhadap kedua kemampuan itu.

bash
# menggabungkan dua kemampuan dengan Gate::any dan Gate::none
cd ~/aplikasi-catatan
php artisan tinker --execute='
use App\Models\User;
use Illuminate\Support\Facades\Gate;

Gate::define("kelola-kategori", function (User $pengguna) {
    return $pengguna->email_verified_at !== null;
});
Gate::define("kelola-sistem", function (User $pengguna) {
    return $pengguna->email === "[email protected]";
});
$ali = User::updateOrCreate(
    ["email" => "[email protected]"],
    ["name" => "Ali", "password" => "sandi-ali"]
);
$ali->forceFill(["email_verified_at" => now()])->save();
$admin = User::updateOrCreate(
    ["email" => "[email protected]"],
    ["name" => "Admin", "password" => "sandi-admin"]
);
$admin->forceFill(["email_verified_at" => null])->save();
$budi = User::updateOrCreate(
    ["email" => "[email protected]"],
    ["name" => "Budi", "password" => "sandi-budi"]
);
$budi->forceFill(["email_verified_at" => null])->save();

echo "Salah satu (Ali): ";
var_dump(Gate::forUser($ali)->any(["kelola-kategori", "kelola-sistem"]));
echo "Salah satu (Admin): ";
var_dump(Gate::forUser($admin)->any(["kelola-kategori", "kelola-sistem"]));
echo "Tidak satu pun (Budi): ";
var_dump(Gate::forUser($budi)->none(["kelola-kategori", "kelola-sistem"]));
'
text Keluaran
Salah satu (Ali): bool(true)
Salah satu (Admin): bool(true)
Tidak satu pun (Budi): bool(true)

Kemampuan kelola-kategori tidak diterima untuk Admin karena emailnya belum terverifikasi, tetapi any() tetap mengembalikan true karena kemampuan kelola-sistem yang membaca alamat emailnya diterima. Budi gagal pada keduanya, sehingga none() mengembalikan true untuk menyatakan bahwa tidak ada satu pun kemampuan yang tersedia. Pemanggilan dengan model juga didukung, yaitu model yang diberikan sebagai argumen diteruskan ke setiap kemampuan pada daftar.

Penggabungan yang tidak dapat dirangkum oleh any() adalah kemampuan yang menilai dua model sekaligus, misalnya menyalin isi satu kategori ke kategori lain. Metode policy dapat menerima argumen model tambahan, dan pemanggil meneruskannya sebagai array pada can(), sehingga metode tersebut menerima kedua kategori sekaligus. Di dalam metode tersebut, aturan gate dapat dipanggil kembali melalui $pengguna->can() agar pengguna yang belum terverifikasi tidak lolos hanya karena aturan antarmodelnya terpenuhi.

bash
# menguji kemampuan salin yang menilai dua kategori sekaligus
cd ~/aplikasi-catatan
php artisan tinker --execute='
use App\Models\Category;
use App\Models\Note;
use App\Models\User;
use Illuminate\Support\Facades\Gate;

Gate::define("kelola-kategori", function (User $pengguna) {
    return $pengguna->email_verified_at !== null;
});
class KebijakanSalinUji {
    public function salin(User $pengguna, Category $sumber, Category $target): bool {
        return $pengguna->can("kelola-kategori")
            && $sumber->id !== $target->id
            && $sumber->notes()->exists();
    }
}
Gate::policy(Category::class, KebijakanSalinUji::class);

$laporan = Category::updateOrCreate(["nama" => "Laporan"]);
$arsip = Category::updateOrCreate(["nama" => "Arsip"]);
Note::whereIn("category_id", [$arsip->id])->delete();
Note::updateOrCreate(
    ["judul" => "Rekap pengeluaran triwulan"],
    ["isi" => "Merangkum pengeluaran tiap bulan beserta selisihnya.", "category_id" => $laporan->id]
);
$ali = User::updateOrCreate(
    ["email" => "[email protected]"],
    ["name" => "Ali", "password" => "sandi-ali"]
);
$ali->forceFill(["email_verified_at" => now()])->save();
$budi = User::updateOrCreate(
    ["email" => "[email protected]"],
    ["name" => "Budi", "password" => "sandi-budi"]
);
$budi->forceFill(["email_verified_at" => null])->save();

echo "Menyalin Laporan ke Arsip (Ali): ";
var_dump($ali->can("salin", [$laporan, $arsip]));
echo "Menyalin Laporan ke Laporan (Ali): ";
var_dump($ali->can("salin", [$laporan, $laporan]));
echo "Menyalin Laporan ke Arsip (Budi): ";
var_dump($budi->can("salin", [$laporan, $arsip]));
'
text Keluaran
Menyalin Laporan ke Arsip (Ali): bool(true)
Menyalin Laporan ke Laporan (Ali): bool(false)
Menyalin Laporan ke Arsip (Budi): bool(false)

Kasus pertama lolos karena Ali terverifikasi, kedua kategori berbeda, dan kategori sumber memuat catatan. Kasus kedua ditolak oleh pemeriksaan $sumber->id !== $target->id karena kategori sumber dan kategori tujuannya sama, sedangkan kasus ketiga ditolak oleh aturan gate yang dipanggil dari dalam metode salin. Bentuk akhir kelas policy yang memuat pengecualian pengurus, aturan kategori kosong, dan kemampuan salin dituliskan seperti berikut.

php CategoryPolicy.php
// tidak-dijalankan
<?php

namespace App\Policies;

use App\Models\Category;
use App\Models\User;

class CategoryPolicy
{
    public function before(User $pengguna, string $kemampuan): ?bool
    {
        return $pengguna->email === "[email protected]" ? true : null;
    }

    public function delete(User $pengguna, Category $kategori): bool
    {
        return $kategori->notes()->doesntExist();
    }

    public function salin(User $pengguna, Category $sumber, Category $target): bool
    {
        return $pengguna->can("kelola-kategori")
            && $sumber->id !== $target->id
            && $sumber->notes()->exists();
    }
}

Metode before pada berkas tersebut menangani pengecualian pengurus untuk seluruh kemampuan policy, metode delete mempertahankan aturan kategori kosong, dan metode salin menilai dua model sekaligus dengan bantuan aturan gate. Definisi gate kelola-kategori tetap berada pada AppServiceProvider karena kemampuan itu juga dipanggil di luar policy. Alur keputusan lengkap dari pemanggilan pemeriksaan hingga responsnya digambarkan pada diagram berikut.

Pemeriksaancan / authorizeMetode beforetrue: terimanull: lanjuttruenullDefinisi kemampuangate / policyallowDiterimaaksi dijalankandenyDitolak403 + pesanPenolakan tetap membawa alasan, sedangkan penerimaan melanjutkan aksi.
Alur keputusan otorisasi dari pemeriksaan hingga respons

Kesalahan umum#

Kesalahan pertama adalah mengembalikan false dari metode before untuk pengguna biasa, misalnya menuliskan return $pengguna->email === "[email protected]"; pada metode policy. Nilai false bersifat final dan menghentikan seluruh penilaian, sehingga setiap kemampuan pengguna biasa ditolak tanpa metode policy-nya sempat dievaluasi. Kembalikan null ketika metode before tidak menemukan pengecualian, dan sisakan true atau false hanya untuk pengecualian yang memang final.

Kesalahan kedua adalah membaca message() dari objek Response tanpa memeriksa asal penolakannya. Penolakan yang berasal dari nilai false menghasilkan pesan kosong, sehingga antarmuka yang menampilkan pesan itu berakhir dengan teks yang tidak menjelaskan apa pun. Pastikan kemampuan yang pesannya ditampilkan mengembalikan Response::deny() dengan kalimat yang lengkap, atau siapkan pesan cadangan apabila message() mengembalikan nilai kosong.

Kesalahan ketiga adalah menggabungkan kemampuan dengan bentuk argumen yang berbeda dalam satu pemanggilan any(). Metode any() meneruskan argumen yang sama ke setiap kemampuan, sehingga kemampuan dua model seperti salin tidak dapat digabungkan dengan kemampuan satu model tanpa penyesuaian. Pisahkan pemanggilannya, atau susun kemampuan pengganti yang menerima bentuk argumen yang sama.

Ringkasan#

  • Gate::inspect() mengembalikan objek Response yang dapat diperiksa dengan allowed(), message(), dan code(), sedangkan Response::deny() membawa pesan penolakan yang ikut terbawa pada exception hingga respons 403.
  • Metode before pada gate dan policy dijalankan lebih dahulu, yaitu true menerima seketika, false menolak seketika, dan null meneruskan penilaian kepada aturan berikutnya.
  • Gate::any() dan Gate::none() menggabungkan beberapa kemampuan terhadap satu pengguna, dan argumen yang sama diteruskan ke setiap kemampuan pada daftar.
  • Metode policy dapat menerima argumen model tambahan melalui can() dan memanggil kemampuan lain, sehingga aturan antarmodel dan aturan gate dapat dirangkai dalam satu metode.

Referensi#