Authorization
Sebelum sebuah screen Panel dirender, framework selalu menjawab dua pertanyaan:
- apakah user boleh masuk ke Panel ini; dan
- apakah user boleh melakukan operasi tertentu di dalamnya.
Pertanyaan pertama dijawab oleh access rule milik Panel. Pertanyaan kedua didelegasikan ke Laravel Gate, sehingga Policy application Anda tetap menjadi sumber aturan authorization utama. Gunakan dokumentasi ini ketika request menghasilkan 403, atau ketika Anda ingin memastikan sebuah operasi memang seharusnya diizinkan atau ditolak.
Dua Pemeriksaan Utama
use App\Models\User;
use Illuminate\Contracts\Auth\Authenticatable;
use PandaPanel\Core\Panel;
use PandaPanel\Core\PanelProvider;
final class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->path('admin')
->auth()
->canAccess(static fn (?Authenticatable $user): bool => $user instanceof User && $user->is_admin);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\User;
final class UserPolicy
{
public function viewAny(User $user): bool
{
return $user->is_admin;
}
public function view(User $user, User $record): bool
{
return $user->is_admin || $user->is($record);
}
public function create(User $user): bool
{
return $user->is_admin;
}
public function update(User $user, User $record): bool
{
return $user->is_admin || $user->is($record);
}
public function delete(User $user, User $record): bool
{
return $user->is_admin && ! $user->is($record);
}
public function deleteAny(User $user): bool
{
return $user->is_admin;
}
}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
40
Dengan konfigurasi di atas, user non-admin akan mendapat 403 saat membuka /admin. Admin hanya akan mendapat 403 pada /admin/users jika Policy memang menolaknya.
$this->actingAs(User::factory()->create())->get('/admin')->assertForbidden();
$this->actingAs(User::factory()->admin()->create())->get('/admin')->assertOk();2
Access ke Panel
public function canAccess(Closure $callback): self // Closure(?Authenticatable): bool
public function isAccessibleTo(?Authenticatable $user): bool2
isAccessibleTo() mengevaluasi dua aturan, dan keduanya harus mengizinkan:
public function isAccessibleTo(?Authenticatable $user): bool
{
if ($user instanceof PanelUser && ! $user->canAccessPanel($this)) {
return false;
}
return $this->canAccess === null || ($this->canAccess)($user);
}2
3
4
5
6
7
8
Closure milik Panel merupakan aturan tentang Panel tersebut, misalnya:
Panel ini hanya untuk administrator.
Sedangkan PandaPanel\Contracts\PanelUser merupakan aturan tentang account user:
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use PandaPanel\Contracts\PanelUser;
use PandaPanel\Core\Panel;
final class User extends Authenticatable implements PanelUser
{
public function canAccessPanel(Panel $panel): bool
{
return $this->suspended_at === null;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Karena rule tersebut ditulis pada model, ia otomatis berlaku untuk semua Panel dan tidak mudah terlupakan ketika Panel baru ditambahkan.
Panel yang mengizinkan user tidak dapat mengoverride User Model yang menolak. Sebaliknya, User Model yang tidak mengimplementasikan PanelUser tidak ditolak oleh contract ini.
ResolvePanel melakukan enforcement:
abort_unless($panel->isAccessibleTo($request->user()), 403);Hasilnya adalah 403, bukan redirect. User yang sudah login tetapi ditolak bukan user yang perlu login ulang. Guest sudah ditangani lebih awal oleh middleware auth.
PanelManager::firstAccessibleTo(?Authenticatable $user): ?Panel berjalan melalui semua Panel berdasarkan urutan id menggunakan predicate yang sama. Predicate yang sama juga digunakan oleh RedirectPanelHome dan Panel switcher, sehingga Panel yang tidak dapat dimasuki user tidak pernah ditawarkan sebagai tujuan navigasi.
Ability Resource
Semua method can* pada PandaPanel\Resources\Resource mendelegasikan authorization ke Laravel Gate melalui satu jalur internal.
| Method | Signature | Ability | Subject |
|---|---|---|---|
canViewAny | static canViewAny(): bool | viewAny | class model |
canView | static canView(Model $record): bool | view | record |
canCreate | static canCreate(): bool | create | class model |
canEdit | static canEdit(Model $record): bool | update | record |
canDelete | static canDelete(Model $record): bool | delete | record |
canDeleteAny | static canDeleteAny(): bool | deleteAny | class model |
canRestore | static canRestore(Model $record): bool | restore | record |
canRestoreAny | static canRestoreAny(): bool | restoreAny | class model |
canForceDelete | static canForceDelete(Model $record): bool | forceDelete | record |
canForceDeleteAny | static canForceDeleteAny(): bool | forceDeleteAny | class model |
use App\Panels\Admin\Resources\Users\UserResource;
$this->actingAs($admin);
UserResource::canViewAny(); // true
UserResource::canDelete($member); // true
UserResource::canDelete($admin); // false — the policy refuses self-deletion2
3
4
5
6
7
Ability dengan suffix *Any digunakan oleh Bulk Action ketika belum ada record tertentu yang dapat diperiksa. Setelah itu setiap selected record tetap di-authorize satu per satu sebelum ada write apa pun, sehingga selection yang mengandung satu record terlarang tidak mengubah sebagian data.
Anda dapat mengoverride method can* jika ada business rule yang tidak tepat diletakkan di Policy:
final class UserResource extends Resource
{
public static function canCreate(): bool
{
return parent::canCreate() && User::query()->count() < 100;
}
}2
3
4
5
6
7
Jika membuat override authorization sendiri, arahkan kembali melalui authorize() daripada memanggil Gate::allows() secara langsung. Dengan begitu guarantee strict mode tetap berlaku pada ability tersebut:
protected static function authorize(string $ability, Model|string $argument): bool
{
return PolicyGate::allows($ability, $argument);
}2
3
4
Enforcement terjadi di beberapa layer:
ListRecords::render()memanggilcanViewAny()sebelum membangun screen;- Page view/create/edit memanggil ability masing-masing;
- Action endpoint mengulang authorization saat execution;
- sidebar juga mengecek authorization, tetapi hanya untuk menentukan apa yang perlu dirender.
Ability Relation
PandaPanel\Resources\RelationManager memiliki dua keluarga ability yang berbeda.
Ability yang berhubungan dengan record di-resolve pada Policy related model.
Ability yang berhubungan dengan membership relation di-resolve pada Policy owner model, dengan related record sebagai argument tambahan.
| Method | Signature | Ability | Policy |
|---|---|---|---|
canViewAny | static canViewAny(Model $owner): bool | viewAny | related |
canView | static canView(Model $owner, Model $record): bool | view | related |
canCreate | static canCreate(Model $owner): bool | create | related |
canEdit | static canEdit(Model $owner, Model $record): bool | update | related |
canDelete | static canDelete(Model $owner, Model $record): bool | delete | related |
canRestore | static canRestore(Model $owner, Model $record): bool | restore | related |
canForceDelete | static canForceDelete(Model $owner, Model $record): bool | forceDelete | related |
canAttach | static canAttach(Model $owner): bool | attachAny | owner |
canDetach | static canDetach(Model $owner, Model $record): bool | detach | owner, [$record] |
canAssociate | static canAssociate(Model $owner): bool | associateAny | owner |
canDissociate | static canDissociate(Model $owner, Model $record): bool | dissociate | owner, [$record] |
final class UserPolicy
{
public function attachAny(User $user, User $owner): bool
{
return $user->is_admin;
}
public function detach(User $user, User $owner, Role $role): bool
{
return $user->is_admin && ! $role->is_system;
}
}2
3
4
5
6
7
8
9
10
11
12
Page dan Widget
public static function canAccess(): bool // PandaPanel\Pages\Page, default true
public static function canView(): bool // PandaPanel\Widgets\Widget, default true2
use PandaPanel\Pages\Page;
final class Settings extends Page
{
public static function canAccess(): bool
{
return auth()->user()?->is_admin === true;
}
}2
3
4
5
6
7
8
9
Page::render() diawali dengan:
abort_unless(static::canAccess(), 403)Artinya route benar-benar melakukan enforcement; check tersebut bukan hanya untuk sidebar.
Pada Widget, canView() diperiksa sebelum toArray() dijalankan, sehingga Widget yang tidak boleh dilihat user tidak pernah menjalankan query-nya.
Jika sebuah concern membutuhkan redirect, bukan jawaban yes/no — misalnya password confirmation atau signed URL — gunakan Page middleware:
use Illuminate\Auth\Middleware\RequirePassword;
protected static array $middleware = [RequirePassword::class];2
3
Action
Action memiliki authorization sendiri dan tidak bergantung hanya pada Resource.
| Method | Signature | Kapan Diperiksa |
|---|---|---|
authorize | authorize(Closure $callback): static — Closure(?Model): bool | saat render dan execution |
authorizeEachUsing | authorizeEachUsing(Closure $callback): static — Closure(Model): bool | untuk setiap record pada bulk execution |
visible | visible(Closure $callback): static — Closure(?Model): bool | hanya saat render |
isAuthorizedFor | isAuthorizedFor(?Model $record): bool | |
isAuthorizedForEach | isAuthorizedForEach(Model $record): bool | |
isVisibleFor | isVisibleFor(?Model $record): bool |
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Actions\Action;
Action::make('approve')
->authorize(fn (?Model $record): bool => $record !== null && auth()->user()->can('approve', $record))
->authorizeEachUsing(fn (Model $record): bool => auth()->user()->can('approve', $record))
->visible(fn (?Model $record): bool => $record?->status === 'pending')
->action(fn (Model $record) => $record->update(['status' => 'approved']));2
3
4
5
6
7
8
visible() hanya menentukan apakah Action ditampilkan. Ia tidak berarti user memiliki permission. authorize() adalah security control-nya.
Action::toArray() mengembalikan null jika Action hidden atau unauthorized untuk record tersebut, sehingga button tidak dirender. Namun PanelActionController tetap melakukan authorization ulang saat execution karena keberadaan button di browser bukan bukti permission.
executeBulk() meng-authorize seluruh selected record sebelum menyentuh satu pun:
foreach ($records as $record) {
if (! $this->isAuthorizedForEach($record)) {
throw new HttpException(403, …);
}
}2
3
4
5
Keputusan all-or-nothing harus selesai sebelum write pertama, bukan baru diketahui di tengah bulk operation.
PolicyGate
Semua ability yang ditanyakan PandaBear melewati satu class:
use PandaPanel\Support\PolicyGate;
/**
* @param Model|class-string $subject
* @param list<mixed> $arguments
*
* @throws PandaPanel\Exceptions\PanelAuthorizationException
*/
public static function allows(string $ability, Model|string $subject, array $arguments = []): bool2
3
4
5
6
7
8
9
PolicyGate::allows('update', $record);
PolicyGate::allows('detach', $owner, [$related]);2
Resource::authorize() dan RelationManager::authorize() sama-sama mendelegasikan ke class ini. Keduanya tidak memanggil Gate::allows() secara langsung.
Dengan desain tersebut, guarantee strict mode tetap konsisten untuk setiap ability yang digunakan Panel, termasuk relation ability yang tidak memiliki method can* pada Resource.
Strict Authorization
$panel->strictAuthorization(); // off by default
$panel->hasStrictAuthorization(); // bool2
Secara default strict mode off.
Tanpa strict mode, Policy yang tidak tersedia menghasilkan 403, karena Gate::allows() menolak ketika tidak ada handler. Dari sisi runtime, hasil tersebut tidak dapat dibedakan dari Policy yang memang mengevaluasi request lalu mengembalikan false.
Dengan strict mode aktif, dua kondisi berikut melempar PandaPanel\Exceptions\PanelAuthorizationException:
| Kondisi | Factory | Pesan |
|---|---|---|
| Tidak ada Policy untuk model | missingPolicy($model, $ability) | "No policy is registered for […], so the ability […] can only ever be denied." |
| Policy tersedia tetapi method ability tidak ada | missingPolicyMethod($policy, $model, $ability) | "The policy […] does not define […]" |
Policy yang mendefinisikan before() dikecualikan karena method tersebut dapat menjawab seluruh ability.
Strict mode mengubah kondisi yang tadinya 403 menjadi 500, sehingga sengaja tidak aktif secara default.
Aktifkan di development dan test suite ketika Anda ingin lupa membuat Policy dianggap sebagai developer error, bukan terlihat seperti authorization rule yang berjalan normal.
Versi yang Lebih Tenang
Tanpa strict mode, PandaPanel\Support\MissingPolicyNotice menulis log satu kali per model ketika Resource dikeluarkan dari navigation karena model tidak memiliki Policy sama sekali:
[panel] UserResource is not in the navigation because User has no policy, so
viewAny() is denied by default. Create one with `php artisan make:policy
UserPolicy --model=User`, or say so on the resource by overriding canViewAny().2
3
Notice ini hanya muncul pada local, testing, atau ketika debug aktif.
Framework tidak menulis warning jika Policy memang tersedia dan mengembalikan false, karena itu adalah keputusan authorization yang sah, bukan kesalahan konfigurasi.
Tenancy
Tenant-scoped Panel menambahkan check ketiga melalui ResolveTenant sebelum query apa pun:
abort_if($tenant === null, 404, 'No such tenant.');
abort_unless(Tenancy::allows($user, $tenant, $panel), 403);2
public static function allows(?Authenticatable $user, Model $tenant, Panel $panel): bool
{
return $user instanceof HasPanelTenants
&& $user->canAccessPanelTenant($tenant, $panel);
}2
3
4
5
Check dilakukan langsung pada User Model di setiap request.
Authorization tidak pernah diturunkan dari getPanelTenants(). Method tersebut digunakan untuk kebutuhan display seperti tenant switcher, sedangkan jawaban security tidak boleh berubah hanya karena keputusan UI/display berubah.
User Model yang tidak mengimplementasikan HasPanelTenants ditolak untuk seluruh Tenant. Itu adalah default failure yang aman dan terlihat jelas.
Nested Resource
ResolveParentRecord me-resolve parent melalui query() milik parent Resource, kemudian meng-authorize hasilnya menggunakan canView() dari parent Resource:
$record = $parentResource::query()->find($key);
if ($record === null || ! $parentResource::canView($record)) {
return null; // → 404
}2
3
4
5
Tanpa rule tersebut, route seperti /users/9/posts dapat menjadi cara membaca child milik user 9 walaupun /users/9 sendiri tidak boleh diakses.
Catatan
- Visibility navigation bukan access control. Sidebar memanggil
canViewAny()dancanAccess()hanya untuk memutuskan apa yang digambar. Route, Action, Page, dan Widget tetap melakukan authorization masing-masing. Item yang hidden tetapi diakses langsung melalui URL tetap ditolak oleh route. - Setiap lookup record melewati
Resource::query(). Key di luar scope query akan dianggap tidak ditemukan, bukan me-resolve record dari scope lain. Dengan demikian tenant scope atau permission scope ikut mempersempit authorization dan listing. - Action di-resolve dari schema yang mendeklarasikannya. Table Action dicari di Table Schema, Infolist Action di Infolist. Action yang tidak pernah dideklarasikan Resource tidak dianggap ada hanya karena request menyebut namanya.
- Resource yang hilang dari sidebar memiliki empat penyebab yang terlihat hampir sama: Policy tidak ada, Policy menolak, Resource terdaftar di Panel lain, atau manifest stale.
strictAuthorization()menghilangkan ambiguitas pertama;panel:clearmenghilangkan kemungkinan terakhir. - Middleware
verifiedyang ditambahkan melaluiauth()tidak melakukan apa pun sampai User Model mengimplementasikanMustVerifyEmail. Middleware tetap terpasang pada route meskipun contract tersebut tidak digunakan.