Tenancy Security Checklist
Everything on this list is a failure that looks like a working page. A tenant scope that silently did not apply returns rows, renders a table and answers 200; nothing about the screen says the where was missing. Work through this before a tenant panel goes live, and again whenever a resource, a route or a job is added to one.
The five-minute check
# 1. The tenant middleware is actually on the routes.
php artisan route:list --path=app
# 2. Nothing in the panel reads records outside Resource::query().
grep -rn "::query()" app/Panels/App --include=*.php
# 3. Every model a tenant owns has an owner column that is not fillable.
grep -rn "fillable" app/Models --include=*.php2
3
4
5
6
7
8
use PandaPanel\Tenancy\Tenancy;
// 4. Two tenants see two different sets of rows.
expect(Tenancy::for($acme, fn () => DocumentResource::query()->pluck('id')->all()))
->not->toEqual(Tenancy::for($beta, fn () => DocumentResource::query()->pluck('id')->all()));2
3
4
5
Routing and resolution
Membership
Resources
Writes
use PandaPanel\Tenancy\Tenancy;
public function creating(Document $document): void
{
$document->workspace_id ??= Tenancy::require()->getKey();
}2
3
4
5
6
Queries outside resources
Resource::query() covers the list, record lookups, actions, bulk actions, global search and exports. It covers nothing that does not go through it.
use PandaPanel\Tenancy\Tenancy;
Document::query()->where('workspace_id', Tenancy::require()->getKey());2
3
Console, queues and Octane
Shared infrastructure, database per tenant
User ids are only unique within a tenant, and several things in the framework are keyed by one.
Sessions and the browser
Error semantics
Confirm these are what you actually see, because a difference means something in the chain is not running:
| Situation | Expected |
|---|---|
| Tenant not found by the resolver | 404 No such tenant. |
| Tenant found, user not a member | 403 |
User model without HasPanelTenants | 403 |
| Scoped resource, no tenant bound | PanelRegistrationException (500) |
| Record key from another tenant | 404 |
$tenantRelationship naming nothing | PanelRegistrationException (500) |
A 403 rather than a 404 for a non-member is deliberate: hiding which tenants exist from somebody who already had to name one is security theatre that costs a comprehensible error message.
Tests worth having
it('refuses a tenant this user does not belong to', function (): void {
$this->actingAs($ada)
->get('/app/documents?workspace='.$beta->getKey())
->assertForbidden();
});
it('answers 404 for a tenant that is not there', function (): void {
$this->get('/app/documents?workspace=999999')->assertNotFound();
});
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='.$acme->getKey())->assertForbidden();
});
it('raises rather than running unscoped when no tenant is bound', function (): void {
expect(fn () => DocumentResource::query()->get())
->toThrow(PanelRegistrationException::class);
});
it('cannot reach another tenant\'s record by key', function (): void {
$this->get('/app/documents/'.$betaDocument->getKey().'?workspace='.$acme->getKey())
->assertNotFound();
});
it('offers no tenant this user does not belong to', function (): void {
$this->get('/app/documents?workspace='.$acme->getKey())
->assertInertia(fn (AssertableInertia $page) => $page->has('tenancy.available', 1));
});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
The framework's own suite carries the first four in tests/Feature/Panel/TenancyTest.php. Copy the shape, not the fixtures.
Notes
- The most common real failure is a resource that names no relationship, in a single-database panel, where the author assumed a global scope existed. There is none. The relationship you name is the entire mechanism.
- The second most common is a query that never touched a resource — a widget, a select, an endpoint of your own.
- The third is queued work, because it fails in a worker log rather than on a screen.
- Tenancy is not authorization, and neither replaces the other. Run the panel with policies in place and confirm both refuse independently; a fixture policy that also said no would make it impossible to tell a scope that worked from a gate that said no.