Otorisasi Action
Setiap action selalu menjawab dua pertanyaan berbeda:
- Apakah action perlu ditampilkan?
- Apakah action boleh dijalankan?
Keduanya adalah hal yang terpisah, diperiksa pada waktu yang berbeda, dan hanya pemeriksaan kedua yang benar-benar melindungi operasi dari akses yang tidak sah.
Gunakan panduan ini ketika Anda perlu menentukan di mana pemeriksaan izin harus ditempatkan dan bagaimana endpoint akan memprosesnya.
Aturan utamanya: menyembunyikan tombol hanya berkaitan dengan tampilan. Endpoint tetap harus memeriksa izin lagi sebelum menjalankan action. Pemeriksaan di endpoint inilah yang menjadi batas keamanan sebenarnya.
Contoh minimal yang dapat digunakan
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Orders\Tables;
use App\Panels\Admin\Resources\Orders\OrderResource;
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Actions\Action;
use PandaPanel\Tables\Columns\TextColumn;
use PandaPanel\Tables\TableSchema;
final class OrdersTable
{
public static function configure(TableSchema $table): TableSchema
{
return $table
->columns([
TextColumn::make('reference')->searchable(),
])
->recordActions([
Action::make('approve')
// Tampilan: order yang sudah diproses tidak perlu menampilkan tombol approve.
->visible(
static fn (?Model $record): bool =>
$record?->getAttribute('status') === 'pending'
)
// Izin: endpoint akan memeriksa kembali sebelum action dijalankan.
->authorize(
static fn (?Model $record): bool =>
$record !== null
&& OrderResource::canEdit($record)
)
->action(
static fn (Model $record) => $record->approve()
),
]);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
User yang tidak memiliki izin tidak akan melihat tombol tersebut. Jika user mencoba mengirim request POST buatan sendiri dengan action bernama approve, server tetap akan menolaknya dengan response 403.
Tiga closure utama
| Method | Signature | Dipanggil dengan | Jika hasilnya false |
|---|---|---|---|
visible() | visible(Closure $callback): static | ?Model | action tidak dimasukkan ke payload |
authorize() | authorize(Closure $callback): static | ?Model | action tidak dimasukkan ke payload dan eksekusi menghasilkan 403 |
authorizeEachUsing() | authorizeEachUsing(Closure $callback): static | Model | seluruh proses bulk dibatalkan dengan 403 sebelum ada data yang diubah |
Method pembacanya adalah:
isVisibleFor(?Model $record): boolisAuthorizedFor(?Model $record): boolisAuthorizedForEach(Model $record): bool
Ketiganya secara default menghasilkan true jika tidak ada closure yang diberikan.
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;
use PandaPanel\Actions\Action;
$action = Action::make('approve')
->visible(
static fn (?Model $record): bool =>
$record?->getAttribute('status') === 'pending'
)
->authorize(
static fn (?Model $record): bool =>
$record !== null
&& Gate::allows('approve', $record)
)
->authorizeEachUsing(
static fn (Model $record): bool =>
Gate::allows('approve', $record)
);
$action->isVisibleFor($order); // bool
$action->isAuthorizedFor($order); // bool
$action->isAuthorizedForEach($order); // bool2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
visible() hanya menentukan apakah action perlu ditampilkan. Method ini bukan izin keamanan.
Contohnya, tombol Restore pada record yang masih aktif tidak perlu ditampilkan karena memang tidak ada yang perlu dipulihkan. Itu tidak berarti user dilarang melakukan restore secara umum.
Sebaliknya, authorize() menentukan apakah user benar-benar memiliki izin untuk menjalankan action.
Pemisahan ini memungkinkan table mengatakan:
"Baris ini tidak memiliki sesuatu yang perlu di-approve."
tanpa mengatakan:
"User ini tidak boleh melakukan approve."
?Model berarti nilainya bisa null
Baik visible() maupun authorize() dapat dipanggil dengan nilai null ketika action diserialisasi tanpa record tertentu.
Hal ini terjadi pada:
- action di header, toolbar, atau empty state karena memang tidak memiliki record;
- bulk action karena definisinya dikirim sebelum user memilih record;
Action::toArray()yang dipanggil tanpa argument, seperti yang dilakukanTableSchemaketika melakukan serialisasi action tersebut.
// Semua built-in action memulai pemeriksaan seperti ini karena ada alasannya.
->authorize(
static fn (?Model $record): bool =>
$record !== null
&& $resource::canDelete($record)
)2
3
4
5
6
Jika closure langsung mengakses $record tanpa memastikan nilainya tidak null, proses rendering table dapat menghasilkan exception dan membuat seluruh halaman gagal dimuat.
Pada bulk action, pemanggilan dengan null bukan kesalahan. Nilai tersebut digunakan untuk menjawab pertanyaan izin secara umum sebelum record yang dipilih diperiksa satu per satu.
use App\Panels\Admin\Resources\Orders\OrderResource;
->authorize(
static fn (?Model $record): bool =>
$record === null
? OrderResource::canDeleteAny()
: OrderResource::canDelete($record)
)2
3
4
5
6
7
8
Cara inilah yang digunakan oleh DeleteBulkAction.
Cara kerja serializer
Action::toArray(?Model $record = null): ?arrayMethod tersebut akan mengembalikan null jika action tidak terlihat atau tidak diizinkan untuk record tersebut.
Semua pemanggil kemudian mengabaikan action yang menghasilkan null:
TableSchema::toRow()membuat daftaractionsuntuk setiap row dan menghapus action yang tidak diizinkan;TableSchema::toArray()melakukan serialisasi header action, toolbar action, empty-state action, dan bulk action tanpa record;InfolistSchema::toArray()danEntry::toArray()melakukan hal yang sama untuk halaman view;Action::toArray()juga melakukan proses yang sama secara rekursif terhadapmodalActions, sehingga action di dalam dialog yang tidak boleh dijalankan user tidak akan dikirim ke frontend.
Dengan demikian, action yang tidak boleh dijalankan user sebaiknya memang tidak dirender sejak awal.
Ini lebih baik daripada menampilkan tombol yang baru mengembalikan 403 setelah ditekan.
Di mana endpoint melakukan pemeriksaan izin
Setiap endpoint action akan melakukan otorisasi sebelum menjalankan operasi apa pun, apa pun data yang dikirim oleh client.
| Endpoint | Action dicari melalui | Pemeriksaan otorisasi |
|---|---|---|
POST actions/record | TableSchema::getRecordAction() | isAuthorizedFor($record) |
POST actions/table | TableSchema::getTableAction() | isAuthorizedFor(null) |
POST actions/bulk | TableSchema::getBulkAction() | isAuthorizedFor(null), kemudian isAuthorizedForEach() untuk setiap record menggunakan authorizeEachUsing() atau authorize($record) |
POST actions/infolist | InfolistSchema::getAction() | isAuthorizedFor($record) |
GET actions/form | schema sesuai scope action | isAuthorizedFor($record) |
POST actions/form | schema sesuai scope action | isAuthorizedFor($record), kemudian pemeriksaan per-record untuk bulk action |
POST actions/cell | TableSchema::getColumn() | Resource::canEdit($record) dan pemeriksaan disabled milik column |
POST actions/reorder | TableSchema::getReorderColumn() | Resource::canEdit($record) untuk setiap record |
Form action diperiksa dua kali:
- ketika server membuat atau menjelaskan dialog;
- ketika user mengirim form untuk menjalankan action.
Membuka dialog dan menjalankan operasi adalah dua kejadian yang berbeda.
Contohnya, jika role user dicabut ketika dialog masih terbuka, user tersebut tidak boleh tetap bisa menjalankan action hanya karena dialog sudah terlanjur dibuka.
Karena itu, izin harus diperiksa kembali ketika form disubmit.
Lookup juga merupakan bagian penting
Sebelum pemeriksaan izin dilakukan, request sudah dipersempit melalui beberapa proses lookup.
Proses ini bukan permission secara langsung, tetapi tetap berperan penting dalam membatasi apa yang bisa diakses.
Urutannya:
- Panel ditentukan berdasarkan request saat ini. Resource yang terdaftar di panel lain tidak tersedia di registry panel ini.
- Slug resource dicari di registry tersebut. Jika tidak ditemukan, server mengembalikan
404. - Action dicari pada schema yang memang mendefinisikannya. Jika tidak ditemukan, server mengembalikan
404. - Setiap scope memiliki whitelist sendiri. Karena itu, action milik halaman view tidak bisa dijalankan melalui endpoint table.
- Record dimuat melalui
Resource::findRecord()ataufindRecords(). Method tersebut menggunakan scope milik resource. Primary key yang berada di luar scope dianggap tidak ditemukan. - Pada bulk action, jumlah record yang berhasil ditemukan dibandingkan dengan jumlah key yang diminta. Jika salah satu key tidak ditemukan, proses menghasilkan
404, bukan menjalankan sebagian record saja. - Setelah seluruh proses tersebut selesai, barulah policy diperiksa.
Nested resource memiliki proses tambahan.
Parent resource akan ditentukan melalui key parent pada payload, kemudian parent tersebut di-resolve dan diotorisasi melalui parent resource dengan mekanisme yang sama seperti route middleware.
Tanpa proses ini, action pada nested resource berisiko dijalankan terhadap child dari parent lain.
Kemampuan atau ability pada Resource
Built-in action melakukan otorisasi melalui resource.
Semua method can* diteruskan melalui:
PandaPanel\Support\PolicyGate| Method Resource | Ability | Argument | Digunakan oleh |
|---|---|---|---|
canViewAny() | viewAny | class model | ExportAction |
canView($record) | view | record | ViewAction, ReplicateAction |
canCreate() | create | class model | CreateAction, ImportAction, ReplicateAction |
canEdit($record) | update | record | EditAction, editable cell, reordering |
canDelete($record) | delete | record | DeleteAction, DeleteBulkAction |
canDeleteAny() | deleteAny | class model | DeleteBulkAction |
canRestore($record) | restore | record | RestoreAction, RestoreBulkAction |
canRestoreAny() | restoreAny | class model | RestoreBulkAction |
canForceDelete($record) | forceDelete | record | ForceDeleteAction, ForceDeleteBulkAction |
canForceDeleteAny() | forceDeleteAny | class model | ForceDeleteBulkAction |
Ability yang berakhiran Any diperlukan karena bulk action harus menentukan apakah action secara umum diizinkan sebelum ada record tertentu yang diperiksa.
Karena itu, tambahkan ability tersebut pada policy bersama method per-record yang sesuai.
Relation action menggunakan ability dari relation manager.
Sebagian permission tersebut diperiksa menggunakan policy milik owner.
| Method Relation Manager | Ability | Policy yang digunakan |
|---|---|---|
canViewAny($owner) | viewAny | model yang berelasi |
canCreate($owner) | create | model yang berelasi |
canEdit($owner, $record) | update | record yang berelasi |
canDelete($owner, $record) | delete | record yang berelasi |
canRestore($owner, $record) | restore | record yang berelasi |
canForceDelete($owner, $record) | forceDelete | record yang berelasi |
canAttach($owner) | attachAny | owner |
canDetach($owner, $record) | detach | owner, dengan related record sebagai argument kedua |
canAssociate($owner) | associateAny | owner |
canDissociate($owner, $record) | dissociate | owner, dengan related record sebagai argument kedua |
Hubungan membership merupakan tanggung jawab owner.
Contohnya:
Apakah line item ini boleh ditambahkan ke order tersebut?
Pertanyaan seperti ini adalah aturan yang berkaitan dengan order sebagai owner, bukan hanya dengan line item.
Strict Authorization
use PandaPanel\Core\Panel;
Panel::make('admin')
->strictAuthorization();2
3
4
Jika strict authorization diaktifkan, PolicyGate memastikan bahwa:
- policy untuk model tersebut benar-benar tersedia; dan
- policy tersebut memiliki ability yang sedang diperiksa.
Jika salah satu syarat tidak terpenuhi, PandaBear akan melempar exception:
PandaPanel\Exceptions\PanelAuthorizationExceptionTanpa strict authorization, method policy yang tidak tersedia hanya dianggap sebagai penolakan biasa oleh Laravel Gate.
Masalahnya, kondisi tersebut dapat terlihat seperti permission berjalan dengan benar, padahal sebenarnya policy atau method-nya belum dibuat dan selalu menghasilkan penolakan.
Policy yang memiliki method before() dianggap memenuhi pemeriksaan untuk semua ability karena before() dapat memberikan jawaban untuk seluruh ability tersebut.
Aturan action untuk seluruh panel
configureActions() menerima setiap action ketika action tersebut dibuat.
Ini dapat digunakan untuk menerapkan aturan umum pada semua action dalam panel.
Contohnya, semua action yang bersifat destruktif harus meminta konfirmasi terlebih dahulu:
use PandaPanel\Actions\Action;
use PandaPanel\Actions\Enums\ActionVariant;
use PandaPanel\Core\Panel;
Panel::make('admin')
->configureActions(
static function (Action $action): void {
if ($action->getVariant() === ActionVariant::Destructive) {
$action->requiresConfirmation();
}
}
);2
3
4
5
6
7
8
9
10
11
12
Namun, configureActions() tidak boleh digunakan untuk menambahkan permission yang dimaksudkan untuk menggantikan permission milik schema.
Alasannya, configurator dijalankan terlebih dahulu. Jika setelah itu schema memanggil authorize(), konfigurasi authorization sebelumnya akan digantikan.
Untuk aturan akses yang berlaku pada seluruh panel, gunakan:
Panel::canAccess()Method tersebut dijalankan sebelum route panel dapat diakses.
Pengujian
it('memastikan policy diperiksa saat action dijalankan, bukan hanya saat tombol dirender', function (): void {
$this->actingAs(User::factory()->create());
$this->post('/admin/actions/record', [
'resource' => 'users',
'action' => 'delete',
'record' => $this->target->id,
])->assertForbidden();
expect(
User::find($this->target->id)
)->not->toBeNull();
});
it('menyembunyikan action yang ditolak policy pada row tersebut', function (): void {
$rows = $this
->get('/admin/users')
->viewData('page')['props']['rows'];
$ownRow = collect($rows)
->firstWhere('key', $this->admin->id);
expect(
array_column($ownRow['actions'], 'name')
)->not->toContain('delete');
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Kedua test tersebut penting.
Namun, test pertama tidak boleh dihapus.
Test pertama memastikan bahwa perlindungan keamanan benar-benar berada di endpoint, bukan hanya karena tombol berhasil disembunyikan dari UI.
Dengan kata lain:
Tombol yang tidak terlihat bukanlah mekanisme keamanan.
Hal-hal yang perlu diperhatikan
Mengganti handler built-in action tidak menghapus authorization bawaannya.
Contohnya,DeleteAction::make(...)->action(...)tetap akan memeriksacanDelete(). Pastikan izin tersebut memang sesuai dengan operasi baru yang Anda jalankan.visible()bukan permission.
Method ini tidak diperiksa ulang oleh record endpoint ketika action dijalankan. Endpoint hanya memeriksaauthorize(). Semua aturan yang berkaitan dengan keamanan harus ditempatkan diauthorize().Pemeriksaan
authorize()pertama pada bulk action menerimanull, termasuk di endpoint.
Pemeriksaan ini dilakukan sebelum record yang dipilih dimuat, sehingga belum bisa mengetahui record mana yang akan diproses.Pemeriksaan per-record pada bulk action akan membatalkan seluruh batch jika salah satu record tidak diizinkan.
Sistem menggunakanauthorizeEachUsing()jika tersedia. Jika tidak, sistem akan menggunakanauthorize($record). Ini adalah mekanisme permission, bukan mekanisme filtering. Jika Anda ingin melewati record tertentu, lakukan filtering di dalam handler.Link action dapat diperiksa dua kali oleh dua pemilik permission yang berbeda.
Action dapat menyembunyikan tombol, tetapi halaman tujuan tetap harus melakukan otorisasi ketika dibuka. Menghapus pemeriksaan kedua akan menciptakan celah keamanan.Registered modal action diperiksa menggunakan record yang sama dengan parent action.
Pemeriksaan dilakukan ketika parent diserialisasi dan dilakukan lagi oleh endpoint ketika modal action dijalankan.Endpoint upload untuk form milik action menggunakan izin action tersebut, bukan izin resource.
Action yang tidak boleh dijalankan user juga tidak boleh digunakan sebagai jalur untuk mengunggah file ke storage.