From 57f3e3934c9205b9a7e418958bfbfb7e830640a8 Mon Sep 17 00:00:00 2001 From: ahmido Date: Wed, 11 Feb 2026 21:01:23 +0000 Subject: [PATCH] 085-tenant-operate-hub (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary Implements Spec 085 “Tenant Operate Hub” semantics so central Monitoring pages are context-aware when entered from a tenant, without changing canonical URLs or implicitly mutating tenant selection. Also fixes a UX leak where tenant-scoped Inventory/Policies/Backups surfaces could appear in Admin navigation / be reachable without a selected tenant. Why Reduce “where am I / lost tenant context” confusion when operators jump between tenant work and central Monitoring. Preserve deny-as-not-found security semantics and avoid tenant identity leaks. Keep tenant-scoped data surfaces strictly tenant-scoped (not workspace-scoped). What changed Context-aware Monitoring: /admin/operations shows scope label + CTAs (“Back to ”, “Show all tenants”) when tenant context is active and entitled. /admin/operations/{run} shows deterministic back affordances + optional escape hatch (“Show all operations”) when tenant context is active and entitled. Canonical Monitoring GET routes do not mutate tenant context. Stale tenant context (not entitled) falls back to workspace scope without leaking tenant identity. Tenant navigation IA: Tenant panel sidebar provides “Monitoring” shortcuts (Runs/Alerts/Audit Log) into the central Monitoring surfaces. Tenant-scoped Admin surfaces guard: Inventory/Policies/Policy Versions/Backup Sets no longer show up tenantless; direct access redirects to /admin/choose-tenant when no tenant is selected. Tests Added/updated Pest coverage for: Spec 085 header affordances + stale-context behavior deny-as-not-found regressions for non-members/non-entitled users “DB-only render” (no outbound calls) for Monitoring pages tenant-scoped admin surfaces redirect when no tenant selected Compatibility / Constraints Filament v5 + Livewire v4 compliant (no v3 APIs). Panel providers remain registered via providers.php (Laravel 11+/12). No new assets; no changes to filament:assets deployment requirements. No global search changes. Manual verification From a tenant, click “Monitoring → Runs” and confirm: Scope label shows tenant scope “Show all tenants” clears tenant context and returns to workspace scope Open a run detail and confirm “Back to ” behavior + “Show all operations”. Co-authored-by: Ahmed Darrazi Co-authored-by: Ahmed Darrazi Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/104 --- app/Filament/Pages/InventoryCoverage.php | 20 ++++++ app/Filament/Resources/BackupSetResource.php | 16 +++++ app/Filament/Resources/PolicyResource.php | 17 +++++ .../Resources/PolicyVersionResource.php | 16 +++++ .../EnsureFilamentTenantSelected.php | 17 +++++ ...opedSurfacesRedirectToChooseTenantTest.php | 65 +++++++++++++++++++ 6 files changed, 151 insertions(+) create mode 100644 tests/Feature/Filament/AdminTenantScopedSurfacesRedirectToChooseTenantTest.php diff --git a/app/Filament/Pages/InventoryCoverage.php b/app/Filament/Pages/InventoryCoverage.php index 0fff310..9bada7b 100644 --- a/app/Filament/Pages/InventoryCoverage.php +++ b/app/Filament/Pages/InventoryCoverage.php @@ -4,7 +4,11 @@ use App\Filament\Clusters\Inventory\InventoryCluster; use App\Filament\Widgets\Inventory\InventoryKpiHeader; +use App\Models\Tenant; +use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Inventory\CoverageCapabilitiesResolver; +use App\Support\Auth\Capabilities; use App\Support\Inventory\InventoryPolicyTypeMeta; use BackedEnum; use Filament\Pages\Page; @@ -24,6 +28,22 @@ class InventoryCoverage extends Page protected string $view = 'filament.pages.inventory-coverage'; + public static function canAccess(): bool + { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->isMember($user, $tenant) + && $resolver->can($user, $tenant, Capabilities::TENANT_VIEW); + } + protected function getHeaderWidgets(): array { return [ diff --git a/app/Filament/Resources/BackupSetResource.php b/app/Filament/Resources/BackupSetResource.php index c64e6f0..8f26d7f 100644 --- a/app/Filament/Resources/BackupSetResource.php +++ b/app/Filament/Resources/BackupSetResource.php @@ -49,6 +49,22 @@ class BackupSetResource extends Resource protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore'; + public static function canViewAny(): bool + { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->isMember($user, $tenant) + && $resolver->can($user, $tenant, Capabilities::TENANT_VIEW); + } + public static function canCreate(): bool { $tenant = Tenant::current(); diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index 86cd290..a666d1a 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -11,6 +11,7 @@ use App\Models\Policy; use App\Models\Tenant; use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Intune\PolicyNormalizer; use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; @@ -58,6 +59,22 @@ class PolicyResource extends Resource protected static string|UnitEnum|null $navigationGroup = 'Inventory'; + public static function canViewAny(): bool + { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->isMember($user, $tenant) + && $resolver->can($user, $tenant, Capabilities::TENANT_VIEW); + } + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index b2d2234..4ff27d1 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -57,6 +57,22 @@ class PolicyVersionResource extends Resource protected static string|UnitEnum|null $navigationGroup = 'Inventory'; + public static function canViewAny(): bool + { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->isMember($user, $tenant) + && $resolver->can($user, $tenant, Capabilities::TENANT_VIEW); + } + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) diff --git a/app/Support/Middleware/EnsureFilamentTenantSelected.php b/app/Support/Middleware/EnsureFilamentTenantSelected.php index a8d04f3..fff692a 100644 --- a/app/Support/Middleware/EnsureFilamentTenantSelected.php +++ b/app/Support/Middleware/EnsureFilamentTenantSelected.php @@ -72,6 +72,14 @@ public function handle(Request $request, Closure $next): Response $tenantParameter = $request->query('tenant'); } + if ( + $tenantParameter === null + && ! filled(Filament::getTenant()) + && $this->adminPathRequiresTenantSelection($path) + ) { + return redirect()->route('filament.admin.pages.choose-tenant'); + } + if ($tenantParameter !== null) { $user = $request->user(); @@ -208,4 +216,13 @@ private function configureNavigationForRequest(\Filament\Panel $panel): void ); }); } + + private function adminPathRequiresTenantSelection(string $path): bool + { + if (! str_starts_with($path, '/admin/')) { + return false; + } + + return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets)(/|$)#', $path) === 1; + } } diff --git a/tests/Feature/Filament/AdminTenantScopedSurfacesRedirectToChooseTenantTest.php b/tests/Feature/Filament/AdminTenantScopedSurfacesRedirectToChooseTenantTest.php new file mode 100644 index 0000000..15a95a6 --- /dev/null +++ b/tests/Feature/Filament/AdminTenantScopedSurfacesRedirectToChooseTenantTest.php @@ -0,0 +1,65 @@ +create(); + + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenants = Tenant::factory()->count(2)->create([ + 'status' => 'active', + 'workspace_id' => $workspace->getKey(), + ]); + + foreach ($tenants as $tenant) { + TenantMembership::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + } + + $this + ->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin/policies') + ->assertRedirect('/admin/choose-tenant'); + + $this + ->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin/policy-versions') + ->assertRedirect('/admin/choose-tenant'); + + $this + ->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin/backup-sets') + ->assertRedirect('/admin/choose-tenant'); + + $this + ->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin/inventory') + ->assertRedirect('/admin/choose-tenant'); +});