From 6dab6297f834a283ef2762ef4bd3b4801570acb0 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 11 Feb 2026 01:02:42 +0100 Subject: [PATCH] feat(spec-085): tenant operate hub --- .github/agents/copilot-instructions.md | 8 +- app/Filament/Pages/Monitoring/Alerts.php | 13 + app/Filament/Pages/Monitoring/AuditLog.php | 13 + app/Filament/Pages/Monitoring/Operations.php | 50 ++++ .../TenantlessOperationRunViewer.php | 45 ++- .../Resources/OperationRunResource.php | 17 +- .../ClearTenantContextController.php | 10 +- .../Middleware/EnsureWorkspaceSelected.php | 13 + app/Providers/Filament/AdminPanelProvider.php | 1 + .../Filament/TenantPanelProvider.php | 18 ++ .../EnsureFilamentTenantSelected.php | 8 +- app/Support/OperateHub/OperateHubShell.php | 131 +++++++++ .../pages/monitoring/alerts.blade.php | 11 +- .../pages/monitoring/audit-log.blade.php | 11 +- .../filament/partials/context-bar.blade.php | 10 +- routes/web.php | 4 + .../checklists/requirements.md | 34 +++ .../contracts/openapi.yaml | 76 +++++ specs/085-tenant-operate-hub/data-model.md | 63 +++++ specs/085-tenant-operate-hub/plan.md | 123 ++++++++ specs/085-tenant-operate-hub/quickstart.md | 39 +++ specs/085-tenant-operate-hub/research.md | 70 +++++ specs/085-tenant-operate-hub/spec.md | 194 +++++++++++++ specs/085-tenant-operate-hub/tasks.md | 192 +++++++++++++ .../OperationsCanonicalUrlsTest.php | 17 +- .../Monitoring/OperationsTenantScopeTest.php | 48 ++++ .../TenantlessOperationRunViewerTest.php | 5 + .../OpsUx/CanonicalViewRunLinksTest.php | 11 + tests/Feature/OpsUx/OperateHubShellTest.php | 264 ++++++++++++++++++ ...nitoringDoesNotMutateTenantContextTest.php | 44 +++ .../Spec085/DenyAsNotFoundSemanticsTest.php | 63 +++++ .../Spec085/OperationsIndexHeaderTest.php | 83 ++++++ .../Spec085/RunDetailBackAffordanceTest.php | 93 ++++++ ...enantNavigationMonitoringShortcutsTest.php | 40 +++ 34 files changed, 1788 insertions(+), 34 deletions(-) create mode 100644 app/Support/OperateHub/OperateHubShell.php create mode 100644 specs/085-tenant-operate-hub/checklists/requirements.md create mode 100644 specs/085-tenant-operate-hub/contracts/openapi.yaml create mode 100644 specs/085-tenant-operate-hub/data-model.md create mode 100644 specs/085-tenant-operate-hub/plan.md create mode 100644 specs/085-tenant-operate-hub/quickstart.md create mode 100644 specs/085-tenant-operate-hub/research.md create mode 100644 specs/085-tenant-operate-hub/spec.md create mode 100644 specs/085-tenant-operate-hub/tasks.md create mode 100644 tests/Feature/OpsUx/OperateHubShellTest.php create mode 100644 tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php create mode 100644 tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php create mode 100644 tests/Feature/Spec085/OperationsIndexHeaderTest.php create mode 100644 tests/Feature/Spec085/RunDetailBackAffordanceTest.php create mode 100644 tests/Feature/Spec085/TenantNavigationMonitoringShortcutsTest.php diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 1af0594..4956a11 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -24,6 +24,9 @@ ## Active Technologies - PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 (082-action-surface-contract) - PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Queue/Jobs (Laravel), Microsoft Graph via `GraphClientInterface` (084-verification-surfaces-unification) - PostgreSQL (JSONB-backed `OperationRun.context`) (084-verification-surfaces-unification) +- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail (085-tenant-operate-hub) +- PostgreSQL (primary) + session (workspace context + last-tenant memory) (085-tenant-operate-hub) +- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 (085-tenant-operate-hub) - PHP 8.4.15 (feat/005-bulk-operations) @@ -43,8 +46,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes -- 084-verification-surfaces-unification: Added PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Queue/Jobs (Laravel), Microsoft Graph via `GraphClientInterface` -- 082-action-surface-contract: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 +- 085-tenant-operate-hub: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 +- 085-tenant-operate-hub: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail +- 085-tenant-operate-hub: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A] diff --git a/app/Filament/Pages/Monitoring/Alerts.php b/app/Filament/Pages/Monitoring/Alerts.php index 31b4305..c09fbda 100644 --- a/app/Filament/Pages/Monitoring/Alerts.php +++ b/app/Filament/Pages/Monitoring/Alerts.php @@ -4,7 +4,9 @@ namespace App\Filament\Pages\Monitoring; +use App\Support\OperateHub\OperateHubShell; use BackedEnum; +use Filament\Actions\Action; use Filament\Pages\Page; use UnitEnum; @@ -25,4 +27,15 @@ class Alerts extends Page protected static ?string $title = 'Alerts'; protected string $view = 'filament.pages.monitoring.alerts'; + + /** + * @return array + */ + protected function getHeaderActions(): array + { + return app(OperateHubShell::class)->headerActions( + scopeActionName: 'operate_hub_scope_alerts', + returnActionName: 'operate_hub_return_alerts', + ); + } } diff --git a/app/Filament/Pages/Monitoring/AuditLog.php b/app/Filament/Pages/Monitoring/AuditLog.php index 31794c2..25b27d0 100644 --- a/app/Filament/Pages/Monitoring/AuditLog.php +++ b/app/Filament/Pages/Monitoring/AuditLog.php @@ -4,7 +4,9 @@ namespace App\Filament\Pages\Monitoring; +use App\Support\OperateHub\OperateHubShell; use BackedEnum; +use Filament\Actions\Action; use Filament\Pages\Page; use UnitEnum; @@ -25,4 +27,15 @@ class AuditLog extends Page protected static ?string $title = 'Audit Log'; protected string $view = 'filament.pages.monitoring.audit-log'; + + /** + * @return array + */ + protected function getHeaderActions(): array + { + return app(OperateHubShell::class)->headerActions( + scopeActionName: 'operate_hub_scope_audit_log', + returnActionName: 'operate_hub_return_audit_log', + ); + } } diff --git a/app/Filament/Pages/Monitoring/Operations.php b/app/Filament/Pages/Monitoring/Operations.php index f2a80a9..2f2b139 100644 --- a/app/Filament/Pages/Monitoring/Operations.php +++ b/app/Filament/Pages/Monitoring/Operations.php @@ -7,10 +7,14 @@ use App\Filament\Resources\OperationRunResource; use App\Filament\Widgets\Operations\OperationsKpiHeader; use App\Models\OperationRun; +use App\Models\Tenant; +use App\Support\OperateHub\OperateHubShell; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\Workspaces\WorkspaceContext; use BackedEnum; +use Filament\Actions\Action; +use Filament\Facades\Filament; use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Contracts\HasForms; use Filament\Pages\Page; @@ -50,6 +54,46 @@ protected function getHeaderWidgets(): array ]; } + /** + * @return array + */ + protected function getHeaderActions(): array + { + $operateHubShell = app(OperateHubShell::class); + + $actions = [ + Action::make('operate_hub_scope_operations') + ->label($operateHubShell->scopeLabel(request())) + ->color('gray') + ->disabled(), + ]; + + $activeTenant = $operateHubShell->activeEntitledTenant(request()); + + if ($activeTenant instanceof Tenant) { + $actions[] = Action::make('operate_hub_back_to_tenant_operations') + ->label('Back to '.$activeTenant->name) + ->icon('heroicon-o-arrow-left') + ->color('gray') + ->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant)); + + $actions[] = Action::make('operate_hub_show_all_tenants') + ->label('Show all tenants') + ->color('gray') + ->action(function (): void { + Filament::setTenant(null, true); + + app(WorkspaceContext::class)->clearLastTenantId(request()); + + $this->removeTableFilter('tenant_id'); + + $this->redirect('/admin/operations'); + }); + } + + return $actions; + } + public function updatedActiveTab(): void { $this->resetPage(); @@ -61,6 +105,8 @@ public function table(Table $table): Table ->query(function (): Builder { $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + $activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request()); + $query = OperationRun::query() ->with('user') ->latest('id') @@ -71,6 +117,10 @@ public function table(Table $table): Table ->when( ! $workspaceId, fn (Builder $query): Builder => $query->whereRaw('1 = 0'), + ) + ->when( + $activeTenant instanceof Tenant, + fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()), ); return $this->applyActiveTab($query); diff --git a/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php b/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php index f88beca..ce7c3aa 100644 --- a/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +++ b/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php @@ -9,17 +9,20 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Auth\CapabilityResolver; +use App\Support\OperateHub\OperateHubShell; use App\Support\OperationRunLinks; use Filament\Actions\Action; use Filament\Actions\ActionGroup; use Filament\Pages\Page; use Filament\Schemas\Components\EmbeddedSchema; use Filament\Schemas\Schema; -use Illuminate\Support\Facades\Gate; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Str; class TenantlessOperationRunViewer extends Page { + use AuthorizesRequests; + protected static bool $shouldRegisterNavigation = false; protected static bool $isDiscovered = false; @@ -37,16 +40,42 @@ class TenantlessOperationRunViewer extends Page */ protected function getHeaderActions(): array { + $operateHubShell = app(OperateHubShell::class); + $actions = [ - Action::make('refresh') - ->label('Refresh') - ->icon('heroicon-o-arrow-path') + Action::make('operate_hub_scope_run_detail') + ->label($operateHubShell->scopeLabel(request())) ->color('gray') - ->url(fn (): string => isset($this->run) - ? route('admin.operations.view', ['run' => (int) $this->run->getKey()]) - : route('admin.operations.index')), + ->disabled(), ]; + $activeTenant = $operateHubShell->activeEntitledTenant(request()); + + if ($activeTenant instanceof Tenant) { + $actions[] = Action::make('operate_hub_back_to_tenant_run_detail') + ->label('← Back to '.$activeTenant->name) + ->color('gray') + ->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant)); + + $actions[] = Action::make('operate_hub_show_all_operations') + ->label('Show all operations') + ->color('gray') + ->url(fn (): string => route('admin.operations.index')); + } else { + $actions[] = Action::make('operate_hub_back_to_operations') + ->label('Back to Operations') + ->color('gray') + ->url(fn (): string => route('admin.operations.index')); + } + + $actions[] = Action::make('refresh') + ->label('Refresh') + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->url(fn (): string => isset($this->run) + ? route('admin.operations.view', ['run' => (int) $this->run->getKey()]) + : route('admin.operations.index')); + if (! isset($this->run)) { return $actions; } @@ -87,7 +116,7 @@ public function mount(OperationRun $run): void abort(403); } - Gate::forUser($user)->authorize('view', $run); + $this->authorize('view', $run); $this->run = $run->loadMissing(['workspace', 'tenant', 'user']); } diff --git a/app/Filament/Resources/OperationRunResource.php b/app/Filament/Resources/OperationRunResource.php index 09dab67..deedb2f 100644 --- a/app/Filament/Resources/OperationRunResource.php +++ b/app/Filament/Resources/OperationRunResource.php @@ -10,6 +10,7 @@ use App\Models\VerificationCheckAcknowledgement; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; +use App\Support\OperateHub\OperateHubShell; use App\Support\OperationCatalog; use App\Support\OperationRunLinks; use App\Support\OperationRunOutcome; @@ -317,6 +318,14 @@ public static function table(Table $table): Table Tables\Filters\SelectFilter::make('tenant_id') ->label('Tenant') ->options(function (): array { + $activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request()); + + if ($activeTenant instanceof Tenant) { + return [ + (string) $activeTenant->getKey() => $activeTenant->getFilamentName(), + ]; + } + $user = auth()->user(); if (! $user instanceof User) { @@ -330,19 +339,19 @@ public static function table(Table $table): Table ->all(); }) ->default(function (): ?string { - $tenant = Filament::getTenant(); + $activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request()); - if (! $tenant instanceof Tenant) { + if (! $activeTenant instanceof Tenant) { return null; } $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); - if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) { + if ($workspaceId === null || (int) $activeTenant->workspace_id !== (int) $workspaceId) { return null; } - return (string) $tenant->getKey(); + return (string) $activeTenant->getKey(); }) ->searchable(), Tables\Filters\SelectFilter::make('type') diff --git a/app/Http/Controllers/ClearTenantContextController.php b/app/Http/Controllers/ClearTenantContextController.php index 839a415..d82ee7b 100644 --- a/app/Http/Controllers/ClearTenantContextController.php +++ b/app/Http/Controllers/ClearTenantContextController.php @@ -17,6 +17,14 @@ public function __invoke(Request $request): RedirectResponse app(WorkspaceContext::class)->clearLastTenantId($request); - return redirect()->to('/admin/operations'); + $previousUrl = url()->previous(); + + $previousHost = parse_url((string) $previousUrl, PHP_URL_HOST); + + if ($previousHost !== null && $previousHost !== $request->getHost()) { + return redirect()->to('/admin/operations'); + } + + return redirect()->to((string) $previousUrl); } } diff --git a/app/Http/Middleware/EnsureWorkspaceSelected.php b/app/Http/Middleware/EnsureWorkspaceSelected.php index 7c634da..f0b45b4 100644 --- a/app/Http/Middleware/EnsureWorkspaceSelected.php +++ b/app/Http/Middleware/EnsureWorkspaceSelected.php @@ -65,6 +65,10 @@ public function handle(Request $request, Closure $next): Response $canCreateWorkspace = Gate::forUser($user)->check('create', Workspace::class); + if (! $hasAnyActiveMembership && $this->isOperateHubPath($path)) { + abort(404); + } + if (! $hasAnyActiveMembership && str_starts_with($path, '/admin/tenants')) { abort(404); } @@ -101,4 +105,13 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool return preg_match('#^/admin/operations/[^/]+$#', $path) === 1; } + + private function isOperateHubPath(string $path): bool + { + return in_array($path, [ + '/admin/operations', + '/admin/alerts', + '/admin/audit-log', + ], true); + } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 874099d..badb4be 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -124,6 +124,7 @@ public function panel(Panel $panel): Panel SubstituteBindings::class, 'ensure-correct-guard:web', 'ensure-workspace-selected', + 'ensure-filament-tenant-selected', DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, ]) diff --git a/app/Providers/Filament/TenantPanelProvider.php b/app/Providers/Filament/TenantPanelProvider.php index bfd280b..3cf942e 100644 --- a/app/Providers/Filament/TenantPanelProvider.php +++ b/app/Providers/Filament/TenantPanelProvider.php @@ -11,6 +11,7 @@ use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DispatchServingFilamentEvent; +use Filament\Navigation\NavigationItem; use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; @@ -40,6 +41,23 @@ public function panel(Panel $panel): Panel ->colors([ 'primary' => Color::Amber, ]) + ->navigationItems([ + NavigationItem::make('Runs') + ->url(fn (): string => route('admin.operations.index')) + ->icon('heroicon-o-queue-list') + ->group('Monitoring') + ->sort(10), + NavigationItem::make('Alerts') + ->url(fn (): string => route('admin.monitoring.alerts')) + ->icon('heroicon-o-bell-alert') + ->group('Monitoring') + ->sort(20), + NavigationItem::make('Audit Log') + ->url(fn (): string => route('admin.monitoring.audit-log')) + ->icon('heroicon-o-clipboard-document-list') + ->group('Monitoring') + ->sort(30), + ]) ->renderHook( PanelsRenderHook::HEAD_END, fn () => view('filament.partials.livewire-intercept-shim')->render() diff --git a/app/Support/Middleware/EnsureFilamentTenantSelected.php b/app/Support/Middleware/EnsureFilamentTenantSelected.php index 957f718..0cfe5f3 100644 --- a/app/Support/Middleware/EnsureFilamentTenantSelected.php +++ b/app/Support/Middleware/EnsureFilamentTenantSelected.php @@ -34,6 +34,12 @@ public function handle(Request $request, Closure $next): Response $existingTenant = Filament::getTenant(); if ($existingTenant instanceof Tenant && $workspaceId !== null && (int) $existingTenant->workspace_id !== (int) $workspaceId) { Filament::setTenant(null, true); + $existingTenant = null; + } + + $user = $request->user(); + if ($existingTenant instanceof Tenant && $user instanceof User && ! $user->canAccessTenant($existingTenant)) { + Filament::setTenant(null, true); } if ($path === '/livewire/update') { @@ -129,8 +135,6 @@ public function handle(Request $request, Closure $next): Response return $next($request); } - $user = $request->user(); - if (! $user instanceof User) { $this->configureNavigationForRequest($panel); diff --git a/app/Support/OperateHub/OperateHubShell.php b/app/Support/OperateHub/OperateHubShell.php new file mode 100644 index 0000000..281efc6 --- /dev/null +++ b/app/Support/OperateHub/OperateHubShell.php @@ -0,0 +1,131 @@ +activeEntitledTenant($request); + + if ($activeTenant instanceof Tenant) { + return 'Scope: Tenant — '.$activeTenant->name; + } + + return 'Scope: Workspace — all tenants'; + } + + /** + * @return array{label: string, url: string}|null + */ + public function returnAffordance(?Request $request = null): ?array + { + $activeTenant = $this->activeEntitledTenant($request); + + if ($activeTenant instanceof Tenant) { + return [ + 'label' => 'Back to '.$activeTenant->name, + 'url' => TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant), + ]; + } + + return null; + } + + /** + * @return array + */ + public function headerActions( + string $scopeActionName = 'operate_hub_scope', + string $returnActionName = 'operate_hub_return', + ?Request $request = null, + ): array { + $actions = [ + Action::make($scopeActionName) + ->label($this->scopeLabel($request)) + ->color('gray') + ->disabled(), + ]; + + $returnAffordance = $this->returnAffordance($request); + + if (is_array($returnAffordance)) { + $actions[] = Action::make($returnActionName) + ->label($returnAffordance['label']) + ->icon('heroicon-o-arrow-left') + ->color('gray') + ->url($returnAffordance['url']); + } + + return $actions; + } + + public function activeEntitledTenant(?Request $request = null): ?Tenant + { + return $this->resolveActiveTenant($request); + } + + private function resolveActiveTenant(?Request $request = null): ?Tenant + { + $tenant = Filament::getTenant(); + + if ($tenant instanceof Tenant && $this->isEntitled($tenant, $request)) { + return $tenant; + } + + $rememberedTenantId = $this->workspaceContext->lastTenantId($request); + + if ($rememberedTenantId === null) { + return null; + } + + $rememberedTenant = Tenant::query()->whereKey($rememberedTenantId)->first(); + + if (! $rememberedTenant instanceof Tenant) { + return null; + } + + if (! $this->isEntitled($rememberedTenant, $request)) { + return null; + } + + return $rememberedTenant; + } + + private function isEntitled(Tenant $tenant, ?Request $request = null): bool + { + if (! $tenant->isActive()) { + return false; + } + + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $workspaceId = $this->workspaceContext->currentWorkspaceId($request); + + if ($workspaceId !== null && (int) $tenant->workspace_id !== (int) $workspaceId) { + return false; + } + + return $this->capabilityResolver->isMember($user, $tenant); + } +} diff --git a/resources/views/filament/pages/monitoring/alerts.blade.php b/resources/views/filament/pages/monitoring/alerts.blade.php index 306c863..5838c48 100644 --- a/resources/views/filament/pages/monitoring/alerts.blade.php +++ b/resources/views/filament/pages/monitoring/alerts.blade.php @@ -1,6 +1,7 @@ -
-
- Alerts is reserved for future work. + +
+
+ Alerts is reserved for future work. +
-
- + diff --git a/resources/views/filament/pages/monitoring/audit-log.blade.php b/resources/views/filament/pages/monitoring/audit-log.blade.php index 0ab0b00..0026d99 100644 --- a/resources/views/filament/pages/monitoring/audit-log.blade.php +++ b/resources/views/filament/pages/monitoring/audit-log.blade.php @@ -1,6 +1,7 @@ -
-
- Audit Log is reserved for future work. + +
+
+ Audit Log is reserved for future work. +
-
- + diff --git a/resources/views/filament/partials/context-bar.blade.php b/resources/views/filament/partials/context-bar.blade.php index 0ac2c3f..a2f0b5a 100644 --- a/resources/views/filament/partials/context-bar.blade.php +++ b/resources/views/filament/partials/context-bar.blade.php @@ -5,6 +5,7 @@ use App\Models\WorkspaceMembership; use App\Services\Auth\WorkspaceRoleCapabilityMap; use App\Support\Auth\Capabilities; + use App\Support\OperateHub\OperateHubShell; use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; @@ -40,9 +41,12 @@ } } - $currentTenant = Filament::getTenant(); + $operateHubShell = app(OperateHubShell::class); + $currentTenant = $operateHubShell->activeEntitledTenant(request()); $currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : null; + $hasAnyFilamentTenantContext = Filament::getTenant() instanceof Tenant; + $path = '/'.ltrim(request()->path(), '/'); $route = request()->route(); $routeName = (string) ($route?->getName() ?? ''); @@ -65,7 +69,7 @@ || ($hasTenantQuery && str_starts_with($routeName, 'filament.admin.')); $lastTenantId = $workspaceContext->lastTenantId(request()); - $canClearTenantContext = $currentTenantName !== null || $lastTenantId !== null; + $canClearTenantContext = $hasAnyFilamentTenantContext || $lastTenantId !== null; @endphp
@@ -174,7 +178,7 @@ class="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-80
@csrf - + Clear tenant context diff --git a/routes/web.php b/routes/web.php index 2ab4541..09ee403 100644 --- a/routes/web.php +++ b/routes/web.php @@ -143,6 +143,7 @@ DispatchServingFilamentEvent::class, FilamentAuthenticate::class, 'ensure-workspace-selected', + 'ensure-filament-tenant-selected', ]) ->get('/admin/operations', \App\Filament\Pages\Monitoring\Operations::class) ->name('admin.operations.index'); @@ -155,6 +156,7 @@ DispatchServingFilamentEvent::class, FilamentAuthenticate::class, 'ensure-workspace-selected', + 'ensure-filament-tenant-selected', ]) ->get('/admin/alerts', \App\Filament\Pages\Monitoring\Alerts::class) ->name('admin.monitoring.alerts'); @@ -167,6 +169,7 @@ DispatchServingFilamentEvent::class, FilamentAuthenticate::class, 'ensure-workspace-selected', + 'ensure-filament-tenant-selected', ]) ->get('/admin/audit-log', \App\Filament\Pages\Monitoring\AuditLog::class) ->name('admin.monitoring.audit-log'); @@ -179,6 +182,7 @@ DispatchServingFilamentEvent::class, FilamentAuthenticate::class, 'ensure-workspace-selected', + 'ensure-filament-tenant-selected', ]) ->get('/admin/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class) ->name('admin.operations.view'); diff --git a/specs/085-tenant-operate-hub/checklists/requirements.md b/specs/085-tenant-operate-hub/checklists/requirements.md new file mode 100644 index 0000000..82766bb --- /dev/null +++ b/specs/085-tenant-operate-hub/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Tenant Operate Hub / Tenant Overview IA + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-09 +**Feature**: [specs/085-tenant-operate-hub/spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Dependencies/assumptions used: canonical monitoring surfaces exist at `/admin/operations`, `/admin/operations/{run}`, `/admin/alerts`, `/admin/audit-log`; tenant plane exists at `/admin/t/{tenant}`; tenant context can be active or absent; authorization semantics remain consistent (deny-as-not-found vs forbidden); Monitoring views are view-only render surfaces that must not initiate outbound calls on render. \ No newline at end of file diff --git a/specs/085-tenant-operate-hub/contracts/openapi.yaml b/specs/085-tenant-operate-hub/contracts/openapi.yaml new file mode 100644 index 0000000..2e4edb0 --- /dev/null +++ b/specs/085-tenant-operate-hub/contracts/openapi.yaml @@ -0,0 +1,76 @@ +openapi: 3.0.3 +info: + title: Tenant Operate Hub / Central Monitoring (UI Route Contracts) + version: 0.1.0 + description: | + Internal documentation of canonical central Monitoring surfaces. + + These are Filament page routes (not a public API). The contract is used to + pin down URL shapes and security semantics (404 vs 403) for acceptance. + +servers: + - url: / + +paths: + /admin/operations: + get: + summary: Central Monitoring - Operations index + description: | + Canonical operations list. Must render without outbound calls. + + Scope semantics: + - If tenant context is active AND entitled: page is tenant-filtered by default and shows tenant-scoped header/CTAs. + - If tenant context is absent: page is workspace-wide. + - If tenant context is active but not entitled: page behaves workspace-wide and must not reveal tenant identity. + responses: + '200': + description: OK + '302': + description: Redirect to choose workspace if none selected + '403': + description: Authenticated but forbidden (capability denial after membership) + '404': + description: Deny-as-not-found when not entitled to workspace scope + /admin/clear-tenant-context: + post: + summary: Exit tenant context (Monitoring) + description: | + Clears the active tenant context for the current session. + Used by “Show all tenants” on central Monitoring pages. + responses: + '302': + description: Redirect back to a canonical Monitoring page + '404': + description: Deny-as-not-found when not entitled to workspace scope + /admin/operations/{run}: + get: + summary: Central Monitoring - Run detail + parameters: + - in: path + name: run + required: true + schema: + type: integer + responses: + '200': + description: OK + '403': + description: Authenticated but forbidden (policy denies view) + '404': + description: Deny-as-not-found when run is outside entitled scope + /admin/alerts: + get: + summary: Central Monitoring - Alerts + responses: + '200': + description: OK + '404': + description: Deny-as-not-found when not entitled to workspace scope + /admin/audit-log: + get: + summary: Central Monitoring - Audit log + responses: + '200': + description: OK + '404': + description: Deny-as-not-found when not entitled to workspace scope diff --git a/specs/085-tenant-operate-hub/data-model.md b/specs/085-tenant-operate-hub/data-model.md new file mode 100644 index 0000000..84e1dc7 --- /dev/null +++ b/specs/085-tenant-operate-hub/data-model.md @@ -0,0 +1,63 @@ +# Data Model: Tenant Operate Hub / Tenant Overview IA + +**Date**: 2026-02-09 +**Branch**: 085-tenant-operate-hub + +This feature is primarily UI/IA + navigation behavior. It introduces **no new database tables**. + +## Entities (existing) + +### Workspace +- Purpose: primary isolation boundary and monitoring scope. +- Source of truth: `workspaces` + membership. + +### Tenant +- Purpose: managed environment; tenant-plane routes live under `/admin/t/{tenant}`. +- Access: entitlement-based. + +### OperationRun +- Purpose: canonical run tracking for all operational workflows. +- Surface: + - Index: `/admin/operations` + - Detail: `/admin/operations/{run}` + +### Alert (placeholder) +- Purpose: future operator signals. +- Surface: `/admin/alerts`. + +### Audit Event / Audit Log (placeholder) +- Purpose: immutable record of sensitive actions. +- Surface: `/admin/audit-log`. + +## Session / Context State (existing) + +### Workspace context +- Key: `WorkspaceContext::SESSION_KEY` (`current_workspace_id`) +- Meaning: selected workspace id for the current session. + +### Last tenant per workspace (session-based) +- Key: `WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY` (`workspace_last_tenant_ids`) +- Shape: + - Map keyed by workspace id string → tenant id int + - Example: + - `{"12": 345}` +- APIs: + - `WorkspaceContext::rememberLastTenantId(int $workspaceId, int $tenantId, Request $request)` + - `WorkspaceContext::lastTenantId(Request $request): ?int` + - `WorkspaceContext::clearLastTenantId(Request $request)` + +### Filament tenant context +- Source: `Filament::getTenant()` (may persist across panels depending on Filament tenancy configuration). +- Used to determine “active tenant context” for Monitoring UX. + +**Spec 085 scope note**: Monitoring may use session-based last-tenant memory as a tenant-context signal when Filament tenant context is absent (e.g., when navigating from the tenant panel into central Monitoring). It must not be inferred from arbitrary deep links. + +### Stale tenant context behavior (no entitlement) + +- If tenant context is active but the user is not entitled, Monitoring pages behave as workspace-wide views and must not display tenant identity. + +## Validation / Rules + +- Tenant context MUST NOT be implicitly mutated by canonical monitoring pages. +- Deny-as-not-found (404) applies when the actor is not entitled to tenant/workspace scope. +- Forbidden (403) applies only after membership is established but capability is missing. diff --git a/specs/085-tenant-operate-hub/plan.md b/specs/085-tenant-operate-hub/plan.md new file mode 100644 index 0000000..e846e6a --- /dev/null +++ b/specs/085-tenant-operate-hub/plan.md @@ -0,0 +1,123 @@ +# Implementation Plan: Spec 085 — Tenant Operate Hub / Tenant Overview IA + +**Branch**: `085-tenant-operate-hub` | **Date**: 2026-02-09 | **Spec**: specs/085-tenant-operate-hub/spec.md +**Input**: specs/085-tenant-operate-hub/spec.md + +## Summary + +Make central Monitoring pages feel context-aware when entered from the tenant panel, without introducing tenant-scoped monitoring routes and without implicit tenant switching. + +Key outcomes: +- Tenant panel sidebar replaces “Operations” with a “Monitoring” group of shortcuts (Runs/Alerts/Audit Log) that open central Monitoring surfaces. +- `/admin/operations` becomes context-aware when tenant context is active: scope label shows tenant, table defaults to tenant filter, and header includes `Back to ` + `Show all tenants` (clears tenant context). +- `/admin/operations/{run}` adds deterministic “back” affordances: tenant back link when tenant context is active + entitled, plus secondary `Show all operations`; otherwise `Back to Operations`. +- Monitoring page render remains DB-only: no outbound calls and no background work triggered by view-only GET. + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 +**Storage**: PostgreSQL (Sail) +**Testing**: Pest v4 (`vendor/bin/sail artisan test`) +**Target Platform**: Web (enterprise SaaS admin UI) +**Project Type**: Laravel monolith (Filament panels + Livewire) +**Performance Goals**: Monitoring page renders are DB-only, low-latency, and avoid N+1 regressions +**Constraints**: +- Canonical monitoring URLs must not change (`/admin/operations`, `/admin/operations/{run}`) +- No new tenant-scoped monitoring routes +- No implicit tenant switching (tenant selection remains explicit POST) +- Deny-as-not-found (404) for non-members/non-entitled; 403 only after membership established +- No outbound calls on render; no render-time side effects (jobs/notifications) +**Scale/Scope**: Small-to-medium UX change touching tenant navigation + 2 monitoring pages + Pest tests + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first / snapshots: Not applicable (read-only monitoring UX). +- Read/write separation: PASS (changes are navigation + view-only rendering; the only mutation is explicit “clear tenant context” action). +- Graph contract path: PASS (no new Graph calls). +- Deterministic capabilities: PASS (uses existing membership/entitlement checks; no new capability strings). +- Workspace isolation: PASS (non-member workspace access remains 404). +- Tenant isolation: PASS (no tenant identity leaks when not entitled; tenant pages remain 404). +- Run observability: PASS (view-only pages do not start operations; Monitoring stays DB-only). +- RBAC-UX destructive confirmation: PASS (no destructive actions added). +- Filament UI Action Surface Contract: PASS (we’re modifying Pages; we will provide explicit header actions and table/default filter behavior; no new list resources are added). + +## Project Structure + +### Documentation (this feature) + +```text +specs/085-tenant-operate-hub/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── openapi.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +app/ +├── Filament/ +│ ├── Pages/ +│ ├── Resources/ +│ └── ... +├── Http/ +│ ├── Controllers/ +│ └── Middleware/ +├── Providers/ +└── Support/ + +resources/views/ +tests/Feature/ +routes/web.php +``` + +**Structure Decision**: Laravel monolith with Filament panels. Changes will be localized to existing panel providers, page classes, shared helpers (if present), and feature tests. + +## Phase Plan + +### Phase 0 — Research (complete) + +Outputs: +- specs/085-tenant-operate-hub/research.md (decisions + alternatives) + +### Phase 1 — Design & Contracts (complete) + +Outputs: +- specs/085-tenant-operate-hub/data-model.md (no schema changes; context rules) +- specs/085-tenant-operate-hub/contracts/openapi.yaml (canonical routes + clear-tenant-context POST) +- specs/085-tenant-operate-hub/quickstart.md (manual verification) + +### Phase 2 — Implementation Planning (next) + +Implementation will be executed as small, test-driven slices: + +1) Tenant panel navigation IA + - Replace tenant-panel “Operations” entry with “Monitoring” group. + - Add 3 shortcut items (Runs/Alerts/Audit Log). + - Verify no new tenant-scoped monitoring routes are introduced. + +2) Operations index context-aware header + default scope + - If tenant context active + entitled: show scope `Tenant — `, default table filter = tenant, CTAs `Back to ` and `Show all tenants`. + - If no tenant context: show scope `Workspace — all tenants`. + - If tenant context active but not entitled: behave workspace-wide (no tenant name, no back-to-tenant). + +3) Run detail deterministic back affordances + - If tenant context active + entitled: `← Back to ` plus secondary `Show all operations`. + - Else: `Back to Operations`. + +4) Pest tests (security + UX) + - OperationsIndexScopeTest (tenant vs workspace scope labels + CTAs) + - RunDetailBackToTenantTest (tenant-context vs no-context actions) + - Deny-as-not-found coverage for non-entitled tenant pages + - “No outbound calls on render” guard for `/admin/operations` and `/admin/operations/{run}` + +## Complexity Tracking + +No constitution violations expected. diff --git a/specs/085-tenant-operate-hub/quickstart.md b/specs/085-tenant-operate-hub/quickstart.md new file mode 100644 index 0000000..91829a7 --- /dev/null +++ b/specs/085-tenant-operate-hub/quickstart.md @@ -0,0 +1,39 @@ +# Quickstart: Tenant Operate Hub / Tenant Overview IA + +**Date**: 2026-02-09 +**Branch**: 085-tenant-operate-hub + +## Local setup + +- Start containers: `vendor/bin/sail up -d` +- Install deps (if needed): `vendor/bin/sail composer install` + +## Manual verification steps (happy path) + +1. Sign in. +2. Select a workspace (via the context bar). +3. Enter a tenant context (e.g., go to `/admin/t/{tenant}` via the tenant panel). +4. In the tenant panel sidebar, open the **Monitoring** group and click: + - Runs → lands on `/admin/operations` +5. Verify `/admin/operations` shows: + - Header scope: `Scope: Tenant — ` + - CTAs: `Back to ` and `Show all tenants` + - The table default scope is tenant-filtered to the active tenant. +6. Click `Show all tenants`. +7. Verify you stay on `/admin/operations` and scope becomes `Scope: Workspace — all tenants`. +8. Open an operation run detail at `/admin/operations/{run}`. +9. Verify the header shows: + - `← Back to ` + - secondary `Show all operations` → `/admin/operations` +10. Click `← Back to ` and verify it lands on the tenant dashboard (`/admin/t/{tenant}`). + +## Negative verification (security) + +- With tenant context active, revoke tenant entitlement for the test user. +- Reload `/admin/operations`. +- Verify scope is workspace-wide and no tenant name / “Back to tenant” affordance appears. +- Request the tenant dashboard URL directly (`/admin/t/{tenant}`) and verify deny-as-not-found. + +## Test commands (to be added in Phase 2) + +- Targeted suite: `vendor/bin/sail artisan test --compact --filter=OperationsIndexScopeTest` diff --git a/specs/085-tenant-operate-hub/research.md b/specs/085-tenant-operate-hub/research.md new file mode 100644 index 0000000..155ef18 --- /dev/null +++ b/specs/085-tenant-operate-hub/research.md @@ -0,0 +1,70 @@ +# Research: Tenant Operate Hub / Tenant Overview IA (Spec 085) + +**Date**: 2026-02-09 +**Branch**: 085-tenant-operate-hub +**Spec**: specs/085-tenant-operate-hub/spec.md + +This research consolidates repo evidence + the final clarification decisions, so the implementation plan and tests can be deterministic. + +## Repository Evidence (high-signal) + +- Canonical monitoring pages already exist in the Admin panel: + - Operations index: app/Filament/Pages/Monitoring/Operations.php + - Run detail (tenantless viewer): app/Filament/Pages/Operations/TenantlessOperationRunViewer.php + - Alerts: app/Filament/Pages/Monitoring/Alerts.php + - Audit log: app/Filament/Pages/Monitoring/AuditLog.php +- Tenant selection + clear tenant context already exist (UI + route/controller): + - Context bar partial: resources/views/filament/partials/context-bar.blade.php + - Tenant select controller: app/Http/Controllers/SelectTenantController.php + +## Decisions (resolved) + +### Decision: Tenant context may use last-tenant memory for cross-panel flows +- Decision: “Tenant context is active” on Monitoring pages is resolved from the active Filament tenant when present, otherwise from the remembered last-tenant id for the current workspace. +- Rationale: Central Monitoring routes are canonical and tenantless, but users enter them from the tenant panel and expect consistent scoping. +- Alternatives considered: + - Query param `?tenant=`: rejected (would introduce an implicit switching vector). + +### Decision: “Show all tenants” explicitly exits tenant context +- Decision: The CTA “Show all tenants” clears tenant context (single meaning) and returns the user to workspace-wide Monitoring. +- Rationale: Prevents the confusing state where tenant context is still active but filters are reset. +- Alternatives considered: + - Only reset table filter: rejected (context remains active and confuses scope semantics). + +### Decision: Stale tenant context is handled without leaks +- Decision: If tenant context is active but the user is no longer entitled to that tenant, Monitoring pages behave as workspace-wide: + - Scope shows `Workspace — all tenants` + - No tenant name is shown + - No “Back to tenant” is rendered + - Tenant pages remain deny-as-not-found +- Rationale: Preserves deny-as-not-found and avoids tenant existence hints. +- Alternatives considered: + - 404 the Monitoring page: rejected (feels like being “thrown out”). + - Auto-clear tenant context implicitly: rejected (implicit context mutation). + +### Decision: Run detail shows an explicit tenant return + secondary escape hatch +- Decision: When tenant context is active and entitled, run detail shows: + - `← Back to ` (tenant dashboard) + - secondary `Show all operations` → `/admin/operations` +- Rationale: “Back to tenant” is deterministic; the secondary link provides a canonical escape hatch. +- Alternatives considered: + - Only show `Back to tenant`: rejected by clarification (secondary escape hatch approved). + +### Decision: Tenant panel navigation uses a “Monitoring” group with central shortcuts +- Decision: Replace the tenant-panel “Operations” item with group “Monitoring” containing shortcuts: + - Runs → `/admin/operations` + - Alerts → `/admin/alerts` + - Audit Log → `/admin/audit-log` +- Rationale: Keep tenant sidebar labels minimal while still providing correct central Monitoring entry points, while preserving canonical URLs and avoiding tenant-scoped monitoring routes. +- Alternatives considered: + - New tenant-scoped monitoring routes: rejected (explicitly forbidden). + +### Decision: “No outbound calls on render” is enforced by tests +- Decision: Monitoring GET renders must not trigger outbound network calls or start background work as a side effect. +- Rationale: Aligns with constitution (“Monitoring pages MUST be DB-only at render time”). +- Alternatives considered: + - Rely on convention: rejected; this needs regression protection. + +## Open Questions + +None remaining for Phase 0. The spec clarifications cover all scope-affecting ambiguities. diff --git a/specs/085-tenant-operate-hub/spec.md b/specs/085-tenant-operate-hub/spec.md new file mode 100644 index 0000000..a661d4f --- /dev/null +++ b/specs/085-tenant-operate-hub/spec.md @@ -0,0 +1,194 @@ +# Feature Specification: Tenant Operate Hub / Tenant Overview IA + +**Feature Branch**: `085-tenant-operate-hub` +**Created**: 2026-02-09 +**Status**: Draft +**Input**: User description: "Make central Monitoring surfaces feel context-aware when entered from a tenant, without changing canonical URLs, and without weakening deny-as-not-found security boundaries." + +## Clarifications + +### Session 2026-02-09 (work order alignment) + +- Q: What is the source of truth for “Back to tenant”? → A: The active entitled tenant context (Filament tenant if present, otherwise the remembered last-tenant id for the current workspace). +- Q: Should “Back to last tenant” be implemented as a separate feature? → A: No; remembered tenant context is used only to preserve context when navigating from a tenant into central Monitoring. +- Q: What does “Show all tenants” do? → A: It explicitly exits tenant context to return to workspace-wide monitoring (no mixed behavior with filter resets). +- Q: How is Monitoring reached from tenant context? → A: Tenant navigation offers a “Monitoring” group with shortcuts that open central Monitoring surfaces. +- Q: How should stale tenant context (tenant context active but user no longer entitled) behave on Monitoring pages? → A: Monitoring renders workspace-wide (no tenant name, no “Back to tenant”), preserving deny-as-not-found for tenant pages. +- Q: Should run detail offer a secondary escape hatch when tenant context is active? → A: Yes — show a secondary “Show all operations” link to `/admin/operations`. +- Q: How should tenant Monitoring shortcuts indicate “opens central monitoring”? → A: Keep labels minimal (no “↗ Central” suffix). + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Monitoring feels context-aware (Priority: P1) + +As an operator, when I open central Monitoring from within a tenant, I immediately understand: +1) whether the Monitoring view is scoped to the current tenant or to all tenants, and +2) how to get back to the tenant I came from. + +**Why this priority**: This is the core usability and safety problem: monitoring and tenant work should not feel like different apps, but they must not blur security boundaries. + +**Independent Test**: With a tenant context active, a test user can open the Operations index and a run detail, see an explicit scope indicator and a deterministic “Back to tenant”, and exit to workspace-wide monitoring intentionally. + +**Acceptance Scenarios**: + +1. **Given** a user is a member of a workspace and has access to at least one tenant, **When** they open central Monitoring (Operations), **Then** the page clearly shows whether tenant context is active. +2. **Given** a tenant context is active, **When** the user navigates to a canonical monitoring detail page, **Then** the UI provides a single, clear “Back to tenant” affordance that returns to that tenant dashboard. +3. **Given** a user is not entitled to the current tenant, **When** they try to access tenant-scoped pages via a direct link, **Then** they receive a not-found experience (deny-as-not-found), without any tenant existence hints. + +--- + +### User Story 2 - Canonical URLs with explicit scope (Priority: P2) + +As an operator, I can use canonical Monitoring URLs at all times. When tenant context is active, Monitoring views can be tenant-filtered by default, but they must not implicitly change tenant selection. + +**Why this priority**: Avoids mistakes and misinterpretation of data by preventing silent scoping changes. + +**Independent Test**: With a tenant selected, open monitoring index and detail views and verify the scope is consistent and clearly communicated. + +**Acceptance Scenarios**: + +1. **Given** a tenant context is active, **When** the user opens the monitoring index, **Then** the default view is tenant-scoped (or clearly offers a one-click tenant scope), and the UI visibly indicates the scope. +2. **Given** no tenant context is active, **When** the user opens monitoring, **Then** the view is workspace-wide and does not imply a tenant is selected. + +--- + +### User Story 3 - Deep links are safe and recoverable (Priority: P3) + +As an operator working inside a tenant, when I land on a canonical run detail via a deep link, I can safely return to the tenant if tenant context is still active and I am still entitled. + +**Why this priority**: These workflows are frequent. Deep links are where users most often “lose” tenant context. + +**Independent Test**: With a tenant context active, open a canonical run detail and verify the “Back to tenant” affordance is present and correct. + +**Acceptance Scenarios**: + +1. **Given** a tenant context is active and the user is still entitled, **When** they open a canonical run detail, **Then** they see a “Back to tenant” affordance. +2. **Given** tenant context is not active, **When** the user opens a canonical run detail, **Then** they see only a “Back to Operations” affordance. + +### Edge Cases + +- User has no workspace selected: Monitoring must not show cross-workspace data; user must select a workspace first. +- User has workspace access but zero tenant access: Monitoring must still work in workspace-wide mode, without tenant selection. +- User’s tenant access is revoked while they have a deep link open: subsequent tenant-scoped navigation must be deny-as-not-found. +- User opens a bookmarked canonical run detail directly: the UI must provide a deterministic “Back” behavior without inventing tenant context. +- Tenant context is active, but entitlement was revoked: Monitoring must not leak tenant identity; tenant return affordance must not appear (or must be safe). +- Monitoring views must remain view-only render surfaces: rendering must not trigger outbound calls. + +## Requirements *(mandatory)* + +### Target State (hard decision) + +This spec adopts a single, deterministic interpretation: + +- Monitoring URLs are canonical and do not change with tenant context. +- Tenant context makes Monitoring *feel* scoped (scope indicators, default filters, and deterministic exits) without implicit tenant switching. + +- Operations index: `/admin/operations` +- Operations run detail: `/admin/operations/{run}` +- Alerts: `/admin/alerts` +- Audit log: `/admin/audit-log` + +Tenant plane remains under `/admin/t/{tenant}` for tenant dashboards and workflows. Monitoring views are central, but when tenant context is active they become tenant-filtered by default and provide a deterministic “Back to tenant” affordance. + +**Constitution alignment (required):** This feature is information architecture + navigation behavior. It MUST NOT introduce new outbound calls for monitoring pages. If it introduces or changes any write/change behavior (e.g., starting workflows), it MUST maintain existing safety gates (preview/confirmation/audit), tenant isolation, run observability, and tests. + +**Constitution alignment (RBAC-UX):** This feature changes how users reach surfaces; it MUST preserve and test authorization semantics: +- Non-member / not entitled to workspace scope OR tenant scope → deny-as-not-found (404 semantics) +- Member but missing capability → forbidden (403 semantics) + +**Constitution alignment (OPS-EX-AUTH-001):** Authentication handshakes may perform synchronous outbound communication on auth endpoints. This MUST NOT be used for Monitoring pages. + +**Constitution alignment (BADGE-001):** If any status/severity/outcome badges are added or changed on hub pages, their meaning MUST be centralized and covered by tests. + +**Constitution alignment (UI Action Surfaces):** If this feature adds/modifies any admin or tenant UI surfaces, the “UI Action Matrix” MUST be updated and action gating MUST remain consistent (confirmation for destructive-like actions; server-side authorization for mutations). + +### Functional Requirements + +- **FR-085-001**: Tenant navigation MUST offer a “Monitoring” group with shortcuts to central Monitoring surfaces: + - Runs (Operations) → `/admin/operations` + - Alerts → `/admin/alerts` + - Audit Log → `/admin/audit-log` + These shortcuts MUST NOT introduce new tenant-scoped monitoring URLs. + +- **FR-085-002**: The Operations index (`/admin/operations`) MUST show a clear scope indicator in the page header: + - `Scope: Workspace — all tenants` when no tenant context is active + - `Scope: Tenant — ` when tenant context is active +- **FR-085-003**: Canonical Monitoring URLs MUST NOT implicitly change tenant context. Tenant context MAY influence default filters on Monitoring views. +- **FR-085-004**: When tenant context is active on the Operations index, the default tenant filter MUST be set to the current tenant, and the UI MUST make this tenant scoping obvious. +- **FR-085-005**: The Operations index MUST provide two explicit CTAs when tenant context is active: + - `Show all tenants` (explicitly exits tenant context and returns to workspace-wide monitoring) + - `Back to ` (navigates to tenant dashboard) +- **FR-085-006**: The run detail (`/admin/operations/{run}`) MUST provide a deterministic “Back” affordance: + - If tenant context is active AND the user is still entitled: `← Back to ` (tenant dashboard) AND a secondary `Show all operations` (to `/admin/operations`) + - Else: `Back to Operations` (Operations index) +- **FR-085-007**: “Back to tenant” MUST be based only on active entitled tenant context (Filament tenant, or remembered tenant for the current workspace). It MUST NOT be inferred from arbitrary deep-link parameters. +- **FR-085-008**: Deny-as-not-found MUST remain: users not entitled to workspace or tenant scope MUST receive a not-found experience (404 semantics), with no tenant existence hints. +- **FR-085-009**: Monitoring views (`/admin/operations` and `/admin/operations/{run}`) MUST remain view-only render surfaces and MUST NOT trigger outbound calls during render. +- **FR-085-010**: If tenant context is active but the user is not entitled to that tenant, Monitoring pages MUST behave as workspace-wide views: + - Scope indicator MUST show `Scope: Workspace — all tenants` + - No tenant name MUST be displayed + - No “Back to ” affordance MUST be rendered + - Direct access to tenant pages MUST continue to be deny-as-not-found + +## UI Action Matrix *(mandatory when UI surfaces are changed)* + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Central Operations (index) | `/admin/operations` | Scope indicator; `Show all tenants` (when tenant context active); deterministic back affordance | Linked run rows to open run detail | N/A | N/A | N/A | N/A | N/A | No | Must not implicitly change tenant context; default tenant filter when tenant context active | +| Central Operations (run detail) | `/admin/operations/{run}` | `← Back to ` (when tenant context active + entitled) OR `Back to Operations`; secondary `Show all operations` allowed when tenant context active + entitled | N/A | N/A | N/A | N/A | N/A | N/A | No | Must not reveal tenant identity when user is not entitled | +| Tenant navigation shortcuts | Tenant sidebar | N/A | N/A | N/A | N/A | N/A | N/A | N/A | No | “Monitoring” group with central shortcuts | + +### Key Entities *(include if feature involves data)* + +- **Workspace**: A security and organizational boundary for operations and monitoring. +- **Tenant**: A managed environment within a workspace; access is entitlement-based. +- **Monitoring (Operations)**: Central monitoring views that can be workspace-wide or tenant-scoped when tenant context is active. +- **Operation Run**: A tracked execution of an operational workflow; viewable via canonical run detail. +- **Alert**: An operator-facing signal about an issue or state requiring attention. +- **Audit Event**: An immutable record of important user-triggered actions and sensitive operations. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-085-001**: In a usability walkthrough, 90% of operators can correctly identify whether Operations is scoped to a tenant or to all tenants within 10 seconds of opening `/admin/operations`. +- **SC-085-002**: With tenant context active, operators can return to the tenant dashboard from `/admin/operations` and `/admin/operations/{run}` in ≤ 1 click. +- **SC-085-003**: Support tickets tagged “lost tenant context / where am I?” decrease by 30% within 30 days after rollout. +- **SC-085-004**: Authorization regression checks show zero cases where a non-entitled user can infer existence of a tenant or view tenant-scoped monitoring data. + +### Engineering Acceptance Outcomes + +- **SC-085-005**: When tenant context is active, `/admin/operations` and `/admin/operations/{run}` clearly show tenant scope and a “Back to ” affordance. +- **SC-085-006**: When tenant context is not active, `/admin/operations/{run}` shows “Back to Operations” and no “Back to tenant”. +- **SC-085-007**: Viewing Monitoring pages does not initiate outbound network requests or start background work as a side effect of rendering. + +## Test Plan *(mandatory)* + +1. **Operations index scope label + CTAs (tenant context)** + - With tenant context active and user entitled, request `/admin/operations`. + - Assert the page indicates `Scope: Tenant — `. + - Assert `Show all tenants` and `Back to ` are available. + +2. **Operations index scope label (no tenant context)** + - With no tenant context active, request `/admin/operations`. + - Assert the page indicates `Scope: Workspace — all tenants`. + +3. **Run detail back affordance (tenant context)** + - With tenant context active and user entitled, request `/admin/operations/{run}`. + - Assert `← Back to ` is available. + - Assert secondary `Show all operations` is available and links to `/admin/operations`. + +4. **Run detail back affordance (no tenant context)** + - With no tenant context active, request `/admin/operations/{run}`. + - Assert only `Back to Operations` is available. + +5. **Deny-as-not-found regression** + - As a user without tenant entitlement, request `/admin/t/{tenant}`. + - Assert deny-as-not-found behavior (404 semantics) and that no tenant identity hints are revealed via Monitoring CTAs. + +6. **Stale tenant context behaves workspace-wide** + - With tenant context active but user not entitled, request `/admin/operations`. + - Assert scope indicates workspace-wide and no tenant name or “Back to tenant” is present. + +7. **No outbound calls on render** + - Assert rendering `/admin/operations` and `/admin/operations/{run}` does not initiate outbound network calls and does not start background work from a view-only GET. diff --git a/specs/085-tenant-operate-hub/tasks.md b/specs/085-tenant-operate-hub/tasks.md new file mode 100644 index 0000000..14613d5 --- /dev/null +++ b/specs/085-tenant-operate-hub/tasks.md @@ -0,0 +1,192 @@ + +--- +description: "Task list for Spec 085 — Tenant Operate Hub / Tenant Overview IA" +--- + +# Tasks: Spec 085 — Tenant Operate Hub / Tenant Overview IA + +**Input**: Design documents from `/specs/085-tenant-operate-hub/` + +**Required**: +- `specs/085-tenant-operate-hub/plan.md` +- `specs/085-tenant-operate-hub/spec.md` + +**Additional docs present**: +- `specs/085-tenant-operate-hub/research.md` +- `specs/085-tenant-operate-hub/data-model.md` +- `specs/085-tenant-operate-hub/contracts/openapi.yaml` +- `specs/085-tenant-operate-hub/quickstart.md` + +**Tests**: REQUIRED (runtime UX + security semantics; Pest) + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Confirm the existing code touchpoints and test harness for Spec 085. + +- [X] T001 Confirm canonical Monitoring routes + existing clear-context endpoint in routes/web.php and app/Http/Controllers/ClearTenantContextController.php +- [X] T002 Confirm the Monitoring pages exist and are canonical: app/Filament/Pages/Monitoring/Operations.php, app/Filament/Pages/Operations/TenantlessOperationRunViewer.php, app/Filament/Pages/Monitoring/Alerts.php, app/Filament/Pages/Monitoring/AuditLog.php +- [X] T003 Confirm Tenant panel provider is the entry point for tenant sidebar Monitoring shortcuts in app/Providers/Filament/TenantPanelProvider.php +- [X] T004 Confirm Laravel 11+/12 panel provider registration is in bootstrap/providers.php (not bootstrap/app.php) +- [X] T005 [P] Identify existing monitoring/tenant scoping tests to extend (tests/Feature/Monitoring/OperationsTenantScopeTest.php, tests/Feature/Operations/TenantlessOperationRunViewerTest.php) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared helper behavior must match Spec 085 semantics before story work. + +- [X] T006 Update scope-label copy and semantics in app/Support/OperateHub/OperateHubShell.php (MUST match FR-085-002 exactly: "Scope: Workspace — all tenants" / "Scope: Tenant — ") +- [X] T007 Ensure OperateHubShell resolves active entitled tenant context safely (Filament tenant when present, otherwise remembered last-tenant id for the current workspace) +- [X] T008 Update OperateHubShell return affordance label to include tenant name ("Back to ") in app/Support/OperateHub/OperateHubShell.php +- [X] T009 Add a helper method to resolve “active tenant AND still entitled” in app/Support/OperateHub/OperateHubShell.php (used by Operations index + run detail to implement stale-tenant-context behavior) +- [X] T010 Ensure Monitoring renders remain DB-only (no outbound calls / no side effects) by standardizing test guards with Http::preventStrayRequests() in tests/Feature/Spec085/*.php and existing coverage tests/Feature/Monitoring/OperationsTenantScopeTest.php and tests/Feature/Operations/TenantlessOperationRunViewerTest.php + +**Checkpoint**: Shared semantics locked; user story work can begin. + +--- + +## Phase 3: User Story 1 — Monitoring feels context-aware (Priority: P1) 🎯 MVP + +**Goal**: When tenant context is active, Monitoring clearly shows tenant scope + deterministic “Back to tenant” and offers explicit “Show all tenants” to exit. + +**Independent Test**: With tenant context active + entitled, GET `/admin/operations` shows `Scope: Tenant — ` and buttons `Back to ` and `Show all tenants`; clicking “Show all tenants” clears tenant context and returns to workspace-wide operations. + +### Tests for User Story 1 (write first) + +- [X] T011 [P] [US1] Add Spec 085 operations header tests in tests/Feature/Spec085/OperationsIndexHeaderTest.php (tenant scope label + both CTAs) +- [X] T012 [P] [US1] Add stale-tenant-context test in tests/Feature/Spec085/OperationsIndexHeaderTest.php (tenant context set but user not entitled → workspace scope + no tenant name + no back-to-tenant) +- [X] T013 [P] [US1] Add explicit-exit behavior test in tests/Feature/Spec085/OperationsIndexHeaderTest.php (POST /admin/clear-tenant-context clears Filament tenant + last tenant id) +- [X] T014 [P] [US1] Add tenant navigation shortcuts test in tests/Feature/Spec085/TenantNavigationMonitoringShortcutsTest.php (tenant sidebar shows “Monitoring” group with Runs/Alerts/Audit Log) +- [X] T015 [P] [US1] Add “deny-as-not-found” regression tests for canonical Monitoring access in tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php (non-workspace-member → 404 for /admin/operations and /admin/operations/{run}) +- [X] T016 [P] [US1] Add “deny-as-not-found” regression test for tenant dashboard direct access in tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php (non-entitled to tenant → 404 for /admin/t/{tenant}) + +### Implementation for User Story 1 + +- [X] T017 [US1] Replace Tenant sidebar "Operations" item with "Monitoring" group shortcuts in app/Providers/Filament/TenantPanelProvider.php (Runs→/admin/operations, Alerts→/admin/alerts, Audit Log→/admin/audit-log) +- [X] T018 [US1] Implement Operations index scope indicator per Spec 085 in app/Filament/Pages/Monitoring/Operations.php (workspace vs tenant; stale context treated as workspace) +- [X] T019 [US1] Implement Operations index CTAs per Spec 085 in app/Filament/Pages/Monitoring/Operations.php (Back to using App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant); Show all tenants exits tenant context) +- [X] T020 [US1] Ensure “Show all tenants” uses an explicit server-side action (no implicit GET mutation) in app/Filament/Pages/Monitoring/Operations.php (perform the same mutations as app/Http/Controllers/ClearTenantContextController.php: Filament::setTenant(null, true) + WorkspaceContext::clearLastTenantId(); then redirect to /admin/operations) + +**Checkpoint**: US1 fully testable and meets FR-085-001/002/005/007/010. + +--- + +## Phase 4: User Story 2 — Canonical URLs with explicit scope (Priority: P2) + +**Goal**: Canonical Monitoring URLs never implicitly change tenant context; tenant context may only affect default filtering and must be obvious. + +**Independent Test**: With tenant context active, GET `/admin/operations` does not change tenant context and defaults the list to the active tenant (or otherwise clearly shows it’s tenant-scoped by default). + +### Tests for User Story 2 + +- [X] T021 [P] [US2] Add non-mutation test in tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php (GET /admin/operations does not set/clear tenant context) +- [X] T022 [P] [US2] Add scope label test in tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php (no tenant context → "Scope: Workspace — all tenants") +- [X] T023 [P] [US2] Add default-tenant-filter test in tests/Feature/Monitoring/OperationsTenantScopeTest.php (tenant context active → list defaults to active tenant) + +### Implementation for User Story 2 + +- [X] T024 [US2] Ensure Operations index query applies workspace scoping and (when tenant context is active + entitled) tenant scoping without mutating tenant context in app/Filament/Pages/Monitoring/Operations.php +- [X] T025 [US2] Ensure any default tenant filter is applied as a query/filter default only (no calls to Filament::setTenant() during GET) in app/Filament/Pages/Monitoring/Operations.php + +**Checkpoint**: US2 meets FR-085-003/004/009. + +--- + +## Phase 5: User Story 3 — Deep links are safe and recoverable (Priority: P3) + +**Goal**: On `/admin/operations/{run}`, tenant-context users get a deterministic “Back to ” plus a secondary “Show all operations”; otherwise only “Back to Operations”. + +**Independent Test**: With tenant context active + entitled, GET `/admin/operations/{run}` shows `← Back to ` and `Show all operations`; without tenant context it shows `Back to Operations` only. + +### Tests for User Story 3 + +- [X] T026 [P] [US3] Add run detail header-action tests in tests/Feature/Spec085/RunDetailBackAffordanceTest.php (tenant context vs no context) +- [X] T027 [P] [US3] Add stale-tenant-context run detail test in tests/Feature/Spec085/RunDetailBackAffordanceTest.php (tenant context set but not entitled → no tenant name, no back-to-tenant) + +### Implementation for User Story 3 + +- [X] T028 [US3] Implement deterministic back affordances for run detail in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php (tenant-context+entitled → “← Back to ” + “Show all operations”; else “Back to Operations”) +- [X] T029 [US3] Ensure run detail never reveals tenant identity when the viewer is not entitled (stale tenant context treated as workspace-wide) in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php + +**Checkpoint**: US3 meets FR-085-006/008/010. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [X] T030 [P] Confirm Spec 085 UI Action Matrix matches implemented header actions in specs/085-tenant-operate-hub/spec.md +- [X] T031 [P] Validate manual verification steps in specs/085-tenant-operate-hub/quickstart.md against actual behavior (update doc only if it drifted) +- [X] T037 Ensure “Show all tenants” clears Operations table tenant filter state (prevents stale Livewire table filter state from keeping the list scoped) +- [X] T032 Run formatting on changed files under app/ and tests/ using vendor/bin/sail bin pint --dirty +- [X] T033 Run focused test suite: vendor/bin/sail artisan test --compact tests/Feature/Spec085 tests/Feature/Monitoring/OperationsTenantScopeTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php +- [X] T034 Fix Filament auth-pattern guard compliance by removing Gate:: usage in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php (use $this->authorize(...)) +- [X] T035 Ensure canonical Operate Hub routes sanitize stale/non-entitled tenant context by applying ensure-filament-tenant-selected middleware to /admin/operations, /admin/alerts, /admin/audit-log, and /admin/operations/{run} +- [X] T036 Harden Spec 085-related tests to match final copy/semantics and avoid brittle Livewire DOM assertions (tests/Feature/OpsUx/OperateHubShellTest.php, tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php) + +--- + +## Dependencies & Execution Order + +### Dependency Graph + +```mermaid +graph TD + P1[Phase 1: Setup] --> P2[Phase 2: Foundational] + P2 --> US1[US1 (P1): Context-aware Monitoring entry] + US1 --> US2[US2 (P2): Canonical URLs + explicit scope] + US1 --> US3[US3 (P3): Deep-link back affordances] + US2 --> P6[Phase 6: Polish] + US3 --> P6 +``` + +### User Story Dependencies + +- US1 is the MVP. +- US2 and US3 depend on the shared foundational semantics (scope labels + entitled active tenant resolution). + +--- + +## Parallel Execution Examples + +### US1 + +```text +In parallel: +- T011 (tests) in tests/Feature/Spec085/OperationsIndexHeaderTest.php +- T017 (tenant nav shortcuts) in app/Providers/Filament/TenantPanelProvider.php +Then: +- T018–T020 in app/Filament/Pages/Monitoring/Operations.php +``` + +### US2 + +```text +In parallel: +- T021–T022 (non-mutation + scope label tests) +- T023 (default tenant filter test) in tests/Feature/Monitoring/OperationsTenantScopeTest.php +Then: +- T024–T025 in app/Filament/Pages/Monitoring/Operations.php +``` + +### US3 + +```text +In parallel: +- T026–T027 (run detail tests) in tests/Feature/Spec085/RunDetailBackAffordanceTest.php +Then: +- T028–T029 in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +``` + +--- + +## Implementation Strategy + +### MVP Scope + +- Implement US1 only (T011–T020), run T033, then manually validate via specs/085-tenant-operate-hub/quickstart.md. + +### Incremental Delivery + +- US1 → US2 → US3, keeping each story independently testable. diff --git a/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php b/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php index 894c25a..7435970 100644 --- a/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php +++ b/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php @@ -118,12 +118,21 @@ $component = Livewire::actingAs($user) ->test(Operations::class) - ->assertCanSeeTableRecords([$runA]) - ->assertCanNotSeeTableRecords([$runB]); + ->assertSee('TenantA') + ->assertDontSee('TenantB') + ->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey()); $component - ->filterTable('tenant_id', null) - ->assertCanSeeTableRecords([$runA, $runB]); + ->callAction('operate_hub_show_all_tenants') + ->assertSet('tableFilters.tenant_id.value', null) + ->assertRedirect('/admin/operations'); + + Filament::setTenant(null, true); + + Livewire::actingAs($user) + ->test(Operations::class) + ->assertSee('TenantA') + ->assertSee('TenantB'); }); it('does not register legacy operation resource routes', function (): void { diff --git a/tests/Feature/Monitoring/OperationsTenantScopeTest.php b/tests/Feature/Monitoring/OperationsTenantScopeTest.php index e687b20..0c030a5 100644 --- a/tests/Feature/Monitoring/OperationsTenantScopeTest.php +++ b/tests/Feature/Monitoring/OperationsTenantScopeTest.php @@ -5,8 +5,13 @@ use App\Models\Tenant; use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; +use Illuminate\Support\Facades\Http; use Livewire\Livewire; +beforeEach(function (): void { + Http::preventStrayRequests(); +}); + it('defaults Monitoring → Operations list to the active tenant when tenant context is set', function () { $tenantA = Tenant::factory()->create(); $tenantB = Tenant::factory()->create(); @@ -47,6 +52,49 @@ ->assertDontSee('TenantB'); }); +it('defaults Monitoring → Operations list to the remembered tenant when Filament tenant is not available', function () { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + + [$user] = createUserWithTenant($tenantA, role: 'owner'); + + $tenantB->forceFill(['workspace_id' => (int) $tenantA->workspace_id])->save(); + + $user->tenants()->syncWithoutDetaching([ + $tenantB->getKey() => ['role' => 'owner'], + ]); + + OperationRun::factory()->create([ + 'tenant_id' => $tenantA->getKey(), + 'type' => 'policy.sync', + 'status' => 'queued', + 'outcome' => 'pending', + 'initiator_name' => 'TenantA', + ]); + + OperationRun::factory()->create([ + 'tenant_id' => $tenantB->getKey(), + 'type' => 'inventory.sync', + 'status' => 'queued', + 'outcome' => 'pending', + 'initiator_name' => 'TenantB', + ]); + + Filament::setTenant(null, true); + + $workspaceId = (int) $tenantA->workspace_id; + app(WorkspaceContext::class)->rememberLastTenantId($workspaceId, (int) $tenantA->getKey()); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => $workspaceId]) + ->get('/admin/operations') + ->assertOk() + ->assertSee('Scope: Tenant — '.$tenantA->name) + ->assertSee('Policy sync') + ->assertSee('TenantA') + ->assertDontSee('Inventory sync'); +}); + it('scopes Monitoring → Operations tabs to the active tenant', function () { $tenantA = Tenant::factory()->create(); $tenantB = Tenant::factory()->create(); diff --git a/tests/Feature/Operations/TenantlessOperationRunViewerTest.php b/tests/Feature/Operations/TenantlessOperationRunViewerTest.php index 6ad7063..993e748 100644 --- a/tests/Feature/Operations/TenantlessOperationRunViewerTest.php +++ b/tests/Feature/Operations/TenantlessOperationRunViewerTest.php @@ -9,6 +9,11 @@ use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\Workspaces\WorkspaceContext; +use Illuminate\Support\Facades\Http; + +beforeEach(function (): void { + Http::preventStrayRequests(); +}); it('allows viewing an operation run without a selected workspace when the user is a member of the run workspace', function (): void { $workspace = Workspace::factory()->create(); diff --git a/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php b/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php index 65d5c77..f949c29 100644 --- a/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php +++ b/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use App\Models\OperationRun; +use App\Support\OperationRunLinks; use Illuminate\Support\Facades\File; it('routes all OperationRun view links through OperationRunLinks', function (): void { @@ -30,3 +32,12 @@ expect($violations)->toBeEmpty(); })->group('ops-ux'); + +it('resolves tenantless operation run links to the canonical admin.operations.view route', function (): void { + $run = OperationRun::factory()->create(); + + $expectedUrl = route('admin.operations.view', ['run' => (int) $run->getKey()]); + + expect(OperationRunLinks::tenantlessView($run))->toBe($expectedUrl); + expect(OperationRunLinks::tenantlessView((int) $run->getKey()))->toBe($expectedUrl); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/OperateHubShellTest.php b/tests/Feature/OpsUx/OperateHubShellTest.php new file mode 100644 index 0000000..229cb67 --- /dev/null +++ b/tests/Feature/OpsUx/OperateHubShellTest.php @@ -0,0 +1,264 @@ +create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'provider.connection.check', + 'status' => 'completed', + 'outcome' => 'blocked', + 'initiator_name' => 'System', + ]); + + $this->actingAs($user); + + Bus::fake(); + Filament::setTenant(null, true); + + $session = [ + WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, + ]; + + assertNoOutboundHttp(function () use ($run, $session): void { + $this->withSession($session) + ->get(route('admin.operations.index')) + ->assertOk() + ->assertSee('Scope: Workspace — all tenants'); + + $this->withSession($session) + ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->assertOk() + ->assertSee('Scope: Workspace — all tenants'); + + $this->withSession($session) + ->get(route('admin.monitoring.alerts')) + ->assertOk() + ->assertSee('Scope: Workspace — all tenants'); + + $this->withSession($session) + ->get(route('admin.monitoring.audit-log')) + ->assertOk() + ->assertSee('Scope: Workspace — all tenants'); + }); + + Bus::assertNothingDispatched(); +})->group('ops-ux'); + +it('shows back to tenant on run detail when tenant context is active and entitled', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'policy.sync', + 'status' => 'queued', + 'outcome' => 'pending', + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $response = $this->withSession([ + WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, + ])->get(route('admin.operations.view', ['run' => (int) $run->getKey()])); + + $response + ->assertOk() + ->assertSee('← Back to '.$tenant->name) + ->assertSee(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant), false) + ->assertSee('Show all operations') + ->assertDontSee('Back to Operations'); + + expect(substr_count((string) $response->getContent(), '← Back to '.$tenant->name))->toBe(1); +})->group('ops-ux'); + +it('shows back to tenant when filament tenant is absent but last tenant memory exists', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'policy.sync', + 'status' => 'queued', + 'outcome' => 'pending', + ]); + + $this->actingAs($user); + Filament::setTenant(null, true); + + $workspaceId = (int) $tenant->workspace_id; + $lastTenantMap = [(string) $workspaceId => (int) $tenant->getKey()]; + + $response = $this->withSession([ + WorkspaceContext::SESSION_KEY => $workspaceId, + WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => $lastTenantMap, + ])->get(route('admin.operations.view', ['run' => (int) $run->getKey()])); + + $response + ->assertOk() + ->assertSee('← Back to '.$tenant->name) + ->assertSee(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant), false) + ->assertSee('Show all operations') + ->assertDontSee('Back to Operations'); +})->group('ops-ux'); + +it('shows no tenant return affordance when active and last tenant contexts are not entitled', function (): void { + [$user, $entitledTenant] = createUserWithTenant(role: 'owner'); + + $nonEntitledTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $entitledTenant->workspace_id, + ]); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $entitledTenant->getKey(), + 'workspace_id' => (int) $entitledTenant->workspace_id, + 'type' => 'policy.sync', + 'status' => 'queued', + 'outcome' => 'pending', + ]); + + $this->actingAs($user); + Filament::setTenant($nonEntitledTenant, true); + + $workspaceId = (int) $entitledTenant->workspace_id; + + $response = $this->withSession([ + WorkspaceContext::SESSION_KEY => $workspaceId, + WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [(string) $workspaceId => (int) $nonEntitledTenant->getKey()], + ])->get(route('admin.operations.view', ['run' => (int) $run->getKey()])); + + $response + ->assertOk() + ->assertSee('Back to Operations') + ->assertDontSee('← Back to '.$nonEntitledTenant->name) + ->assertDontSee('Show all operations'); +})->group('ops-ux'); + +it('returns 404 for non-member workspace access to /admin/operations', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(route('admin.operations.index')) + ->assertNotFound(); +})->group('ops-ux'); + +it('returns 404 for non-entitled tenant dashboard direct access', function (): void { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)) + ->assertNotFound(); +})->group('ops-ux'); + +it('keeps member-without-capability workflow start denial as 403 with no run side effects', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(RestoreRunResource::getUrl('create', tenant: $tenant)) + ->assertForbidden(); + + expect(OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->exists())->toBeFalse(); +})->group('ops-ux'); + +it('does not mutate workspace or last-tenant session memory on /admin/operations', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $workspaceId = (int) $tenant->workspace_id; + $lastTenantMap = [(string) $workspaceId => (int) $tenant->getKey()]; + + $response = $this->withSession([ + WorkspaceContext::SESSION_KEY => $workspaceId, + WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => $lastTenantMap, + ])->get(route('admin.operations.index')); + + $response->assertOk(); + $response->assertSessionHas(WorkspaceContext::SESSION_KEY, $workspaceId); + $response->assertSessionHas(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, $lastTenantMap); +})->group('ops-ux'); + +it('shows tenant scope label when tenant context is active', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $this->withSession([ + WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, + ])->get(route('admin.operations.index')) + ->assertOk() + ->assertSee('Scope: Tenant — '.$tenant->name) + ->assertDontSee('Scope: Workspace — all tenants'); +})->group('ops-ux'); + +it('does not create audit entries when viewing operate hub pages', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'policy.sync', + 'status' => 'queued', + 'outcome' => 'pending', + ]); + + $this->actingAs($user); + Filament::setTenant(null, true); + + $before = (int) AuditLog::query()->count(); + + $session = [ + WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, + ]; + + $this->withSession($session) + ->get(route('admin.operations.index')) + ->assertOk(); + + $this->withSession($session) + ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->assertOk(); + + $this->withSession($session) + ->get(route('admin.monitoring.alerts')) + ->assertOk(); + + $this->withSession($session) + ->get(route('admin.monitoring.audit-log')) + ->assertOk(); + + expect((int) AuditLog::query()->count())->toBe($before); +})->group('ops-ux'); diff --git a/tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php b/tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php new file mode 100644 index 0000000..e9c96ea --- /dev/null +++ b/tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php @@ -0,0 +1,44 @@ +create(); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); + + Filament::setTenant($tenant, true); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get('/admin/operations') + ->assertOk(); + + expect(Filament::getTenant())->toBe($tenant); +}); + +it('renders workspace scope label when no tenant context is active', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); + + Filament::setTenant(null, true); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get('/admin/operations') + ->assertOk() + ->assertSee('Scope: Workspace — all tenants'); + + expect(Filament::getTenant())->toBeNull(); +}); diff --git a/tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php b/tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php new file mode 100644 index 0000000..b8e2b61 --- /dev/null +++ b/tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php @@ -0,0 +1,63 @@ +create(); + + session()->forget(WorkspaceContext::SESSION_KEY); + + $this->actingAs($user) + ->get('/admin/operations') + ->assertNotFound(); +}); + +it('returns 404 for non-workspace-members on central operation run detail', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + session()->forget(WorkspaceContext::SESSION_KEY); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => null, + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + ]); + + $this->actingAs($user) + ->get("/admin/operations/{$run->getKey()}") + ->assertNotFound(); +}); + +it('returns 404 for non-entitled users on tenant dashboard direct access', function (): void { + $tenantA = Tenant::factory()->create(); + [$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner'); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id]) + ->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenantB)) + ->assertNotFound(); +}); diff --git a/tests/Feature/Spec085/OperationsIndexHeaderTest.php b/tests/Feature/Spec085/OperationsIndexHeaderTest.php new file mode 100644 index 0000000..0779a28 --- /dev/null +++ b/tests/Feature/Spec085/OperationsIndexHeaderTest.php @@ -0,0 +1,83 @@ +create(); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); + + Filament::setTenant($tenant, true); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get('/admin/operations') + ->assertOk() + ->assertSee('Scope: Tenant — '.$tenant->name) + ->assertSee('Back to '.$tenant->name) + ->assertSee('Show all tenants'); +}); + +it('treats stale tenant context as workspace-wide without tenant identity hints', function (): void { + $entitledTenant = Tenant::factory()->create(); + [$user, $entitledTenant] = createUserWithTenant($entitledTenant, role: 'owner', workspaceRole: 'readonly'); + + $staleTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $entitledTenant->workspace_id, + ]); + + Filament::setTenant($staleTenant, true); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $entitledTenant->workspace_id]) + ->get('/admin/operations') + ->assertOk() + ->assertSee('Scope: Workspace — all tenants') + ->assertDontSee('Back to '.$staleTenant->name) + ->assertDontSee($staleTenant->name) + ->assertDontSee('Show all tenants'); +}); + +it('clears filament tenant context and last-tenant session state via clear-tenant-context endpoint', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); + + $workspaceId = (int) $tenant->workspace_id; + $lastTenantIds = [ + (string) $workspaceId => (int) $tenant->getKey(), + ]; + + Filament::setTenant($tenant, true); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => $workspaceId, + WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => $lastTenantIds, + ]) + ->from('/admin/alerts') + ->post('/admin/clear-tenant-context') + ->assertRedirect('/admin/alerts'); + + expect(Filament::getTenant())->toBeNull(); + expect(session()->get(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [])) + ->not->toHaveKey((string) $workspaceId); + + $this->withSession([ + WorkspaceContext::SESSION_KEY => $workspaceId, + ]) + ->get('/admin/operations') + ->assertOk() + ->assertSee('Scope: Workspace — all tenants') + ->assertDontSee('Scope: Tenant — '.$tenant->name); +}); diff --git a/tests/Feature/Spec085/RunDetailBackAffordanceTest.php b/tests/Feature/Spec085/RunDetailBackAffordanceTest.php new file mode 100644 index 0000000..23f1bfa --- /dev/null +++ b/tests/Feature/Spec085/RunDetailBackAffordanceTest.php @@ -0,0 +1,93 @@ +create(); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + ]); + + Filament::setTenant($tenant, true); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get("/admin/operations/{$run->getKey()}") + ->assertOk() + ->assertSee('← Back to '.$tenant->name) + ->assertSee('Show all operations') + ->assertDontSee('Back to Operations'); +}); + +it('shows only back-to-operations when no tenant context is active', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + ]); + + Filament::setTenant(null, true); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get("/admin/operations/{$run->getKey()}") + ->assertOk() + ->assertSee('Back to Operations') + ->assertDontSee('← Back to ') + ->assertDontSee('Show all operations'); +}); + +it('treats stale tenant context as workspace-wide on run detail', function (): void { + $entitledTenant = Tenant::factory()->create(); + [$user, $entitledTenant] = createUserWithTenant($entitledTenant, role: 'owner', workspaceRole: 'readonly'); + + $staleTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $entitledTenant->workspace_id, + ]); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $entitledTenant->workspace_id, + 'tenant_id' => (int) $entitledTenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + ]); + + Filament::setTenant($staleTenant, true); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $entitledTenant->workspace_id]) + ->get("/admin/operations/{$run->getKey()}") + ->assertOk() + ->assertSee('Scope: Workspace — all tenants') + ->assertSee('Back to Operations') + ->assertDontSee('← Back to '.$staleTenant->name) + ->assertDontSee($staleTenant->name) + ->assertDontSee('Show all operations'); +}); diff --git a/tests/Feature/Spec085/TenantNavigationMonitoringShortcutsTest.php b/tests/Feature/Spec085/TenantNavigationMonitoringShortcutsTest.php new file mode 100644 index 0000000..e545b8c --- /dev/null +++ b/tests/Feature/Spec085/TenantNavigationMonitoringShortcutsTest.php @@ -0,0 +1,40 @@ +create(); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)) + ->assertOk(); + + $panel = Filament::getCurrentOrDefaultPanel(); + + $monitoringLabels = collect($panel->getNavigationItems()) + ->filter(static fn ($item): bool => $item->getGroup() === 'Monitoring') + ->map(static fn ($item): string => $item->getLabel()) + ->values() + ->all(); + + expect($monitoringLabels)->toContain('Runs'); + expect($monitoringLabels)->toContain('Alerts'); + expect($monitoringLabels)->toContain('Audit Log'); + + expect($monitoringLabels)->not->toContain('Operations'); +});