## Summary - introduce a shared tenant-owned query and record-resolution canon for first-slice Filament resources - harden direct views, row actions, bulk actions, relation managers, and workspace-admin canonical viewers against wrong-tenant access - add registry-backed rollout metadata, search posture handling, architectural guards, and focused Pest coverage for scope parity and 404/403 semantics ## Included - Spec 150 package under `specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/` - shared support classes: `TenantOwnedModelFamilies`, `TenantOwnedQueryScope`, `TenantOwnedRecordResolver` - shared Filament concern: `InteractsWithTenantOwnedRecords` - resource/page/policy hardening across findings, policies, policy versions, backup schedules, backup sets, restore runs, inventory items, and Entra groups - additional regression coverage for canonical tenant state, wrong-tenant record resolution, relation-manager congruence, and action-surface guardrails ## Validation - `vendor/bin/sail artisan test --compact` passed - full suite result: `2733 passed, 8 skipped` - formatting applied with `vendor/bin/sail bin pint --dirty --format agent` ## Notes - Livewire v4.0+ compliant via existing Filament v5 stack - provider registration remains in `bootstrap/providers.php` - globally searchable first-slice posture: Entra groups scoped; policies and policy versions explicitly disabled - destructive actions continue to use confirmation and policy authorization - no new Filament assets added; existing deployment flow remains unchanged, including `php artisan filament:assets` when registered assets are used Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #180
247 lines
9.1 KiB
PHP
247 lines
9.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Filament\Resources\BackupScheduleResource;
|
|
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
|
|
use App\Filament\Resources\BackupSetResource;
|
|
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
|
|
use App\Filament\Resources\EntraGroupResource;
|
|
use App\Filament\Resources\EntraGroupResource\Pages\ListEntraGroups;
|
|
use App\Filament\Resources\FindingResource;
|
|
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
|
use App\Filament\Resources\InventoryItemResource;
|
|
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
|
|
use App\Filament\Resources\PolicyResource;
|
|
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
|
use App\Filament\Resources\PolicyVersionResource;
|
|
use App\Filament\Resources\PolicyVersionResource\Pages\ListPolicyVersions;
|
|
use App\Filament\Resources\RestoreRunResource;
|
|
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
|
|
use App\Models\BackupSchedule;
|
|
use App\Models\BackupSet;
|
|
use App\Models\EntraGroup;
|
|
use App\Models\Finding;
|
|
use App\Models\InventoryItem;
|
|
use App\Models\Policy;
|
|
use App\Models\PolicyVersion;
|
|
use App\Models\RestoreRun;
|
|
use App\Models\Tenant;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use Filament\Facades\Filament;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
function tenantOwnedAdminSession(Tenant $tenant): array
|
|
{
|
|
return [
|
|
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
|
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
|
(string) $tenant->workspace_id => (int) $tenant->getKey(),
|
|
],
|
|
];
|
|
}
|
|
|
|
dataset('tenant-owned-list-pages', [
|
|
'policy list' => [
|
|
ListPolicies::class,
|
|
static fn (Tenant $tenant, string $label): Policy => Policy::factory()->for($tenant)->create([
|
|
'display_name' => $label,
|
|
]),
|
|
],
|
|
'policy-version list' => [
|
|
ListPolicyVersions::class,
|
|
static function (Tenant $tenant, string $label): PolicyVersion {
|
|
$policy = Policy::factory()->for($tenant)->create([
|
|
'display_name' => $label.' policy',
|
|
]);
|
|
|
|
return PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
|
'version_number' => random_int(1, 9999),
|
|
]);
|
|
},
|
|
],
|
|
'backup-schedule list' => [
|
|
ListBackupSchedules::class,
|
|
static function (Tenant $tenant, string $label): BackupSchedule {
|
|
return BackupSchedule::create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'name' => $label,
|
|
'is_enabled' => true,
|
|
'timezone' => 'UTC',
|
|
'frequency' => 'daily',
|
|
'time_of_day' => '10:00:00',
|
|
'days_of_week' => null,
|
|
'policy_types' => ['deviceConfiguration'],
|
|
'include_foundations' => true,
|
|
'retention_keep_last' => 30,
|
|
'next_run_at' => now()->addHour(),
|
|
]);
|
|
},
|
|
],
|
|
'backup-set list' => [
|
|
ListBackupSets::class,
|
|
static fn (Tenant $tenant, string $label): BackupSet => BackupSet::factory()->for($tenant)->create([
|
|
'name' => $label,
|
|
]),
|
|
],
|
|
'restore-run list' => [
|
|
ListRestoreRuns::class,
|
|
static function (Tenant $tenant, string $label): RestoreRun {
|
|
$backupSet = BackupSet::factory()->for($tenant)->create([
|
|
'name' => $label.' backup set',
|
|
]);
|
|
|
|
return RestoreRun::factory()->for($tenant)->for($backupSet)->create();
|
|
},
|
|
],
|
|
'inventory-item list' => [
|
|
ListInventoryItems::class,
|
|
static fn (Tenant $tenant, string $label): InventoryItem => InventoryItem::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'display_name' => $label,
|
|
]),
|
|
],
|
|
'finding list' => [
|
|
ListFindings::class,
|
|
static fn (Tenant $tenant, string $label): Finding => Finding::factory()->for($tenant)->create(),
|
|
],
|
|
'entra-group list' => [
|
|
ListEntraGroups::class,
|
|
static fn (Tenant $tenant, string $label): EntraGroup => EntraGroup::factory()->for($tenant)->create([
|
|
'display_name' => $label,
|
|
]),
|
|
],
|
|
]);
|
|
|
|
dataset('tenant-owned-detail-pages', [
|
|
'policy view' => [
|
|
PolicyResource::class,
|
|
'view',
|
|
static fn (Tenant $tenant, string $label): Policy => Policy::factory()->for($tenant)->create([
|
|
'display_name' => $label,
|
|
]),
|
|
],
|
|
'policy-version view' => [
|
|
PolicyVersionResource::class,
|
|
'view',
|
|
static function (Tenant $tenant, string $label): PolicyVersion {
|
|
$policy = Policy::factory()->for($tenant)->create([
|
|
'display_name' => $label.' policy',
|
|
]);
|
|
|
|
return PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
|
'version_number' => random_int(1, 9999),
|
|
]);
|
|
},
|
|
],
|
|
'backup-set view' => [
|
|
BackupSetResource::class,
|
|
'view',
|
|
static fn (Tenant $tenant, string $label): BackupSet => BackupSet::factory()->for($tenant)->create([
|
|
'name' => $label,
|
|
]),
|
|
],
|
|
'restore-run view' => [
|
|
RestoreRunResource::class,
|
|
'view',
|
|
static function (Tenant $tenant, string $label): RestoreRun {
|
|
$backupSet = BackupSet::factory()->for($tenant)->create([
|
|
'name' => $label.' backup set',
|
|
]);
|
|
|
|
return RestoreRun::factory()->for($tenant)->for($backupSet)->create();
|
|
},
|
|
],
|
|
'inventory-item view' => [
|
|
InventoryItemResource::class,
|
|
'view',
|
|
static fn (Tenant $tenant, string $label): InventoryItem => InventoryItem::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'display_name' => $label,
|
|
]),
|
|
],
|
|
'finding view' => [
|
|
FindingResource::class,
|
|
'view',
|
|
static fn (Tenant $tenant, string $label): Finding => Finding::factory()->for($tenant)->create(),
|
|
],
|
|
'entra-group view' => [
|
|
EntraGroupResource::class,
|
|
'view',
|
|
static fn (Tenant $tenant, string $label): EntraGroup => EntraGroup::factory()->for($tenant)->create([
|
|
'display_name' => $label,
|
|
]),
|
|
],
|
|
'backup-schedule edit' => [
|
|
BackupScheduleResource::class,
|
|
'edit',
|
|
static function (Tenant $tenant, string $label): BackupSchedule {
|
|
return BackupSchedule::create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'name' => $label,
|
|
'is_enabled' => true,
|
|
'timezone' => 'UTC',
|
|
'frequency' => 'daily',
|
|
'time_of_day' => '10:00:00',
|
|
'days_of_week' => null,
|
|
'policy_types' => ['deviceConfiguration'],
|
|
'include_foundations' => true,
|
|
'retention_keep_last' => 30,
|
|
'next_run_at' => now()->addHour(),
|
|
]);
|
|
},
|
|
],
|
|
]);
|
|
|
|
it('scopes covered tenant-owned admin lists to the remembered canonical tenant', function (string $pageClass, Closure $makeRecord): void {
|
|
$tenantA = Tenant::factory()->create();
|
|
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
|
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
|
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
|
|
|
$allowed = $makeRecord($tenantA, 'Allowed record');
|
|
$blocked = $makeRecord($tenantB, 'Blocked record');
|
|
|
|
$this->actingAs($user);
|
|
Filament::setCurrentPanel('admin');
|
|
Filament::setTenant(null, true);
|
|
Filament::bootCurrentPanel();
|
|
|
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
|
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
|
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
|
]);
|
|
|
|
Livewire::actingAs($user)->test($pageClass)
|
|
->assertCanSeeTableRecords([$allowed])
|
|
->assertCanNotSeeTableRecords([$blocked]);
|
|
})->with('tenant-owned-list-pages');
|
|
|
|
it('returns not found for covered tenant-owned admin detail pages outside the remembered canonical tenant', function (string $resourceClass, string $page, Closure $makeRecord): void {
|
|
$tenantA = Tenant::factory()->create();
|
|
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
|
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
|
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
|
|
|
$allowed = $makeRecord($tenantA, 'Allowed record');
|
|
$blocked = $makeRecord($tenantB, 'Blocked record');
|
|
|
|
$this->actingAs($user);
|
|
Filament::setCurrentPanel('admin');
|
|
Filament::setTenant(null, true);
|
|
Filament::bootCurrentPanel();
|
|
|
|
$session = tenantOwnedAdminSession($tenantA);
|
|
|
|
$this->withSession($session)
|
|
->get($resourceClass::getUrl($page, ['record' => $allowed], panel: 'admin'))
|
|
->assertSuccessful();
|
|
|
|
$this->withSession($session)
|
|
->get($resourceClass::getUrl($page, ['record' => $blocked], panel: 'admin'))
|
|
->assertNotFound();
|
|
})->with('tenant-owned-detail-pages');
|