Respons 403
Setiap screen, action, widget, dan endpoint pada panel melakukan authorization secara independen, sehingga respons 403 selalu berasal dari salah satu dari sedikit aturan yang jelas. Halaman ini membantu Anda menemukan aturan mana yang menolak request, diurutkan berdasarkan penyebab yang paling sering terjadi. Gunakan panduan ini ketika user yang sudah login ditolak tetapi sidebar atau screen tidak menjelaskan alasannya.
Mulai dari sini
Respons 403 berasal dari salah satu dari lima layer. Periksa dalam urutan berikut melalui tinker:
use App\Models\User;
use App\Panels\Admin\Resources\Users\UserResource;
use PandaPanel\Core\PanelManager;
$user = User::query()->where('email', 'ada@example.test')->firstOrFail();
auth()->login($user);
app(PanelManager::class)->get('admin')->isAccessibleTo($user); // 1. the panel
UserResource::canViewAny(); // 2. the resource
UserResource::canEdit($record); // 3. this record2
3
4
5
6
7
8
9
10
11
| Yang ditolak | Layer | Lokasi aturan |
|---|---|---|
| Semua URL di panel, termasuk dashboard | Akses panel | Panel::canAccess() dan PanelUser::canAccessPanel() |
| Satu resource beserta seluruh route-nya | viewAny | policy model |
| Satu record | view / update / delete … | policy model |
| Satu page atau widget | Page::canAccess(), Widget::canView() | class terkait |
| Satu tombol | Action::authorize() | action terkait |
Guest tidak pernah menerima 403 ketika membuka URL panel — guest diarahkan ke halaman login. Jika guest justru melihat 403, berarti request masuk ke endpoint di luar auth stack panel; lihat Redirect login.
1. Panel menolak user
Ada dua pertanyaan independen dan keduanya harus mengizinkan akses. Panel yang menjawab "ya" tidak dapat mengabaikan user model yang menjawab "tidak".
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?->is_admin === true);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
use PandaPanel\Contracts\PanelUser;
use PandaPanel\Core\Panel;
final class User extends Authenticatable implements PanelUser
{
public function canAccessPanel(Panel $panel): bool
{
return ! $this->suspended;
}
}2
3
4
5
6
7
8
9
10
| Member | Signature | Catatan |
|---|---|---|
canAccess | Panel::canAccess(Closure $callback): self | fn (?Authenticatable $user): bool |
isAccessibleTo | Panel::isAccessibleTo(?Authenticatable $user): bool | mengecek contract terlebih dahulu, lalu closure |
canAccessPanel | PanelUser::canAccessPanel(Panel $panel): bool | berada di user model dan berlaku untuk setiap panel |
User model yang tidak mengimplementasikan contract mana pun tidak ditolak oleh aturan tambahan ini, sesuai dengan asumsi panel yang dibuat sebelum contract tersebut tersedia.
Console memberikan informasi yang sama ketika user dibuat dan menyebutkan aturan mana yang menolak:
php artisan panel:user --panel=admin WARN They cannot reach the Administrator panel yet — the panel's own canAccess() says no.Flag privilege biasanya memang tidak dibuat mass-assignable. Ini disengaja, karena is_admin yang fillable dapat diubah sendiri oleh siapa pun melalui form POST. Karena itu, memberikan privilege sebaiknya dilakukan sebagai write yang eksplisit:
$user->forceFill(['is_admin' => true])->save();2. Resource tidak memiliki policy
Ini adalah penyebab 403 yang paling sering muncul pada panel baru, dan juga yang paling sulit terlihat: resource menghilang begitu saja dari sidebar dan URL-nya ditolak.
Gate::allows() menolak ketika policy tidak tersedia. Itu adalah perilaku yang aman — default seharusnya menolak — tetapi hasilnya tidak dapat dibedakan dari policy yang memang memeriksa request lalu menolak.
php artisan make:policy ProductPolicy --model=ProductPada environment development, panel memberi tahu kondisi ini satu kali per model melalui navigation builder:
[panel] ProductResource is not in the navigation because Product has no policy, so viewAny()
is denied by default. Create one with `php artisan make:policy ProductPolicy --model=Product`,
or say so on the resource by overriding canViewAny(). Panel::strictAuthorization() turns this
into an exception everywhere the panel asks, which is worth having in development.2
3
4
Pesan ini ditulis oleh PandaPanel\Support\MissingPolicyNotice:
| Method | Signature | Catatan |
|---|---|---|
reportIfMissing | static reportIfMissing(string $resource, string $model): void | menulis log level debug, sekali per model |
forget | static forget(): void | menghapus daftar model yang sudah dilaporkan; berguna untuk test yang membangun navigation berulang kali |
expectedPolicy | static expectedPolicy(string $model): string | App\Models\Product → App\Policies\ProductPolicy |
use PandaPanel\Support\MissingPolicyNotice;
MissingPolicyNotice::expectedPolicy(App\Models\Product::class);
// 'App\Policies\ProductPolicy'2
3
4
Notice ini diam di production dan juga diam ketika policy memang ada — policy yang mempertimbangkan request lalu menjawab tidak adalah keputusan, bukan kesalahan. Notice hanya muncul ketika app()->hasDebugModeEnabled() bernilai true atau environment adalah local atau testing.
3. Ability yang diminta oleh setiap operasi panel
Setiap method can* pada PandaPanel\Resources\Resource diteruskan ke Gate melalui satu jalur yang sama, sehingga policy adalah sumber jawabannya.
| Method | Signature | Ability |
|---|---|---|
canViewAny | static canViewAny(): bool | viewAny |
canView | static canView(Model $record): bool | view |
canCreate | static canCreate(): bool | create |
canEdit | static canEdit(Model $record): bool | update |
canDelete | static canDelete(Model $record): bool | delete |
canDeleteAny | static canDeleteAny(): bool | deleteAny |
canRestore | static canRestore(Model $record): bool | restore |
canRestoreAny | static canRestoreAny(): bool | restoreAny |
canForceDelete | static canForceDelete(Model $record): bool | forceDelete |
canForceDeleteAny | static canForceDeleteAny(): bool | forceDeleteAny |
Ability *Any dipakai bulk action sebelum tersedia satu record tertentu untuk diperiksa. Setelah itu, setiap record tetap di-authorize satu per satu sebelum ada perubahan yang ditulis, sehingga selection yang berisi satu record terlarang tidak mengubah apa pun. Inilah sebabnya bulk delete dapat menghasilkan 403 walaupun setiap baris tampak editable.
Jika Anda meng-override method can*, tetap lewatkan hasilnya melalui funnel yang sama agar strict mode tetap dapat melindunginya:
use Illuminate\Database\Eloquent\Model;
public static function canDelete(Model $record): bool
{
return ! $record->is_locked && parent::canDelete($record);
}2
3
4
5
6
Relation manager memeriksa dua policy
Membaca dan menulis related record adalah ability pada policy milik record tersebut. Sementara attach dan detach adalah ability pada policy owner, karena apakah sebuah tag boleh dipasang ke post adalah keputusan bisnis milik post, bukan tag.
| Method | Signature | Ability | Ditanyakan kepada |
|---|---|---|---|
canViewAny | static canViewAny(Model $owner): bool | viewAny | related model |
canView | static canView(Model $owner, Model $record): bool | view | related record |
canCreate | static canCreate(Model $owner): bool | create | related model |
canEdit | static canEdit(Model $owner, Model $record): bool | update | related record |
canDelete | static canDelete(Model $owner, Model $record): bool | delete | related record |
canRestore | static canRestore(Model $owner, Model $record): bool | restore | related record |
canForceDelete | static canForceDelete(Model $owner, Model $record): bool | forceDelete | related record |
canAttach | static canAttach(Model $owner): bool | attachAny | owner |
canDetach | static canDetach(Model $owner, Model $record): bool | detach | owner, dengan record sebagai argumen kedua |
canAssociate | static canAssociate(Model $owner): bool | associateAny | owner |
canDissociate | static canDissociate(Model $owner, Model $record): bool | dissociate | owner, bersama record |
Karena itu, PostPolicy memerlukan attachAny(User $user, Post $post) dan detach(User $user, Post $post, Tag $tag) agar relation manager attach/detach dapat bekerja. Method yang hilang adalah penyebab umum kondisi "table tampil tetapi tombol Attach menghasilkan 403".
4. Ubah policy yang hilang menjadi exception
$panel->strictAuthorization(); // off by default
$panel->hasStrictAuthorization(); // bool2
Dalam strict mode, PandaPanel\Support\PolicyGate memastikan policy benar-benar dapat menjawab ability sebelum meminta hasilnya:
use PandaPanel\Support\PolicyGate;
PolicyGate::allows('update', $record); // ability, subject
PolicyGate::allows('detach', $post, [$tag]); // extra policy arguments2
3
4
| Method | Signature |
|---|---|
allows | static allows(string $ability, Model|string $subject, array $arguments = []): bool |
Ada dua jenis kegagalan, keduanya berupa PandaPanel\Exceptions\PanelAuthorizationException:
No policy is registered for [App\Models\Product], so the ability [viewAny] can only ever be
denied. Register one, or turn off strictAuthorization() for this panel.2
The policy [App\Policies\ProductPolicy] for [App\Models\Product] does not define [deleteAny],
so that ability can only ever be denied. Add the method, or turn off strictAuthorization()
for this panel.2
3
Policy yang mendefinisikan before() dikecualikan dari pemeriksaan method kedua karena before() memang dapat menjawab seluruh ability.
Ini dibuat sebagai satu pemeriksaan bersama, bukan pemeriksaan terpisah di setiap caller. Resource::authorize() dan RelationManager::authorize() sama-sama meneruskan ke PolicyGate, sehingga jaminan yang sama tetap berlaku untuk ability relation yang bahkan tidak memiliki method can* pada resource.
Aktifkan strict mode selama development lalu nonaktifkan di production, atau aktifkan di semua environment dan terima bahwa policy yang terlupa menghasilkan 500 alih-alih 403. Keduanya masuk akal; yang penting, policy yang tidak sengaja hilang tidak terlihat seperti aturan authorization yang sengaja bekerja.
5. Pages, widgets, dan actions
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
use PandaPanel\Widgets\StatsWidget;
final class RevenueStats extends StatsWidget
{
public static function canView(): bool
{
return auth()->user()?->can('view-revenue') === true;
}
}2
3
4
5
6
7
8
9
| Member | Signature | Default |
|---|---|---|
Page::canAccess | static canAccess(): bool | true |
Widget::canView | static canView(): bool | true |
Action::authorize | authorize(Closure $callback): static | fn (?Model $record): bool |
Action::authorizeEachUsing | authorizeEachUsing(Closure $callback): static | fn (Model $record): bool, untuk setiap record pada bulk run |
Action::isAuthorizedForEach | isAuthorizedForEach(Model $record): bool | true jika tidak ada callback per-record |
Action::visible | visible(Closure $callback): static | menyembunyikan tanpa berarti melarang |
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Actions\Action;
Action::make('approve')
->authorize(static fn (?Model $record): bool => auth()->user()?->can('approve') === true)
->authorizeEachUsing(static fn (Model $record): bool => $record->status === 'pending')
->action(static fn (Model $record) => $record->update(['status' => 'approved']));2
3
4
5
6
7
authorize() menjawab apakah action boleh dijalankan, sedangkan authorizeEachUsing() menjawab apakah setiap record boleh disentuh. Tanpa pemeriksaan kedua, bulk action hanya mewarisi pemeriksaan kolektif — padahal "boleh menjalankan action ini" tidak sama dengan "boleh menjalankannya pada record-record ini".
visible() bukan authorization. Method ini hanya menyembunyikan tombol; endpoint tetap memanggil authorize() kembali pada saat eksekusi, apa pun yang ditampilkan row.
6. Endpoint yang dapat menghasilkan 403 karena aturan khususnya sendiri
| Endpoint | Aturan |
|---|---|
panel.{id}.uploads | Form tempat field berada. page=create memeriksa create; page=edit memeriksa update pada record yang disebut; relation form memeriksa ability relation manager; form action memeriksa action terkait. page adalah allowlist — nilai yang tidak dikenal menghasilkan 422, bukan fallback ke create form. |
panel.{id}.actions.* | authorize() milik action terlebih dahulu, lalu ability record sesuai operasi yang dilakukan |
| Screen Integrations | Tiga syarat: resource mengaktifkan integrations()->isEnabled(true), user lolos viewAny milik resource, dan user lolos gate manage-panel-integrations — gate ini menolak jika belum didefinisikan, sehingga aplikasi yang belum menentukan siapa yang boleh mengelolanya berarti belum memberi izin kepada siapa pun |
| Route tenant-scoped apa pun | ResolveTenant: tenant yang tidak dapat diidentifikasi menghasilkan 404; tenant yang ada tetapi tidak boleh dimasuki user menghasilkan 403 |
panel.{id}.notifications.* | Di-scope ke row milik $request->user(), sehingga id notification milik user lain tidak cocok dengan apa pun daripada menghasilkan 403 |
use Illuminate\Support\Facades\Gate;
// AppServiceProvider::boot()
Gate::define('manage-panel-integrations', static fn ($user): bool => $user->is_admin === true);2
3
4
Menguji respons 403
Uji alasan penolakannya, bukan hanya status code. Route yang menghasilkan 403 karena masalah lain tidak membuktikan aturan yang ingin Anda test.
use PandaPanel\Exceptions\PanelAuthorizationException;
it('refuses every route of the resource to a user without the policy', function (): void {
$this->actingAs($editor)->get('/admin/users')->assertForbidden();
$this->actingAs($editor)->post('/admin/users/create')->assertForbidden();
});
it('names the missing policy under strict authorization', function (): void {
panel('admin')->strictAuthorization();
expect(fn () => ProductResource::canViewAny())
->toThrow(PanelAuthorizationException::class);
});2
3
4
5
6
7
8
9
10
11
12
13
Action helper menanyakan hal yang sama seperti row: apakah action visible dan authorized.
panelRecordActions(UserResource::class)->assertExists('edit');
panelBulkActions(UserResource::class)->assertCanNotRun('delete');
panelInfolistActions(UserResource::class)->assertVisible('impersonate', $user);
panelTableActions(UserResource::class)->call('purgeUnverified');2
3
4
call() memeriksa authorization terlebih dahulu dan menggagalkan test, bukan melewati action. Menjalankan action yang sebenarnya tidak boleh diakses user hanya akan membuktikan handler bekerja, bukan bahwa action tersebut memang dapat dijangkau secara sah.
Catatan
- Menyembunyikan navigation item bukan access control. Setiap route, action, page, dan widget melakukan authorization sendiri; sidebar hanyalah convenience layer yang dibangun dari jawaban yang sama.
- Record di luar
Resource::query()menghasilkan 404, bukan 403. Record lookup melewati query milik resource, sehingga key di luar scope tidak pernah resolve — termasuk key milik tenant lain. canViewAny()juga melindungi global search. Resource yang tidak boleh dilihat user tidak pernah di-query oleh command palette.- Policy method yang hilang menghasilkan 403 tanpa strict mode, pada semua layer. Laravel Gate menolak ability yang tidak diimplementasikan policy, dan panel tidak dapat membedakannya dari penolakan yang disengaja.
before()pada policy dapat menjawab semua ability, sehingga strict mode mengecualikannya. Konsekuensinya,before()yang mengembalikanfalsejuga menolak semuanya, termasukviewAny, sehingga resource menghilang dari sidebar tanpa log tambahan.canAccess()milik panel berjalan pada setiap request yang masuk ke panel, jadi jaga implementasinya tetap murah. Satu query per request per user berarti satu query tambahan di setiap page view.- Boot callback berjalan setelah access check. User yang ditolak panel tidak pernah memicu pekerjaan
bootUsing().