Kebocoran tenant scope
Query yang lolos dari tenant scope tetap dapat mengembalikan row, merender table, dan menjawab 200. Tidak ada yang terlihat salah di layar meskipun where tidak pernah diterapkan. Itulah sebabnya kegagalan ini perlu dibahas tersendiri: bug lain biasanya memberikan gejala yang jelas. Gunakan halaman ini ketika satu tenant dapat melihat record tenant lain, count terlihat terlalu besar, atau PanelRegistrationException mengatakan tidak ada tenant yang ter-bind dan Anda perlu memastikan apakah itu bug atau justru guard yang bekerja.
Buktikan masalahnya sebelum mengubah apa pun
Gunakan dua tenant, satu resource, dan bandingkan hasilnya. PandaPanel\Tenancy\Tenancy::for() melakukan bind tenant, menjalankan callback, lalu mengembalikan binding sebelumnya. Pola ini dapat digunakan dari tinker, test, maupun console command:
use App\Panels\App\Resources\Documents\DocumentResource;
use PandaPanel\Core\PanelManager;
use PandaPanel\Tenancy\Tenancy;
// A resource reads its scope through the current panel, so bind one first.
app(PanelManager::class)->setCurrentPanel(app(PanelManager::class)->get('app'));
Tenancy::for($acme, fn () => DocumentResource::query()->pluck('title')->all());
// ['Acme plan', 'Acme notes']
Tenancy::for($beta, fn () => DocumentResource::query()->pluck('title')->all());
// ['Beta secrets']2
3
4
5
6
7
8
9
10
11
12
Jika kedua list identik, ada kebocoran scope. Dalam bentuk assertion:
expect(Tenancy::for($acme, fn () => DocumentResource::query()->pluck('id')->all()))
->not->toEqual(Tenancy::for($beta, fn () => DocumentResource::query()->pluck('id')->all()));2
Tiga kondisi dan urutan kegagalannya
Resource::applyTenantScope() adalah seluruh mekanisme tenant scoping dan memeriksa tiga hal:
protected static function applyTenantScope(Builder $query): Builder
{
$panel = panel();
if ($panel === null || ! $panel->hasTenancy()) {
return $query; // 1. no panel, or the panel is not tenant-scoped
}
$relationship = static::tenantRelationship();
if ($relationship === null) {
return $query; // 2. this resource opted out
}
// …validation of the relationship…
$tenant = Tenancy::require(); // 3. throws rather than running unscoped
return $query->whereHas(
$relationship,
static fn (Builder $related): Builder => $related->whereKey($tenant->getKey()),
);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| Kondisi | Jika tidak terpenuhi | Hasil |
|---|---|---|
| Panel menggunakan tenancy | tidak ada current panel, atau hasTenancy() false | query dikembalikan tanpa perubahan |
| Resource mendeklarasikan relationship | tenantRelationship() bernilai null | query dikembalikan tanpa perubahan |
| Ada tenant yang ter-bind | tidak ada binding | PanelRegistrationException |
Dua kondisi pertama mengembalikan query apa adanya—semua kebocoran scope berasal dari area ini. Kondisi ketiga melempar exception—dan biasanya memunculkan keluhan "sekarang tidak bekerja". Exception tersebut merupakan desain keamanan, bukan bug.
1. Resource tidak memiliki $tenantRelationship
Ini adalah penyebab kebocoran paling umum dan merupakan satu-satunya opt-in yang diperlukan. Menentukan relationship adalah seluruh mekanismenya; framework tidak memiliki convention otomatis terhadap column tenant_id dan tidak memasang global scope apa pun.
use PandaPanel\Resources\Resource;
final class DocumentResource extends Resource
{
protected static string $model = Document::class;
/** The relationship on Document that leads to the tenant. */
protected static ?string $tenantRelationship = 'workspace';
}2
3
4
5
6
7
8
9
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class Document extends Model
{
/** @return BelongsTo<Workspace, $this> */
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
}2
3
4
5
6
7
8
9
10
| Member | Signature | Default |
|---|---|---|
$tenantRelationship | protected static ?string $tenantRelationship | null |
tenantRelationship | public static function tenantRelationship(): ?string | mengembalikan property tersebut |
applyTenantScope | protected static function applyTenantScope(Builder $query): Builder | dipanggil oleh query() |
Override method jika jawabannya bergantung pada sesuatu yang tidak dapat direpresentasikan oleh property:
public static function tenantRelationship(): ?string
{
return panel()?->getId() === 'app' ? 'workspace' : null;
}2
3
4
Audit seluruh resource panel sekaligus agar lebih cepat daripada membuka satu per satu:
use PandaPanel\Facades\PandaPanel;
collect(PandaPanel::resources('app')->all())
->mapWithKeys(fn (string $resource): array => [$resource => $resource::tenantRelationship()])
->filter(fn (?string $relation): bool => $relation === null)
->keys();
// every resource in the panel that is not scoped — each one had better be deliberate2
3
4
5
6
7
null hanya merupakan jawaban yang disengaja pada dua situasi: arsitektur database-per-tenant, ketika connection menjadi boundary dan tidak ada foreign key untuk discoping, serta table global seperti plan, country, atau feature flag yang memang dibaca seluruh tenant dengan cara sama. Sebaiknya tulis komentar pada kedua kasus tersebut agar opt-out tidak terlihat seperti kelalaian.
Scope dibangun dengan whereHas, sehingga definisi relationship menentukan arti "milik tenant ini". belongsTo, belongsToMany, dan hasOneThrough dapat digunakan. Related query dibatasi dengan whereKey($tenant->getKey())—primary key model tenant, bukan PanelTenant::getTenantKey(). Tenant yang diidentifikasi melalui slug pada URL tetap dijoin menggunakan id-nya.
2. query() dioverride tanpa parent::query()
Gejala. Hanya satu resource yang bocor, sementara resource lain aman.
Penyebab. Resource::query() merupakan satu-satunya funnel. Override yang membangun builder baru akan sekaligus kehilangan tenant scope, narrowing per-panel, dan eager load, tanpa menghasilkan warning:
// Wrong: three things silently gone.
public static function query(): Builder
{
return Document::query()->where('is_archived', false);
}2
3
4
5
// Right.
use Illuminate\Database\Eloquent\Builder;
public static function query(): Builder
{
return parent::query()->where('is_archived', false);
}2
3
4
5
6
7
Jika relationship bukan keseluruhan aturan—misalnya ada flag "shared with everyone" atau Anda harus scope berdasarkan column langsung—tambahkan narrowing di atas query bawaan, bukan menggantikannya:
use Illuminate\Database\Eloquent\Builder;
use PandaPanel\Tenancy\Tenancy;
public static function query(): Builder
{
return parent::query()
->where(static fn (Builder $q) => $q
->where('workspace_id', Tenancy::key())
->orWhere('is_public', true));
}2
3
4
5
6
7
8
9
10
Tenancy::key() mengembalikan getTenantKey() jika tenant mengimplementasikan PanelTenant. Jika key tersebut adalah slug sedangkan foreign key Anda berupa id, gunakan Tenancy::require()->getKey().
Temukan seluruh override:
grep -rn "function query" app/Panels --include=*.php3. Tidak ada current panel sehingga scope tidak pernah diterapkan
Ini adalah kegagalan paling senyap karena query tetap berjalan tanpa exception. applyTenantScope() membaca panel(), dan di luar request tidak ada current panel kecuali Anda mengaturnya secara eksplisit.
panel(); // null in a console command, a job, or a test that never bound one
DocumentResource::query(); // unscoped: no panel, so the scope returned early2
Perbaikan. Set panel dan tenant, selalu dalam urutan tersebut:
use PandaPanel\Core\PanelManager;
use PandaPanel\Tenancy\Tenancy;
public function handle(PanelManager $manager): void
{
$manager->setCurrentPanel($manager->get('app'));
Tenancy::for($workspace, static fn () => DocumentResource::query()->count());
}2
3
4
5
6
7
8
9
Dengan panel ter-set tetapi tenant belum ter-bind, query melempar exception. Dengan keduanya tidak ada, query berjalan dan mengembalikan record seluruh tenant. Kegagalan yang keras justru yang diinginkan; menetapkan panel mengubah kebocoran senyap menjadi failure yang dapat didiagnosis.
| Member | Signature |
|---|---|
PanelManager::setCurrentPanel | setCurrentPanel(?Panel $panel): void |
PanelManager::currentPanel | currentPanel(): ?Panel |
Panel::hasTenancy | hasTenancy(): bool |
panel() | panel(?string $id = null): ?Panel — helper yang dibaca applyTenantScope() |
4. Console command, queued job, dan scheduler
Queued job berjalan di luar request yang melakukan dispatch dan tenant binding tidak ikut terbawa. Tenancy menyimpan tenant di PandaPanel\Support\PanelContext, sebuah scoped() container binding, sementara worker Laravel memanggil forgetScopedInstances() di antara job. Artinya setiap job selalu dimulai tanpa tenant binding. Ini adalah perilaku yang benar dan alasan memasuki tenant harus eksplisit.
use PandaPanel\Tenancy\Tenancy;
final class RebuildWorkspaceIndex implements ShouldQueue
{
use Queueable;
public function __construct(private readonly int $workspaceKey) {}
public function handle(PanelManager $manager): void
{
$manager->setCurrentPanel($manager->get('app'));
$workspace = Workspace::query()->findOrFail($this->workspaceKey);
Tenancy::for($workspace, function (): void {
// Everything in here reads through the bound tenant.
});
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Bawa key, bukan model. Model yang diserialisasi akan di-reload di sisi worker menggunakan connection yang sedang aktif; pada arsitektur database-per-tenant connection tersebut belum tentu connection tempat model awal berasal.
Untuk melakukan loop semua tenant dari command:
Workspace::query()->each(function (Workspace $workspace): void {
Tenancy::for($workspace, function () use ($workspace): void {
$this->line($workspace->name.': '.DocumentResource::query()->count());
});
});2
3
4
5
each() sengaja berada di luar Tenancy::for(): daftar tenant adalah central question. Mengambil daftar tersebut dari dalam tenant scope justru melintasi boundary yang hendak dijaga desain ini.
Job bawaan framework melakukan bind panel tetapi bukan tenant
| Job | Set panel | Bind tenant |
|---|---|---|
PandaPanel\Jobs\RunPanelExport | ya | tidak |
PandaPanel\Jobs\RunPanelImport | ya | tidak |
PandaPanel\Jobs\SendPanelIntegration | tidak | tidak |
Pada tenant panel single-database dengan scoped resource, queued export mencapai Tenancy::require() tanpa tenant binding dan gagal dengan jelas, alih-alih menghasilkan file berisi data seluruh tenant. Ada tiga solusi, dalam urutan yang disarankan.
Jangan queue pekerjaan tersebut. queueAfter() bernilai negatif menjalankan proses di request berapa pun jumlah row:
final class DocumentExporter extends Exporter
{
public static function queueAfter(): int
{
return -1;
}
}2
3
4
5
6
7
Exporter::queueAfter() default 2000 dan Importer::queueAfter() default 500; keduanya masuk queue ketika jumlah data melebihi nilai tersebut, sedangkan 0 berarti selalu queue.
Dispatch job Anda sendiri yang membawa tenant key dan menjalankan pekerjaan di dalam Tenancy::for(), menggunakan ExportRun / ImportRun secara langsung.
Pulihkan binding melalui queue hook yang Anda miliki, jika infrastructure Anda memang sudah membawa tenant id di setiap job.
Pada arsitektur database-per-tenant, situasinya berbeda: QueueTenancyBootstrapper milik stancl/tenancy memulihkan connection di sekitar job yang didispatch dalam tenant context, dan resource tidak memiliki relationship tenant karena connection merupakan boundary.
5. Tenancy::require() melempar exception — apakah itu bug?
Tidak. Itu adalah guard yang bekerja.
PandaPanel\Exceptions\PanelRegistrationException
This panel is tenant-scoped, but no tenant is bound to this request. Routes registered by the
panel resolve one through ResolveTenant; a route registered by hand has to include that
middleware, and console or queue work has to enter a tenant with Tenancy::for().2
3
4
5
Jika resource yang menyatakan dirinya tenant-scoped dibiarkan berjalan tanpa scope karena tenant tidak ter-bind, hasilnya adalah seluruh record milik semua tenant—kegagalan persis yang ingin dicegah dan terlihat seperti halaman yang normal. Tiga penyebab paling umum:
| Penyebab | Perbaikan |
|---|---|
| Route diregistrasikan manual di luar group panel | tambahkan ResolveTenant::class.':'.$panel->getId() |
| Console atau queue work | Tenancy::for($tenant, …) |
| Test tidak pernah melakukan bind tenant | gunakan Tenancy::for(), atau lakukan request melalui route panel |
Tidak ada middleware alias untuk ResolveTenant; gunakan nama class dan berikan panel id, persis seperti registrar.
php artisan route:list --path=app # confirm ResolveTenant is on the routesRegistrar menambahkannya paling akhir, setelah ResolvePanel, RequireTwoFactor, dan RequireEmailCode, serta hanya untuk panel yang mendeklarasikan tenant(). Panel tanpa tenancy tidak membayar biaya tambahan dan panel dengan tenancy tidak dapat lupa memasangnya.
6. Dua kesalahan relationship yang ditangkap dengan pesan jelas
protected static ?string $tenantRelationship = 'nothing_like_this';
[DocumentResource]scopes to the tenant through[nothing_like_this], which[App\Models\Document]does not have. Name a relationship that exists, or overridequery()and scope it yourself.
protected static ?string $tenantRelationship = 'getTable';
[DocumentResource]scopes by the tenant relationship[getTable], and[App\Models\Document::getTable()]exists but does not return an Eloquent relationship. A scope or an accessor cannot be traversed to a tenant — name abelongsTo,belongsToManyorhasOneThrough, or overridequery()and scope it yourself.
Keduanya menghasilkan PandaPanel\Exceptions\PanelRegistrationException. Pemeriksaan kedua diperlukan karena method_exists() yang true belum membuktikan method tersebut sebuah relationship. Scope atau accessor dapat lolos pemeriksaan pertama lalu gagal di dalam whereHas dengan Call to a member function getRelated() on null, pesan yang tidak menjelaskan resource maupun property mana yang salah.
7. Read yang tidak pernah melewati Resource::query()
Funnel adalah jaminan scope. Karena itu read yang berada di luar funnel secara otomatis tidak mendapatkan tenant scope. Berikut tempat yang perlu diperhatikan:
| Read | Scoped? | Yang perlu dilakukan |
|---|---|---|
Select::options() | tidak | menerima array; bangun array tersebut dengan query yang sudah discoping |
Select::relationship() | tidak | query related model secara langsung; gunakan options() jika list harus discoping |
| Widget yang query model langsung | tidak | scope sendiri atau baca melalui resource |
| Query milik custom page | tidak | gunakan parent::query() pada resource atau Tenancy::key() |
| Relation manager | berdasarkan owner | owner sudah di-resolve melalui scoped query(); relationship child tetap berlaku di atasnya jika memang dideklarasikan |
| Nested resource | berdasarkan parent | sama: parent berasal dari scoped query |
| Global search | ya | dimulai dari Resource::query() |
| Export/import yang berjalan di request | ya | menggunakan query yang sama; queued run dibahas di bagian 4 |
| Action, bulk action, record lookup | ya | semuanya resolve melalui query() |
Select::options() menerima array<array-key, string>, dan form() dipanggil saat schema dibangun untuk request yang sudah memiliki tenant binding. Karena itu lakukan scoping ketika array dibuat:
use PandaPanel\Forms\Components\Select;
use PandaPanel\Tenancy\Tenancy;
Select::make('project_id')->options(
Project::query()
->where('workspace_id', Tenancy::require()->getKey())
->pluck('name', 'id')
->all(),
);2
3
4
5
6
7
8
9
relationship('project', 'name') tidak dapat discoping dengan cara ini: Select::resolveOptions() membuat query dari $related->newQuery(), yang tidak mengetahui tenant. Gunakan options() jika list harus dibatasi, atau terapkan global scope pada related model.
8. Write juga tidak discoping otomatis
applyTenantScope() hanya mempersempit read. Tidak ada mekanisme yang otomatis menulis owner column saat create—framework memanggil FormSchema::dehydrate() lalu save. Tenant id yang dapat dikirim dari form adalah tenant id yang dapat dimanipulasi. Tetapkan ownership di server, di tempat yang tidak dapat disentuh request:
use PandaPanel\Tenancy\Tenancy;
final class DocumentObserver
{
public function creating(Document $document): void
{
$document->workspace_id ??= Tenancy::require()->getKey();
}
}2
3
4
5
6
7
8
9
Jangan masukkan foreign key ke $fillable, dengan alasan yang sama seperti privilege flag tidak seharusnya fillable. Karena setiap read discoping, record yang dibuat tanpa owner langsung menjadi tidak terlihat—bug yang idealnya tertangkap di test pertama, bukan di production.
9. Tenant yang di-resolve salah, atau tidak ada
ResolveTenant melakukan tiga langkah dan seluruhnya harus berhasil sebelum query apa pun dijalankan:
$tenant = $panel->resolveTenant($request, $user);
abort_if($tenant === null, 404, 'No such tenant.');
abort_unless(Tenancy::allows($user, $tenant, $panel), 403);
Tenancy::bind($tenant);2
3
4
5
6
| Status | Arti |
|---|---|
| 404 | Tenant tidak dapat diidentifikasi—request menunjuk sesuatu yang tidak ada |
| 403 | Tenant tersedia tetapi user tidak diizinkan masuk |
404 ketika Anda mengharapkan tenant biasanya berasal dari resolver. Panel::resolveTenant() mengembalikan null untuk apa pun yang bukan instance model yang dideklarasikan. Ini disengaja: resolver yang salah dan mengembalikan user dapat membuat seluruh query discoping menggunakan user id dan sekilas terlihat bekerja.
$panel->tenant(
Workspace::class,
static fn (Request $request, ?Authenticatable $user): ?Workspace => Workspace::query()
->find($request->query('workspace')),
);2
3
4
5
403 untuk tenant yang seharusnya dimiliki user mengarah ke membership contract. Tenancy::allows() memanggil HasPanelTenants::canAccessPanelTenant() secara langsung pada setiap request—tidak mencari tenant di list switcher, karena list tersebut dibuat untuk dropdown dan bisa diurutkan, dipangkas, atau dipaginasi. Jawaban security tidak boleh berubah karena keputusan display.
User model yang tidak mengimplementasikan HasPanelTenants dianggap tidak memiliki tenant apa pun, sehingga tenant-scoped panel menolak akses alih-alih terbuka tanpa pembatasan.
API lengkap Tenancy
use PandaPanel\Tenancy\Tenancy;| Method | Signature | Catatan |
|---|---|---|
bind | static bind(Model $tenant): void | untuk ResolveTenant dan test saja |
current | static current(): ?Model | null di luar tenant |
require | static require(): Model | melempar PanelRegistrationException, bukan menurunkan jaminan scope |
key | static key(): int|string|null | value yang biasa dibutuhkan where clause |
keyOf | static keyOf(Model $tenant): int|string | getTenantKey(), atau primary key |
nameOf | static nameOf(Model $tenant): string | getTenantName(), attribute name, lalu key |
describe | static describe(Model $tenant): array{key: int|string, name: string} | representasi tenant yang dikirim ke frontend |
availableTo | static availableTo(?Authenticatable $user, Panel $panel): list<Model> | list untuk switcher |
allows | static allows(?Authenticatable $user, Model $tenant, Panel $panel): bool | access check per request |
for | static for(Model $tenant, callable $callback): mixed | bind, jalankan callback, lalu restore di finally |
for() memulihkan binding sebelumnya bahkan ketika callback melempar exception. Callback yang gagal tidak boleh meninggalkan process dalam scope tenant lain:
Tenancy::bind($acme);
try {
Tenancy::for($beta, fn () => throw new RuntimeException('nope'));
} catch (RuntimeException) {
}
Tenancy::current()?->getKey(); // still Acme2
3
4
5
6
7
8
Jangan memanggil bind() di worker tanpa restore yang sesuai; binding dapat terbawa ke job berikutnya dalam process yang sama. Gunakan for().
Member panel
| Member | Signature |
|---|---|
tenant | tenant(string $model, Closure $resolver): self — Closure(Request, ?Authenticatable): ?Model |
tenantUrlUsing | tenantUrlUsing(Closure $url): self — Closure(Model, Panel): string |
getTenantUrl | getTenantUrl(Model $tenant): ?string |
hasTenancy | hasTenancy(): bool |
getTenantModel | getTenantModel(): ?class-string<Model> |
resolveTenant | resolveTenant(Request $request, ?Authenticatable $user): ?Model |
Contract
| Contract | Method |
|---|---|
PandaPanel\Contracts\PanelTenant | getTenantKey(): int|string, getTenantName(): string |
PandaPanel\Contracts\HasPanelTenants | getPanelTenants(Panel $panel): Collection, canAccessPanelTenant(Model $tenant, Panel $panel): bool |
Menguji kebocoran scope
Test yang bernilai adalah test yang gagal ketika scope dilepas, bukan hanya test yang memastikan halaman dapat dirender.
use PandaPanel\Exceptions\PanelRegistrationException;
use PandaPanel\Tenancy\Tenancy;
it('shows one tenant\'s records and not the other\'s', function (): void {
expect(Tenancy::for($this->acme, fn (): array => DocumentResource::query()->pluck('title')->all()))
->toBe(['Acme plan', 'Acme notes'])
->and(Tenancy::for($this->beta, fn (): array => DocumentResource::query()->pluck('title')->all()))
->toBe(['Beta secrets']);
});
it('raises rather than running unscoped when no tenant is bound', function (): void {
expect(fn () => DocumentResource::query()->get())
->toThrow(PanelRegistrationException::class);
});
it('refuses a tenant this user does not belong to', function (): void {
$this->get('/app/documents?workspace='.$this->beta->getKey())->assertForbidden();
});
it('answers 404 for a tenant that is not there', function (): void {
$this->get('/app/documents?workspace=999999')->assertNotFound();
});
it('scopes nothing in a panel that declared no tenancy', function (): void {
app(PanelManager::class)->setCurrentPanel(null);
expect(DocumentResource::query()->count())->toBe(3);
});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
Test terakhir bukan kebocoran: tenancy merupakan property milik panel, sehingga resource class yang sama dapat terdaftar di tenant panel dan central admin panel—discoping di panel pertama dan global di panel kedua—tanpa mengubah class resource. Catat perilaku tersebut melalui test agar perubahan yang tidak disengaja langsung terlihat.
Catatan
- Tidak ada scoping otomatis berdasarkan
tenant_id. Tidak ada convention column dan tidak ada global scope. Relationship yang Anda deklarasikan adalah seluruh mekanismenya. parent::query()wajib dipanggil pada override. Melewatkannya menghilangkan tiga mekanisme sekaligus tanpa warning.- Record di luar tenant menghasilkan 404, bukan sekadar row yang difilter. Lookup menggunakan query yang sama, sehingga id milik tenant lain yang diketik manual tidak dapat di-resolve. Inilah alasan kebocoran tidak terlihat dari record page jika scope bekerja.
findRecord()hanya mengangkatSoftDeletingScope, tidak scope lain. Tenant scope tetap berlaku untuk trashed record, sehingga restore action tidak dapat menjangkau record tenant lain.- Scope menggunakan subquery
whereHas. Index foreign key. Pada table besar dengan tenant relationshipbelongsToMany, exists subquery adalah hal pertama yang perlu diperiksa ketika list lambat. - Central panel pada domain yang sama dengan tenant membutuhkan
Panel::domain(). Tanpanyaadmin.example.testdapat diidentifikasi sebagai tenant bernamaadmin. - Tenant tidak disimpan dalam static property.
PanelContextadalahscoped()binding danResetPanelContextberjalan pada awal setiap requestweb, sehingga state tidak bocor antar-request, antar-test, maupun dua request di Octane worker yang sama. - Switcher hanya menawarkan tenant yang dapat dimasuki user, karena list-nya berasal dari membership answer yang sama dengan access check per request; switcher tidak menawarkan tujuan yang berakhir 403.
- Panel dengan tenancy tetapi tanpa
tenantUrlUsing()tidak merender switcher. Entry tanpa tujuan URL lebih buruk daripada tidak ada switcher.
Lihat juga
- Konsep tenancy, resource scoping, tenant resolver
- Single-database tenancy, database per tenant, dengan stancl/tenancy
- Queue dan tenant context, tenancy security checklist
- Contract
PanelTenant,HasPanelTenants, tenant URL, switcher - Resource query, nested resource, konfigurasi per-panel
- Queued export, queued import
- Testing tenancy, negative security tests
- Tenancy API reference
- Response 403, route panel yang 404