HasPanelTenants
PandaPanel\Contracts\HasPanelTenants adalah sisi tenancy yang diimplementasikan pada model user: tenant mana saja yang boleh dimasuki user ini, dan apakah user tersebut boleh memasuki tenant tertentu. Contract ini wajib untuk setiap panel yang mendeklarasikan tenant() — tanpa contract ini, dari sudut pandang panel user dianggap tidak menjadi anggota tenant mana pun dan setiap request akan ditolak. Implementasikan contract ini pada model yang digunakan panel untuk autentikasi.
Mengimplementasikannya
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use PandaPanel\Contracts\HasPanelTenants;
use PandaPanel\Core\Panel;
final class User extends Authenticatable implements HasPanelTenants
{
/** @return BelongsToMany<Workspace, $this> */
public function workspaces(): BelongsToMany
{
return $this->belongsToMany(Workspace::class);
}
/** @return Collection<int, Model> */
public function getPanelTenants(Panel $panel): Collection
{
return $this->workspaces()->orderBy('id')->get();
}
public function canAccessPanelTenant(Model $tenant, Panel $panel): bool
{
return $this->workspaces()->whereKey($tenant->getKey())->exists();
}
}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
Itulah seluruh contract-nya. Hanya dua method, dan keduanya menerima instance panel sehingga satu model dapat memberikan jawaban berbeda untuk admin panel dan customer panel.
Interface
namespace PandaPanel\Contracts;
interface HasPanelTenants
{
/** @return Collection<int, Model> */
public function getPanelTenants(Panel $panel): Collection;
public function canAccessPanelTenant(Model $tenant, Panel $panel): bool;
}2
3
4
5
6
7
8
9
| Method | Signature | Dipanggil oleh | Kapan dipanggil |
|---|---|---|---|
getPanelTenants | getPanelTenants(Panel $panel): Illuminate\Database\Eloquent\Collection | Tenancy::availableTo() | Saat membangun daftar switcher, satu kali per render panel |
canAccessPanelTenant | canAccessPanelTenant(Model $tenant, Panel $panel): bool | Tenancy::allows() | Pada setiap request di ResolveTenant, sebelum query apa pun dijalankan |
getPanelTenants()
Mengembalikan semua tenant yang boleh diakses user melalui panel ini. Digunakan untuk membangun switcher dan memilih tenant default ketika request tidak menyebutkan tenant.
public function getPanelTenants(Panel $panel): Collection
{
// A panel may deserve a different list.
if ($panel->getId() === 'support') {
return Workspace::query()->where('supported', true)->get();
}
return $this->workspaces()->orderBy('name')->get();
}2
3
4
5
6
7
8
9
Collection kosong adalah hasil yang valid. User yang tidak memiliki tenant apa pun akan mendapatkan panel yang menolak akses, bukan panel yang justru menampilkan semua data.
Nilainya dapat dibaca kembali melalui Tenancy:
use PandaPanel\Tenancy\Tenancy;
/** @var list<Illuminate\Database\Eloquent\Model> $tenants */
$tenants = Tenancy::availableTo($user, panel('app'));2
3
4
public static function availableTo(?Authenticatable $user, Panel $panel): array
{
if (! $user instanceof HasPanelTenants) {
return [];
}
return array_values($user->getPanelTenants($panel)->all());
}2
3
4
5
6
7
8
Perhatikan perubahan bentuk datanya: contract mengembalikan Eloquent Collection, sedangkan availableTo() mengembalikan list<Model> biasa. Model user yang tidak mengimplementasikan contract — misalnya guest atau aplikasi yang belum memasangnya — mendapatkan daftar kosong, bukan error, karena switcher adalah kebutuhan tampilan.
canAccessPanelTenant()
Menentukan apakah user boleh memasuki tenant tertentu melalui panel ini. Method ini dipanggil langsung pada setiap request dan tidak pernah diturunkan dari getPanelTenants().
use PandaPanel\Tenancy\Tenancy;
Tenancy::allows($user, $workspace, panel('app')); // bool2
3
public static function allows(?Authenticatable $user, Model $tenant, Panel $panel): bool
{
return $user instanceof HasPanelTenants
&& $user->canAccessPanelTenant($tenant, $panel);
}2
3
4
5
Pemisahan dua method tersebut memang disengaja. getPanelTenants() dibuat untuk kebutuhan dropdown sehingga daftarnya dapat diurutkan, dipangkas, atau dipaginasi sesuai kebutuhan UI; keputusan keamanan tidak boleh berubah hanya karena keputusan tampilan berubah. Jika user ditawarkan tiga tenant tetapi hanya diizinkan masuk ke dua tenant, berarti ada bug pada model — tepat di wilayah yang tidak dapat diperbaiki framework secara otomatis. Karena itu keduanya selalu ditanyakan secara terpisah dan salah satunya tidak diturunkan dari yang lain.
Buat pemeriksaan akses menjadi satu indexed query yang murah. Method ini berjalan pada setiap request di panel:
public function canAccessPanelTenant(Model $tenant, Panel $panel): bool
{
return $this->workspaces()->whereKey($tenant->getKey())->exists();
}2
3
4
Bentuk penolakannya
abort_unless(Tenancy::allows($user, $tenant, $panel), 403);| Kondisi | Hasil |
|---|---|
| Model user mengimplementasikan contract dan mengizinkan | request diteruskan |
| Model user mengimplementasikan contract dan menolak | 403 |
| Model user tidak mengimplementasikan contract | 403 untuk setiap tenant |
Tidak ada user terautentikasi ($user === null) | 403 |
Status yang dipakai adalah 403, bukan 404, karena request sudah menunjuk tenant yang memang ada. Menyembunyikan keberadaan tenant dari seseorang yang sudah dapat menyebutkan identifier-nya hanya menghasilkan security theatre dan mengorbankan pesan error yang mudah dipahami.
Baris terakhir perlu ditekankan: tenant-scoped panel yang model user-nya tidak mengimplementasikan HasPanelTenants akan menolak setiap request. Ini adalah kegagalan yang benar dan sengaja dibuat jelas — alternatifnya, membiarkan panel terbuka, justru merupakan kebocoran yang hendak dicegah oleh mekanisme ini.
Lebih dari sekadar membership
Contract mengembalikan bool, sehingga aturan apa pun yang dapat diekspresikan sebagai ya/tidak cocok ditempatkan di sini — misalnya membership yang ditangguhkan, invitation yang kedaluwarsa, atau role yang hanya berlaku pada tenant tertentu:
public function canAccessPanelTenant(Model $tenant, Panel $panel): bool
{
$membership = $this->workspaces()
->whereKey($tenant->getKey())
->first()
?->pivot;
return $membership !== null
&& $membership->suspended_at === null;
}2
3
4
5
6
7
8
9
10
Yang tidak cocok ditempatkan di sini adalah kondisi yang seharusnya melakukan redirect, bukan menolak. canAccessPanelTenant() hanya menjawab ya atau tidak dan penolakannya menghasilkan 403; contoh seperti trial yang kedaluwarsa dan harus mengarahkan user ke halaman billing sebaiknya dibuat sebagai middleware dalam stack panel, dengan pola serupa PandaPanel\Http\Middleware\RequireTwoFactor.
Mengujinya
use PandaPanel\Tenancy\Tenancy;
it('refuses a tenant this user does not belong to', function (): void {
$this->actingAs($user)
->get('/app/documents?workspace='.$otherWorkspace->getKey())
->assertForbidden();
});
it('refuses a user model that does not know about tenants at all', function (): void {
$this->actingAs(User::factory()->create()); // no HasPanelTenants
$this->get('/app/documents?workspace='.$workspace->getKey())
->assertForbidden();
});
it('offers only the tenants the user belongs to', function (): void {
expect(Tenancy::availableTo($user, panel('app')))->toHaveCount(1);
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Catatan
- Return type-nya adalah
Collectionmilik Eloquent, bukanSupport\Collection. Interface secara eksplisit mendeklarasikanIlluminate\Database\Eloquent\Collection<int, Model>. - Kedua method menerima
Panel. Model dapat memberikan jawaban berbeda untuk setiap panel tanpa membutuhkan registry tambahan — berguna ketika support panel boleh melihat semua tenant, sedangkan customer panel hanya melihat tenant milik user. getPanelTenants()dipanggil di dalam shared prop closure, sehingga halaman panel yang tidak pernah merender switcher tidak akan menjalankan query di baliknya. Tetap sebaiknya tambahkanorderBy, karena daftar dirender sesuai urutan data yang diterima.- Jangan mengimplementasikan
canAccessPanelTenant()sebagai$this->getPanelTenants($panel)->contains($tenant). Pendekatan itu tampak bekerja sampai daftar untuk dropdown dipangkas, lalu tanpa disadari batas keamanan ikut mengecil. - Contract ini berbeda dengan
PanelUser::canAccessPanel(). Method tersebut menentukan apakah user boleh membuka panel sama sekali;HasPanelTenantsmenentukan tenant mana yang boleh diakses di dalam panel. Keduanya diperiksa secara berurutan — lihat Authorization.