Otorisasi Widget
Sebuah widget menentukan sendiri apakah pengguna saat ini boleh melihatnya dengan melakukan override pada satu static method. Gunakan mekanisme ini ketika widget menampilkan informasi yang tidak boleh dibaca oleh semua pengguna panel — misalnya pendapatan pada dashboard yang juga digunakan staf support, kedalaman queue yang hanya perlu dilihat operator, atau informasi lain yang dibatasi berdasarkan role.
Pemeriksaan dijalankan sebelum widget dibuat dan karena itu juga sebelum data(), sehingga widget yang tidak memiliki izin tidak pernah menjalankan query-nya. Urutan ini memang disengaja: widget yang hanya disembunyikan tetap akan menjalankan query dan bahkan masih dapat membocorkan informasi jumlah melalui timing.
Contoh minimal yang dapat langsung digunakan
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Widgets;
use PandaPanel\Widgets\StatsWidget;
use PandaPanel\Widgets\Support\Stat;
final class RevenueStats extends StatsWidget
{
public static function canView(): bool
{
return auth()->user()?->can('view-revenue') === true;
}
/**
* @return list<Stat>
*/
public function stats(): array
{
return [Stat::make('Revenue', 12045)->format(prefix: '£')];
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Pengguna yang tidak memiliki ability tersebut tidak melihat ruang kosong di tempat widget seharusnya berada — widget benar-benar tidak disertakan dalam response.
canView()
public static function canView(): boolMethod ini didefinisikan pada PandaPanel\Widgets\Widget dan secara default mengembalikan true. Artinya widget dapat dilihat secara default, lalu membatasi dirinya sendiri dengan melakukan override pada method ini; page tempat widget ditampilkan tetap memiliki proses otorisasinya sendiri.
Semua mekanisme yang disediakan Laravel dapat digunakan karena ini hanyalah PHP biasa yang dijalankan di dalam request:
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
// A gate or ability
public static function canView(): bool
{
return Gate::allows('view-operations');
}
// A role flag on the user model
public static function canView(): bool
{
return Auth::user()?->is_admin === true;
}
// A guest-proof check, for a widget that might be reused elsewhere
public static function canView(): bool
{
return Auth::user() instanceof User;
}
// Configuration rather than a user
public static function canView(): bool
{
return config('services.stripe.key') !== null;
}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
Method ini bersifat static dan tidak menerima argumen. Ia berjalan sebelum object widget tersedia, sehingga dapat membaca request, session, dan authenticated user, tetapi tidak dapat membaca page context maupun filter milik widget. Keputusan yang bergantung pada record dijelaskan di bagian berikutnya.
Di mana pemeriksaan dijalankan
PandaPanel\Pages\WidgetCollection memanggilnya di dua tempat, dan keduanya dilakukan sebelum proses lain berjalan:
public static function for(array $classes, ?PageContext $context = null, ?WidgetFilters $filters = null): self
{
foreach ($classes as $class) {
if (! $class::canView()) {
continue; // never constructed, never queried
}
$widget = new $class;
// ...
}
}2
3
4
5
6
7
8
9
10
11
/** @return array<string, FormSchema> */
public static function filterSchemas(array $classes): array2
filterSchemas() juga melewati widget yang ditolak. Karena itu field filter milik widget yang tidak diizinkan tidak ikut masuk ke whitelist query string — parameter milik widget yang tidak boleh dilihat pengguna akan dibuang, bukan sekadar dipersempit.
Package memastikan urutan ini melalui test, bukan sekadar asumsi. Fixture widget berikut sengaja melempar exception dari stats():
final class ForbiddenStatsWidget extends StatsWidget
{
public static function canView(): bool
{
return false;
}
public function stats(): array
{
throw new RuntimeException('Data resolved for an unauthorized widget.');
}
}2
3
4
5
6
7
8
9
10
11
12
$collection = WidgetCollection::for([ForbiddenStatsWidget::class]);
expect($collection->definitions())->toBe([])
->and($collection->deferred())->toBeNull();2
3
4
Jika exception tersebut sampai dijalankan, test akan gagal.
Apa yang tersisa dari widget yang ditolak
Tidak ada. Widget dibuang dari daftar sebelum proses serialisasi, sehingga response tidak berisi:
- definition — tidak ada id, heading, description, maupun column span;
- entry pada deferred payload
widgetData; - filter schema pada whitelist milik page;
- query pada log.
Hal yang sama berlaku untuk widget lazy: deferred loading hanya mengubah kapan data di-resolve, bukan apakah otorisasi dijalankan.
Dua lapisan yang terpisah
| Lapisan | Method | Diberlakukan oleh |
|---|---|---|
| Page | Page::canAccess() | route, melalui abort_unless(static::canAccess(), 403) di Page::render() |
| Widget | Widget::canView() | WidgetCollection::for() |
Dashboard yang menolak pengguna akan mengembalikan 403 di root panel, bukan merender shell kosong:
use PandaPanel\Pages\Dashboard;
final class FinanceDashboard extends Dashboard
{
public static function canAccess(): bool
{
return auth()->user()?->can('view-finance') === true;
}
}2
3
4
5
6
7
8
9
Sebelum kedua pemeriksaan tersebut, middleware dan access rule milik panel menentukan apakah request dapat mencapai sebuah panel page. Lihat Akses panel dan Otorisasi page.
Kedua lapisan ini sengaja dibuat independen. Widget yang memang tidak boleh dilihat tetap harus menolak melalui canView() walaupun saat ini hanya ditempatkan pada page yang juga sudah menolak pengguna tersebut — penempatan widget dapat berubah, sedangkan aturan keamanan milik widget tidak boleh bergantung pada tempat ia sedang digunakan.
Keputusan berdasarkan record dan query
canView() tidak dapat membuat keputusan jenis ini. Method tersebut berjalan sebelum withPageContext(), sehingga belum ada record maupun query yang dapat diperiksa. Ada dua pendekatan yang tepat:
Tentukan pada page. Resource page menentukan widget apa saja yang ditampilkan, sehingga Anda dapat mengembalikan daftar yang berbeda:
use PandaPanel\Resources\Pages\ListRecords;
final class ListOrders extends ListRecords
{
public function headerWidgets(): array
{
return auth()->user()?->can('view-revenue') === true
? [OrderStats::class, RevenueStats::class]
: [OrderStats::class];
}
}2
3
4
5
6
7
8
9
10
11
Tentukan di dalam data(). Widget tetap dirender, tetapi tidak mengembalikan data:
use PandaPanel\Widgets\StatsWidget;
final class RecordFinance extends StatsWidget
{
public function stats(): array
{
$record = $this->context()->record();
if ($record === null || auth()->user()?->can('view', $record) !== true) {
return [];
}
return [/* ... */];
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
Pendekatan kedua tetap menyisakan widget kosong beserta heading-nya di layar. Jika keberadaan widget itu sendiri termasuk informasi sensitif, gunakan pendekatan pertama.
Tujuan link mengotorisasi dirinya sendiri
Stat::url() hanya menghasilkan link, bukan memberikan izin:
use App\Panels\Admin\Resources\Users\UserResource;
Stat::make('Total users', User::query()->count())->url(UserResource::url());2
3
Ketika link dibuka, navigasi panel berjalan seperti biasa dan destination menjalankan otorisasinya sendiri. Karena itu widget yang boleh dilihat pengguna tetap dapat mengarah ke page yang tidak boleh mereka akses — pengguna akan mendapatkan 403 di tujuan, dan itu adalah perilaku yang benar. Jika link tersebut bahkan tidak boleh ditawarkan, lindungi stat-nya:
$stat = Stat::make('Total users', $count);
return [
UserResource::canViewAny() ? $stat->url(UserResource::url()) : $stat,
];2
3
4
5
Testing
use PandaPanel\Pages\WidgetCollection;
it('omits a widget the user may not view', function (): void {
$collection = WidgetCollection::for([
CountingStatsWidget::class,
ForbiddenStatsWidget::class,
]);
expect(array_column($collection->definitions(), 'id'))
->toBe([CountingStatsWidget::id()]);
});2
3
4
5
6
7
8
9
10
11
Untuk pengujian end-to-end, lakukan assertion terhadap props milik page:
$this->actingAs($support)->get('/admin')
->assertInertia(fn (AssertableInertia $page) => expect(
array_column($page->toArray()['props']['widgets'], 'id')
)->not->toContain('revenue-stats'));2
3
4
Hal yang perlu diperhatikan
canView()dapat dipanggil lebih dari sekali dalam satu request — sekali saat mengumpulkan filter schema dan sekali lagi saat me-resolve widget. Jaga method ini tetap ringan; jangan menaruh query di dalamnya.- Method ini dijalankan kembali pada setiap poll, sehingga widget yang mulai tidak memenuhi izin di tengah session akan hilang pada tick berikutnya, bukan terus menampilkan data yang sudah stale.
auth()->user()dapat bernilainulljika widget dirender di luar auth middleware milik panel. Gunakan?->dan bandingkan dengan=== true, seperti pada contoh.- Mengembalikan
falseberarti widget dihapus dari response; bukan dibuat disabled. Tidak ada state read-only atau abu-abu. - Otorisasi widget tidak otomatis mengamankan data di dalam widget. Widget yang boleh dilihat pengguna tetapi menjalankan query yang tidak di-scope untuk pengguna tersebut tetap merupakan kebocoran data — scope
query()dan aggregate di dalamstats()dengan benar. - Widget yang terdaftar pada sebuah panel hanya dapat diakses melalui page milik panel tersebut. Jika class yang sama didaftarkan pada dua panel, pemeriksaan dijalankan pada kedua context; buat aturan yang benar terhadap pengguna, bukan bergantung pada panel tertentu kecuali memang disengaja.