From bf43dad3d1a5da72fdf9e507113fbcd4d69406cc Mon Sep 17 00:00:00 2001 From: ahmido Date: Fri, 15 May 2026 20:52:37 +0000 Subject: [PATCH] fix: enforce workspace surface scope for customer review workspace (#366) ## Summary - keep `/admin/reviews/workspace` workspace-scoped in shell and sidebar context - treat `tenant` query hints on the customer review workspace as page-level filters only - update the customer review workspace tests and Spec 311 navigation contract to match the workspace-hub IA ## Testing - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php tests/Feature/Filament/PanelNavigationSegregationTest.php` - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - `git diff --check` Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/366 --- Agents.md | 5 + .../Pages/Reviews/CustomerReviewWorkspace.php | 7 +- .../Filament/Pages/Reviews/ReviewRegister.php | 7 +- .../Resources/AlertDeliveryResource.php | 30 +- .../Resources/ProviderConnectionResource.php | 2 +- .../Widgets/Alerts/AlertsKpiHeader.php | 8 - .../Operations/OperationsKpiHeader.php | 73 +++- .../EnsureFilamentTenantSelected.php | 143 +------- .../Navigation/WorkspaceSidebarNavigation.php | 167 +++++++++ .../Support/OperateHub/OperateHubShell.php | 12 + .../Support/Tenants/TenantInteractionLane.php | 1 + .../Support/Tenants/TenantPageCategory.php | 49 ++- apps/platform/routes/web.php | 1 + .../AlertDeliveryDeepLinkFiltersTest.php | 16 +- ...EnvironmentReviewRegisterPrefilterTest.php | 20 +- .../EnvironmentReviewRegisterTest.php | 17 +- .../Alerts/AlertDeliveryViewerTest.php | 31 +- .../Filament/Alerts/AlertsKpiHeaderTest.php | 20 +- .../Feature/Filament/AuditLogPageTest.php | 52 +-- .../PanelNavigationSegregationTest.php | 184 ++++++++++ ...aceContextTopbarAndTenantSelectionTest.php | 6 +- .../OperationsCanonicalUrlsTest.php | 23 +- .../OperationsKpiHeaderTenantContextTest.php | 20 +- .../Monitoring/OperationsTenantScopeTest.php | 45 +-- .../CustomerReviewWorkspacePageTest.php | 5 +- .../Spec085/OperationsIndexHeaderTest.php | 10 +- .../GlobalContextShellContractTest.php | 93 +++++ .../Unit/Tenants/TenantPageCategoryTest.php | 10 +- docs/AGENTS-draft.md | 50 +++ docs/ai-coding-rules.md | 94 ++++++ docs/architecture-guidelines.md | 177 ++++++++++ docs/deployment-checklist.md | 102 ++++++ docs/filament-guidelines.md | 153 +++++++++ docs/package-governance.md | 84 +++++ docs/performance-guidelines.md | 101 ++++++ docs/product/spec-candidates.md | 319 +++++++++++++++++- docs/security-guidelines.md | 137 ++++++++ docs/stack-overview.md | 206 +++++++++++ docs/testing-guidelines.md | 113 +++++++ .../plan.md | 189 +++++++++++ .../spec.md | 134 ++++++++ .../tasks.md | 81 +++++ 42 files changed, 2656 insertions(+), 341 deletions(-) create mode 100644 apps/platform/app/Support/Navigation/WorkspaceSidebarNavigation.php create mode 100644 docs/AGENTS-draft.md create mode 100644 docs/ai-coding-rules.md create mode 100644 docs/architecture-guidelines.md create mode 100644 docs/deployment-checklist.md create mode 100644 docs/filament-guidelines.md create mode 100644 docs/package-governance.md create mode 100644 docs/performance-guidelines.md create mode 100644 docs/security-guidelines.md create mode 100644 docs/stack-overview.md create mode 100644 docs/testing-guidelines.md create mode 100644 specs/311-workspace-environment-surface-scope-contract/plan.md create mode 100644 specs/311-workspace-environment-surface-scope-contract/spec.md create mode 100644 specs/311-workspace-environment-surface-scope-contract/tasks.md diff --git a/Agents.md b/Agents.md index e092c426..9e5b6a9a 100644 --- a/Agents.md +++ b/Agents.md @@ -378,9 +378,14 @@ ## AI Usage Note All AI agents must read: - `AGENTS.md` - `.specify/*` +- `docs/ai-coding-rules.md` +- the relevant guideline file under `docs/*-guidelines.md` before proposing or implementing changes. +For the current enterprise best-practice baseline and the proposed compact addendum +for this file, see `docs/stack-overview.md` and `docs/AGENTS-draft.md`. + ## Reference Materials - PowerShell scripts from IntuneManagement are stored under `/references/IntuneManagement-master` for implementation guidance only. diff --git a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php index 2ea649e7..d4eb74b1 100644 --- a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php +++ b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php @@ -317,11 +317,7 @@ private function tenantFilterOptions(): array private function defaultTenantFilter(): ?string { - $tenantId = app(WorkspaceContext::class)->lastTenantId(request()); - - return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants()) - ? (string) $tenantId - : null; + return null; } private function applyRequestedTenantPrefilter(): void @@ -353,7 +349,6 @@ private function hasActiveFilters(): bool private function clearWorkspaceFilters(): void { - app(WorkspaceContext::class)->clearLastTenantId(request()); $this->removeTableFilters(); } diff --git a/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php b/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php index 6aac6e0d..b15322e4 100644 --- a/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php +++ b/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php @@ -278,11 +278,7 @@ private function tenantFilterOptions(): array private function defaultTenantFilter(): ?string { - $tenantId = app(WorkspaceContext::class)->lastTenantId(request()); - - return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants()) - ? (string) $tenantId - : null; + return null; } private function applyRequestedTenantPrefilter(): void @@ -317,7 +313,6 @@ private function hasActiveFilters(): bool private function clearRegisterFilters(): void { - app(WorkspaceContext::class)->clearLastTenantId(request()); $this->removeTableFilters(); } diff --git a/apps/platform/app/Filament/Resources/AlertDeliveryResource.php b/apps/platform/app/Filament/Resources/AlertDeliveryResource.php index f6b42bbe..db148eeb 100644 --- a/apps/platform/app/Filament/Resources/AlertDeliveryResource.php +++ b/apps/platform/app/Filament/Resources/AlertDeliveryResource.php @@ -15,7 +15,6 @@ use App\Support\Badges\BadgeRenderer; use App\Support\Filament\FilterOptionCatalog; use App\Support\Filament\FilterPresets; -use App\Support\OperateHub\OperateHubShell; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; @@ -115,7 +114,6 @@ public static function getEloquentQuery(): Builder { $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); $user = auth()->user(); - $activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request()); $scopeResolver = app(ManagedEnvironmentAccessScopeResolver::class); @@ -153,10 +151,6 @@ public static function getEloquentQuery(): Builder }); }), ) - ->when( - $activeTenant instanceof ManagedEnvironment, - fn (Builder $query): Builder => $query->where('managed_environment_id', (int) $activeTenant->getKey()), - ) ->latest('id'); } @@ -276,14 +270,6 @@ public static function table(Table $table): Table SelectFilter::make('managed_environment_id') ->label('ManagedEnvironment') ->options(function (): array { - $activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request()); - - if ($activeTenant instanceof ManagedEnvironment) { - return [ - (string) $activeTenant->getKey() => $activeTenant->getFilamentName(), - ]; - } - $user = auth()->user(); if (! $user instanceof User) { @@ -296,21 +282,7 @@ public static function table(Table $table): Table ]) ->all(); }) - ->default(function (): ?string { - $activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request()); - - if (! $activeTenant instanceof ManagedEnvironment) { - return null; - } - - $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); - - if ($workspaceId === null || (int) $activeTenant->workspace_id !== (int) $workspaceId) { - return null; - } - - return (string) $activeTenant->getKey(); - }) + ->default(null) ->searchable(), SelectFilter::make('status') ->options(FilterOptionCatalog::alertDeliveryStatuses()), diff --git a/apps/platform/app/Filament/Resources/ProviderConnectionResource.php b/apps/platform/app/Filament/Resources/ProviderConnectionResource.php index 037477b9..0aaaf29b 100644 --- a/apps/platform/app/Filament/Resources/ProviderConnectionResource.php +++ b/apps/platform/app/Filament/Resources/ProviderConnectionResource.php @@ -891,7 +891,7 @@ public static function table(Table $table): Table ->filters([ SelectFilter::make('tenant') ->label('ManagedEnvironment') - ->default(static::resolveScopedTenant()?->external_id) + ->default(static::resolveRequestedTenantExternalId()) ->options(static::tenantFilterOptions()) ->query(function (Builder $query, array $data): Builder { $value = $data['value'] ?? null; diff --git a/apps/platform/app/Filament/Widgets/Alerts/AlertsKpiHeader.php b/apps/platform/app/Filament/Widgets/Alerts/AlertsKpiHeader.php index a54bbedf..411e27a8 100644 --- a/apps/platform/app/Filament/Widgets/Alerts/AlertsKpiHeader.php +++ b/apps/platform/app/Filament/Widgets/Alerts/AlertsKpiHeader.php @@ -10,10 +10,8 @@ use App\Models\AlertDelivery; use App\Models\AlertDestination; use App\Models\AlertRule; -use App\Models\ManagedEnvironment; use App\Models\User; use App\Services\Auth\ManagedEnvironmentAccessScopeResolver; -use App\Support\OperateHub\OperateHubShell; use App\Support\Workspaces\WorkspaceContext; use Filament\Widgets\StatsOverviewWidget; use Filament\Widgets\StatsOverviewWidget\Stat; @@ -103,12 +101,6 @@ private function deliveriesQueryForViewer(User $user, int $workspaceId): Builder qualifiedEnvironmentColumn: 'alert_deliveries.managed_environment_id', ); - $activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request()); - - if ($activeTenant instanceof ManagedEnvironment) { - $query->where('managed_environment_id', (int) $activeTenant->getKey()); - } - return $query; } } diff --git a/apps/platform/app/Filament/Widgets/Operations/OperationsKpiHeader.php b/apps/platform/app/Filament/Widgets/Operations/OperationsKpiHeader.php index ef37314a..c5cfc5b3 100644 --- a/apps/platform/app/Filament/Widgets/Operations/OperationsKpiHeader.php +++ b/apps/platform/app/Filament/Widgets/Operations/OperationsKpiHeader.php @@ -6,13 +6,17 @@ use App\Models\OperationRun; use App\Models\ManagedEnvironment; +use App\Models\User; +use App\Services\Auth\ManagedEnvironmentAccessScopeResolver; use App\Support\OperateHub\OperateHubShell; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\OpsUx\ActiveRuns; +use App\Support\Workspaces\WorkspaceContext; use Carbon\CarbonInterval; use Filament\Widgets\StatsOverviewWidget; use Filament\Widgets\StatsOverviewWidget\Stat; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; class OperationsKpiHeader extends StatsOverviewWidget @@ -25,11 +29,22 @@ protected function getPollingInterval(): ?string { $tenant = $this->activeTenant(); - if (! $tenant instanceof ManagedEnvironment) { + if ($tenant instanceof ManagedEnvironment) { + return ActiveRuns::existForTenant($tenant) ? '10s' : null; + } + + $query = $this->scopedOperationRunQuery(); + + if (! $query instanceof Builder) { return null; } - return ActiveRuns::existForTenant($tenant) ? '10s' : null; + return (clone $query) + ->whereIn('status', [ + OperationRunStatus::Queued->value, + OperationRunStatus::Running->value, + ]) + ->exists() ? '10s' : null; } /** @@ -37,29 +52,24 @@ protected function getPollingInterval(): ?string */ protected function getStats(): array { - $tenant = $this->activeTenant(); + $scopeQuery = $this->scopedOperationRunQuery(); - if (! $tenant instanceof ManagedEnvironment) { + if (! $scopeQuery instanceof Builder) { return []; } - $tenantId = (int) $tenant->getKey(); - - $totalRuns30Days = (int) OperationRun::query() - ->where('managed_environment_id', $tenantId) + $totalRuns30Days = (int) (clone $scopeQuery) ->where('created_at', '>=', now()->subDays(30)) ->count(); - $activeRuns = (int) OperationRun::query() - ->where('managed_environment_id', $tenantId) + $activeRuns = (int) (clone $scopeQuery) ->whereIn('status', [ OperationRunStatus::Queued->value, OperationRunStatus::Running->value, ]) ->count(); - $failedOrPartial7Days = (int) OperationRun::query() - ->where('managed_environment_id', $tenantId) + $failedOrPartial7Days = (int) (clone $scopeQuery) ->where('status', OperationRunStatus::Completed->value) ->whereIn('outcome', [ OperationRunOutcome::Failed->value, @@ -69,8 +79,7 @@ protected function getStats(): array ->count(); /** @var Collection $recentCompletedRuns */ - $recentCompletedRuns = OperationRun::query() - ->where('managed_environment_id', $tenantId) + $recentCompletedRuns = (clone $scopeQuery) ->where('status', OperationRunStatus::Completed->value) ->whereNotNull('started_at') ->whereNotNull('completed_at') @@ -117,6 +126,42 @@ private function activeTenant(): ?ManagedEnvironment return $tenant instanceof ManagedEnvironment ? $tenant : null; } + private function scopedOperationRunQuery(): ?Builder + { + $tenant = $this->activeTenant(); + + if ($tenant instanceof ManagedEnvironment) { + return OperationRun::query() + ->where('workspace_id', (int) $tenant->workspace_id) + ->where('managed_environment_id', (int) $tenant->getKey()); + } + + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + $user = auth()->user(); + + if (! is_int($workspaceId) || ! $user instanceof User) { + return null; + } + + $query = OperationRun::query() + ->where('workspace_id', $workspaceId); + + $allowedTenantIds = app(ManagedEnvironmentAccessScopeResolver::class) + ->allowedManagedEnvironmentIdsForWorkspace($user, $workspaceId); + + if ($allowedTenantIds === null) { + return $query; + } + + return $query->where(function (Builder $query) use ($allowedTenantIds): void { + $query->whereNull('managed_environment_id'); + + if ($allowedTenantIds !== []) { + $query->orWhereIn('managed_environment_id', array_values(array_unique(array_map('intval', $allowedTenantIds)))); + } + }); + } + private static function formatDurationSeconds(int $seconds): string { if ($seconds <= 0) { diff --git a/apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php b/apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php index 5832d241..d2774c44 100644 --- a/apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php +++ b/apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php @@ -2,30 +2,16 @@ namespace App\Support\Middleware; -use App\Filament\Pages\WorkspaceOverview; -use App\Filament\Pages\Governance\GovernanceInbox; -use App\Filament\Pages\Reviews\CustomerReviewWorkspace; -use App\Filament\Pages\Settings\WorkspaceSettings; -use App\Filament\Resources\AlertDeliveryResource; -use App\Filament\Resources\AlertDestinationResource; -use App\Filament\Resources\AlertRuleResource; -use App\Filament\Resources\ProviderConnectionResource; use App\Models\ManagedEnvironment; use App\Models\User; -use App\Models\Workspace; -use App\Models\WorkspaceMembership; -use App\Services\Auth\WorkspaceCapabilityResolver; -use App\Services\Auth\WorkspaceRoleCapabilityMap; -use App\Support\Auth\Capabilities; use App\Support\Navigation\NavigationScope; -use App\Support\OperationRunLinks; +use App\Support\Navigation\WorkspaceSidebarNavigation; use App\Support\OperateHub\OperateHubShell; use App\Support\Tenants\TenantPageCategory; use App\Support\Workspaces\WorkspaceContext; use Closure; use Filament\Facades\Filament; use Filament\Navigation\NavigationBuilder; -use Filament\Navigation\NavigationItem; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -171,134 +157,11 @@ private function configureNavigationForRequest(\Filament\Panel $panel, Request $ return; } - $panel->navigation(function (): NavigationBuilder { - return app(NavigationBuilder::class) - ->item(WorkspaceOverview::navigationItem()) - ->item( - NavigationItem::make('Governance inbox') - ->url(fn (): string => GovernanceInbox::getUrl(panel: 'admin')) - ->icon('heroicon-o-inbox-stack') - ->group('Governance') - ->sort(5), - ) - ->item( - NavigationItem::make('Customer reviews') - ->url(fn (): string => CustomerReviewWorkspace::getUrl(panel: 'admin')) - ->icon('heroicon-o-document-text') - ->group('Reporting') - ->sort(44), - ) - ->item( - NavigationItem::make('Integrations') - ->url(fn (): string => route('filament.admin.resources.provider-connections.index')) - ->icon('heroicon-o-link') - ->group('Settings') - ->sort(15) - ->visible(fn (): bool => ProviderConnectionResource::canViewAny()), - ) - ->item( - NavigationItem::make('Settings') - ->url(fn (): string => WorkspaceSettings::getUrl(panel: 'admin')) - ->icon('heroicon-o-cog-6-tooth') - ->group('Settings') - ->sort(20) - ->visible(fn (): bool => $this->canViewWorkspaceSettings()), - ) - ->item( - NavigationItem::make('Manage workspaces') - ->url(fn (): string => route('filament.admin.resources.workspaces.index')) - ->icon('heroicon-o-squares-2x2') - ->group('Settings') - ->sort(10) - ->visible(function (): bool { - $user = auth()->user(); - - if (! $user instanceof User) { - return false; - } - - $roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE); - - return WorkspaceMembership::query() - ->where('user_id', (int) $user->getKey()) - ->whereIn('role', $roles) - ->exists(); - }), - ) - ->item( - NavigationItem::make('Operations') - ->url(fn (): string => OperationRunLinks::index()) - ->icon('heroicon-o-queue-list') - ->group('Monitoring') - ->sort(10), - ) - ->item( - NavigationItem::make('Alert targets') - ->url(fn (): string => AlertDestinationResource::getUrl(panel: 'admin')) - ->icon('heroicon-o-bell-alert') - ->group('Monitoring') - ->sort(20) - ->visible(fn (): bool => AlertDestinationResource::canViewAny()), - ) - ->item( - NavigationItem::make('Alert rules') - ->url(fn (): string => AlertRuleResource::getUrl(panel: 'admin')) - ->icon('heroicon-o-funnel') - ->group('Monitoring') - ->sort(21) - ->visible(fn (): bool => AlertRuleResource::canViewAny()), - ) - ->item( - NavigationItem::make('Alert deliveries') - ->url(fn (): string => AlertDeliveryResource::getUrl(panel: 'admin')) - ->icon('heroicon-o-clock') - ->group('Monitoring') - ->sort(22) - ->visible(fn (): bool => AlertDeliveryResource::canViewAny()), - ) - ->item( - NavigationItem::make('Alerts') - ->url(fn (): string => '/admin/alerts') - ->icon('heroicon-o-bell-alert') - ->group('Monitoring') - ->sort(23), - ) - ->item( - NavigationItem::make('Audit Log') - ->url(fn (): string => '/admin/audit-log') - ->icon('heroicon-o-clipboard-document-list') - ->group('Monitoring') - ->sort(30), - ); + $panel->navigation(function (WorkspaceSidebarNavigation $navigation): NavigationBuilder { + return $navigation->build(); }); } - private function canViewWorkspaceSettings(): bool - { - $user = auth()->user(); - - if (! $user instanceof User) { - return false; - } - - $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); - - if (! is_int($workspaceId)) { - return false; - } - - $workspace = Workspace::query()->whereKey($workspaceId)->first(); - - if (! $workspace instanceof Workspace) { - return false; - } - - $resolver = app(WorkspaceCapabilityResolver::class); - - return $resolver->isMember($user, $workspace) - && $resolver->can($user, $workspace, Capabilities::WORKSPACE_SETTINGS_VIEW); - } - private function isWorkspaceScopedPageWithTenant(string $path): bool { return preg_match('#^/admin/workspaces/[^/]+/environments/[^/]+/(required-permissions|diagnostics|access-scopes)$#', $path) === 1; diff --git a/apps/platform/app/Support/Navigation/WorkspaceSidebarNavigation.php b/apps/platform/app/Support/Navigation/WorkspaceSidebarNavigation.php new file mode 100644 index 00000000..ee3d51e6 --- /dev/null +++ b/apps/platform/app/Support/Navigation/WorkspaceSidebarNavigation.php @@ -0,0 +1,167 @@ +item(WorkspaceOverview::navigationItem()) + ->groups([ + NavigationGroup::make(__('localization.navigation.monitoring')) + ->items($this->visibleItems([ + NavigationItem::make(FindingExceptionsQueue::getNavigationLabel()) + ->url(fn (): string => FindingExceptionsQueue::getUrl(panel: 'admin')) + ->icon(FindingExceptionsQueue::getNavigationIcon()) + ->visible(fn (): bool => FindingExceptionsQueue::canAccess()), + NavigationItem::make(__('localization.navigation.operations')) + ->url(fn (): string => OperationRunLinks::index()) + ->icon('heroicon-o-queue-list') + ->visible(fn (): bool => true), + NavigationItem::make(__('localization.navigation.alerts')) + ->url(fn (): string => route('filament.admin.alerts')) + ->icon('heroicon-o-bell-alert') + ->visible(fn (): bool => Alerts::canAccess()) + ->childItems($this->visibleItems([ + NavigationItem::make(AlertDestinationResource::getNavigationLabel()) + ->url(fn (): string => AlertDestinationResource::getUrl(panel: 'admin')) + ->icon(AlertDestinationResource::getNavigationIcon()) + ->visible(fn (): bool => AlertDestinationResource::canViewAny()), + NavigationItem::make(AlertRuleResource::getNavigationLabel()) + ->url(fn (): string => AlertRuleResource::getUrl(panel: 'admin')) + ->icon(AlertRuleResource::getNavigationIcon()) + ->visible(fn (): bool => AlertRuleResource::canViewAny()), + NavigationItem::make(AlertDeliveryResource::getNavigationLabel()) + ->url(fn (): string => AlertDeliveryResource::getUrl(panel: 'admin')) + ->icon(AlertDeliveryResource::getNavigationIcon()) + ->visible(fn (): bool => AlertDeliveryResource::canViewAny()), + ])), + NavigationItem::make(__('localization.navigation.audit_log')) + ->url(fn (): string => route('admin.monitoring.audit-log')) + ->icon('heroicon-o-clipboard-document-list'), + ])), + NavigationGroup::make(__('localization.review.reporting')) + ->items($this->visibleItems([ + NavigationItem::make(ReviewRegister::getNavigationLabel()) + ->url(fn (): string => ReviewRegister::getUrl(panel: 'admin')) + ->icon(ReviewRegister::getNavigationIcon()), + NavigationItem::make(CustomerReviewWorkspace::getNavigationLabel()) + ->url(fn (): string => CustomerReviewWorkspace::getUrl(panel: 'admin')) + ->icon(CustomerReviewWorkspace::getNavigationIcon()), + ])), + NavigationGroup::make(__('localization.navigation.settings')) + ->items($this->visibleItems([ + NavigationItem::make(__('localization.navigation.manage_workspaces')) + ->url(fn (): string => route('filament.admin.resources.workspaces.index')) + ->icon(WorkspaceResource::getNavigationIcon()) + ->visible(fn (): bool => $this->canManageWorkspaces()), + NavigationItem::make(__('localization.navigation.integrations')) + ->url(fn (): string => ProviderConnectionResource::getUrl('index', panel: 'admin')) + ->icon(ProviderConnectionResource::getNavigationIcon()) + ->visible(fn (): bool => ProviderConnectionResource::canViewAny()) + ->childItems($this->visibleItems([ + NavigationItem::make(ProviderConnectionResource::getNavigationLabel()) + ->url(fn (): string => ProviderConnectionResource::getUrl('index', panel: 'admin')) + ->icon(ProviderConnectionResource::getNavigationIcon()) + ->visible(fn (): bool => ProviderConnectionResource::canViewAny()), + ])), + NavigationItem::make(__('localization.navigation.settings')) + ->url(fn (): string => WorkspaceSettings::getUrl(panel: 'admin')) + ->icon('heroicon-o-cog-6-tooth') + ->visible(fn (): bool => $this->canViewWorkspaceSettings()), + ])), + NavigationGroup::make(__('localization.navigation.governance')) + ->items($this->visibleItems([ + NavigationItem::make(GovernanceInbox::getNavigationLabel()) + ->url(fn (): string => GovernanceInbox::getUrl(panel: 'admin')) + ->icon(GovernanceInbox::getNavigationIcon()), + NavigationItem::make(DecisionRegister::getNavigationLabel()) + ->url(fn (): string => DecisionRegister::getUrl(panel: 'admin')) + ->icon(DecisionRegister::getNavigationIcon()) + ->visible(fn (): bool => DecisionRegister::canAccess()), + ])), + ]); + } + + /** + * @param array $items + * @return array + */ + private function visibleItems(array $items): array + { + return array_values(array_filter( + $items, + static fn (NavigationItem $item): bool => $item->isVisible(), + )); + } + + private function canManageWorkspaces(): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE); + + return WorkspaceMembership::query() + ->where('user_id', (int) $user->getKey()) + ->whereIn('role', $roles) + ->exists(); + } + + private function canViewWorkspaceSettings(): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if (! is_int($workspaceId)) { + return false; + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return false; + } + + $resolver = app(WorkspaceCapabilityResolver::class); + + return $resolver->isMember($user, $workspace) + && $resolver->can($user, $workspace, Capabilities::WORKSPACE_SETTINGS_VIEW); + } +} diff --git a/apps/platform/app/Support/OperateHub/OperateHubShell.php b/apps/platform/app/Support/OperateHub/OperateHubShell.php index a98b0e8e..2d9c4f35 100644 --- a/apps/platform/app/Support/OperateHub/OperateHubShell.php +++ b/apps/platform/app/Support/OperateHub/OperateHubShell.php @@ -169,6 +169,18 @@ private function buildResolvedContext(?Request $request = null): ResolvedShellCo ); } + if ($pageCategory->forcesTenantlessShellContext()) { + return new ResolvedShellContext( + workspace: $workspace, + tenant: null, + pageCategory: $pageCategory, + state: 'tenantless_workspace', + displayMode: 'tenantless', + workspaceSource: $workspaceSource, + recoveryReason: $recoveryReason, + ); + } + $queryHintTenant = $this->resolveValidatedQueryHintTenant($request, $workspace, $pageCategory); if ($queryHintTenant['tenant'] instanceof ManagedEnvironment) { diff --git a/apps/platform/app/Support/Tenants/TenantInteractionLane.php b/apps/platform/app/Support/Tenants/TenantInteractionLane.php index 11fd2e55..f5312e55 100644 --- a/apps/platform/app/Support/Tenants/TenantInteractionLane.php +++ b/apps/platform/app/Support/Tenants/TenantInteractionLane.php @@ -18,6 +18,7 @@ public static function fromPageCategory(TenantPageCategory $pageCategory): self TenantPageCategory::TenantBound, TenantPageCategory::TenantScopedEvidence => self::AdministrativeManagement, TenantPageCategory::CanonicalWorkspaceRecordViewer => self::CanonicalWorkspaceRecord, + TenantPageCategory::WorkspaceWideSurface, TenantPageCategory::WorkspaceScoped, TenantPageCategory::WorkspaceChooserException => self::StandardActiveOperating, }; diff --git a/apps/platform/app/Support/Tenants/TenantPageCategory.php b/apps/platform/app/Support/Tenants/TenantPageCategory.php index b3744389..7233cadd 100644 --- a/apps/platform/app/Support/Tenants/TenantPageCategory.php +++ b/apps/platform/app/Support/Tenants/TenantPageCategory.php @@ -8,6 +8,7 @@ enum TenantPageCategory: string { + case WorkspaceWideSurface = 'workspace_wide_surface'; case WorkspaceScoped = 'workspace_scoped'; case WorkspaceChooserException = 'workspace_chooser_exception'; case TenantBound = 'tenant_bound'; @@ -21,7 +22,7 @@ public static function fromRequest(?Request $request = null): self return self::WorkspaceScoped; } - return self::fromPath('/'.ltrim($request->path(), '/')); + return self::fromPath(self::effectivePath($request)); } public static function fromPath(string $path): self @@ -36,6 +37,10 @@ public static function fromPath(string $path): self return self::CanonicalWorkspaceRecordViewer; } + if (self::isWorkspaceWideSurfacePath($normalizedPath)) { + return self::WorkspaceWideSurface; + } + if ( str_starts_with($normalizedPath, '/admin/evidence/') && ! str_starts_with($normalizedPath, '/admin/evidence/overview') @@ -57,7 +62,7 @@ public static function fromPath(string $path): self public function allowsQueryTenantHints(): bool { return match ($this) { - self::WorkspaceScoped, self::OnboardingWorkflow => true, + self::WorkspaceWideSurface, self::WorkspaceScoped, self::OnboardingWorkflow => true, default => false, }; } @@ -73,6 +78,7 @@ public function allowsRememberedTenantRestore(): bool public function allowsTenantlessState(): bool { return match ($this) { + self::WorkspaceWideSurface, self::WorkspaceScoped, self::WorkspaceChooserException, self::OnboardingWorkflow, @@ -81,6 +87,16 @@ public function allowsTenantlessState(): bool }; } + public function forcesTenantlessShellContext(): bool + { + return match ($this) { + self::WorkspaceWideSurface, + self::WorkspaceChooserException, + self::CanonicalWorkspaceRecordViewer => true, + default => false, + }; + } + public function requiresExplicitTenant(): bool { return match ($this) { @@ -93,4 +109,33 @@ public function lane(): TenantInteractionLane { return TenantInteractionLane::fromPageCategory($this); } + + private static function isWorkspaceWideSurfacePath(string $normalizedPath): bool + { + if (preg_match('#^/admin/workspaces/[^/]+/(?:overview|operations)(?:/|$)#', $normalizedPath) === 1) { + return true; + } + + return preg_match('#^/admin/(?:alerts|audit-log|evidence/overview|governance/(?:decisions|inbox)|provider-connections|reviews(?:/workspace)?)(?:/|$)#', $normalizedPath) === 1; + } + + private static function effectivePath(Request $request): string + { + $path = '/'.ltrim((string) $request->path(), '/'); + + if (! self::isLivewireRequestPath($path) && ! $request->headers->has('x-livewire')) { + return $path; + } + + $refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH); + + return is_string($refererPath) && $refererPath !== '' + ? '/'.ltrim($refererPath, '/') + : $path; + } + + private static function isLivewireRequestPath(string $path): bool + { + return preg_match('#^/(?:livewire(?:-[^/]+)?/update|livewire-unit-test-endpoint)(?:/|$)#', $path) === 1; + } } diff --git a/apps/platform/routes/web.php b/apps/platform/routes/web.php index 54d4cb3a..5079e2f6 100644 --- a/apps/platform/routes/web.php +++ b/apps/platform/routes/web.php @@ -394,6 +394,7 @@ DispatchServingFilamentEvent::class, FilamentAuthenticate::class, 'ensure-workspace-member', + 'ensure-filament-tenant-selected', ]) ->prefix('/admin/workspaces/{workspace}') ->group(function (): void { diff --git a/apps/platform/tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php b/apps/platform/tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php index f0d00ef5..ede88673 100644 --- a/apps/platform/tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php +++ b/apps/platform/tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Filament\Resources\AlertDeliveryResource; use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries; use App\Models\AlertDelivery; use App\Models\AlertDestination; @@ -199,7 +200,7 @@ function alertDeliveryFilterIndicatorLabels($component): array ->assertCanNotSeeTableRecords([$failedDelivery]); }); -it('replaces the persisted tenant filter when canonical tenant context changes', function (): void { +it('keeps persisted alert delivery filters tenantless when remembered tenant context changes', function (): void { $tenantA = ManagedEnvironment::factory()->create(); [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); @@ -244,11 +245,12 @@ function alertDeliveryFilterIndicatorLabels($component): array (string) $workspaceId => (int) $tenantA->getKey(), ]); - $component = Livewire::test(ListAlertDeliveries::class) + $component = Livewire::withHeaders(['referer' => AlertDeliveryResource::getUrl(panel: 'admin')]) + ->test(ListAlertDeliveries::class) ->filterTable('status', AlertDelivery::STATUS_SENT); expect(data_get(session()->get($component->instance()->getTableFiltersSessionKey()), 'managed_environment_id.value')) - ->toBe((string) $tenantA->getKey()); + ->toBeNull(); expect(data_get(session()->get($component->instance()->getTableFiltersSessionKey()), 'status.value')) ->toBe(AlertDelivery::STATUS_SENT); @@ -256,11 +258,11 @@ function alertDeliveryFilterIndicatorLabels($component): array (string) $workspaceId => (int) $tenantB->getKey(), ]); - Livewire::test(ListAlertDeliveries::class) - ->assertSet('tableFilters.managed_environment_id.value', (string) $tenantB->getKey()) + Livewire::withHeaders(['referer' => AlertDeliveryResource::getUrl(panel: 'admin')]) + ->test(ListAlertDeliveries::class) + ->assertSet('tableFilters.managed_environment_id.value', null) ->assertSet('tableFilters.status.value', AlertDelivery::STATUS_SENT) - ->assertCanSeeTableRecords([$deliveryB]) - ->assertCanNotSeeTableRecords([$deliveryA]); + ->assertCanSeeTableRecords([$deliveryA, $deliveryB]); }); it('includes tenantless test deliveries in the list', function (): void { diff --git a/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewRegisterPrefilterTest.php b/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewRegisterPrefilterTest.php index 3a8730e8..07c8c107 100644 --- a/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewRegisterPrefilterTest.php +++ b/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewRegisterPrefilterTest.php @@ -7,7 +7,7 @@ use App\Support\Workspaces\WorkspaceContext; use Livewire\Livewire; -it('defaults the canonical review register to the remembered tenant when tenant context is available', function (): void { +it('keeps the canonical review register unfiltered when remembered tenant context is available', function (): void { $tenantA = ManagedEnvironment::factory()->create(['name' => 'Alpha ManagedEnvironment']); [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); @@ -38,14 +38,13 @@ (string) $tenantA->workspace_id => (int) $tenantB->getKey(), ]); - Livewire::actingAs($user) + Livewire::withHeaders(['referer' => ReviewRegister::getUrl(panel: 'admin')]) + ->actingAs($user) ->test(ReviewRegister::class) - ->assertSet('tableFilters.managed_environment_id.value', (string) $tenantB->getKey()) - ->assertCanSeeTableRecords([$reviewB]) - ->assertCanNotSeeTableRecords([$reviewA]) + ->assertSet('tableFilters.managed_environment_id.value', null) + ->assertCanSeeTableRecords([$reviewA, $reviewB]) ->assertSee('Publication blocked') - ->assertSee('Resolve the review blockers before publication') - ->assertDontSee('Publishable'); + ->assertSee('Resolve the review blockers before publication'); }); it('prefilters the review register from a tenant query parameter and accepts external tenant identifiers', function (): void { @@ -76,7 +75,8 @@ setAdminPanelContext(); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id); - Livewire::withQueryParams(['tenant' => (string) $tenantA->external_id]) + Livewire::withHeaders(['referer' => ReviewRegister::getUrl(panel: 'admin')]) + ->withQueryParams(['tenant' => (string) $tenantA->external_id]) ->test(ReviewRegister::class) ->assertSet('tableFilters.managed_environment_id.value', (string) $tenantA->getKey()) ->assertCanSeeTableRecords([$reviewA]) @@ -105,7 +105,9 @@ setAdminPanelContext(); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id); - $component = Livewire::actingAs($user)->test(ReviewRegister::class); + $component = Livewire::withHeaders(['referer' => ReviewRegister::getUrl(panel: 'admin')]) + ->actingAs($user) + ->test(ReviewRegister::class); $tenantFilter = $component->instance()->getTable()->getFilters()['managed_environment_id'] ?? null; expect($tenantFilter)->not->toBeNull() diff --git a/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewRegisterTest.php b/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewRegisterTest.php index acab06b4..0ffbc060 100644 --- a/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewRegisterTest.php +++ b/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewRegisterTest.php @@ -38,7 +38,8 @@ setAdminPanelContext(); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id); - Livewire::actingAs($user) + Livewire::withHeaders(['referer' => ReviewRegister::getUrl(panel: 'admin')]) + ->actingAs($user) ->test(ReviewRegister::class) ->assertSee('Outcome') ->assertDontSee('Monitoring landing') @@ -58,7 +59,8 @@ setAdminPanelContext(); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); - Livewire::actingAs($user) + Livewire::withHeaders(['referer' => ReviewRegister::getUrl(panel: 'admin')]) + ->actingAs($user) ->test(ReviewRegister::class) ->searchTable('no-such-review-row') ->assertCanNotSeeTableRecords([$review]) @@ -67,7 +69,7 @@ ->assertSee('Clear filters'); }); -it('clears the remembered tenant prefilter from the review register', function (): void { +it('clears only the page-level tenant filter from the review register', function (): void { $tenantA = ManagedEnvironment::factory()->create(['name' => 'Alpha ManagedEnvironment']); [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); @@ -87,8 +89,10 @@ (string) $tenantA->workspace_id => (int) $tenantA->getKey(), ]); - $component = Livewire::actingAs($user) + $component = Livewire::withHeaders(['referer' => ReviewRegister::getUrl(panel: 'admin')]) + ->withQueryParams(['tenant' => (string) $tenantA->external_id]) ->test(ReviewRegister::class) + ->assertSet('tableFilters.managed_environment_id.value', (string) $tenantA->getKey()) ->assertActionVisible('clear_filters') ->assertCanSeeTableRecords([$reviewA]) ->assertCanNotSeeTableRecords([$reviewB]); @@ -100,7 +104,7 @@ ->assertActionHidden('clear_filters') ->assertCanSeeTableRecords([$reviewA, $reviewB]); - expect(app(WorkspaceContext::class)->lastTenantId())->toBeNull(); + expect(app(WorkspaceContext::class)->lastTenantId())->toBe((int) $tenantA->getKey()); }); it('keeps stale and partial review rows aligned with environment review detail trust', function (): void { @@ -157,7 +161,8 @@ setAdminPanelContext(); session()->put(WorkspaceContext::SESSION_KEY, (int) $staleTenant->workspace_id); - Livewire::actingAs($user) + Livewire::withHeaders(['referer' => ReviewRegister::getUrl(panel: 'admin')]) + ->actingAs($user) ->test(ReviewRegister::class) ->assertCanSeeTableRecords([$staleReview, $partialReview]) ->assertSee('Internal only') diff --git a/apps/platform/tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php b/apps/platform/tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php index f603d805..95cd40ab 100644 --- a/apps/platform/tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php +++ b/apps/platform/tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php @@ -203,7 +203,7 @@ function getAlertDeliveryHeaderAction(Testable $component, string $name): ?Actio ->assertForbidden(); }); -it('keeps persisted alert delivery filters inside the active tenant scope', function (): void { +it('keeps persisted alert delivery filters inside the workspace-wide alert delivery scope', function (): void { $tenantA = ManagedEnvironment::factory()->create(); [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); @@ -242,29 +242,30 @@ function getAlertDeliveryHeaderAction(Testable $component, string $name): ?Actio $this->actingAs($user); Filament::setTenant($tenantA, true); - Livewire::test(ListAlertDeliveries::class) + Livewire::withHeaders(['referer' => AlertDeliveryResource::getUrl(panel: 'admin')]) + ->test(ListAlertDeliveries::class) ->filterTable('status', AlertDelivery::STATUS_SENT) - ->assertCanSeeTableRecords([$tenantADelivery]) - ->assertCanNotSeeTableRecords([$tenantBDelivery]); + ->assertCanSeeTableRecords([$tenantADelivery, $tenantBDelivery]); - Livewire::test(ListAlertDeliveries::class) + Livewire::withHeaders(['referer' => AlertDeliveryResource::getUrl(panel: 'admin')]) + ->test(ListAlertDeliveries::class) ->assertSet('tableFilters.status.value', AlertDelivery::STATUS_SENT) - ->assertCanSeeTableRecords([$tenantADelivery]) - ->assertCanNotSeeTableRecords([$tenantBDelivery]); + ->assertCanSeeTableRecords([$tenantADelivery, $tenantBDelivery]); }); -it('preselects the tenant filter when a tenant context exists', function (): void { +it('does not preselect the tenant filter when a tenant context exists on the workspace-wide alert delivery list', function (): void { $tenant = ManagedEnvironment::factory()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $this->actingAs($user); Filament::setTenant($tenant, true); - Livewire::test(ListAlertDeliveries::class) - ->assertSet('tableFilters.managed_environment_id.value', (string) $tenant->getKey()); + Livewire::withHeaders(['referer' => AlertDeliveryResource::getUrl(panel: 'admin')]) + ->test(ListAlertDeliveries::class) + ->assertSet('tableFilters.managed_environment_id.value', null); }); -it('scopes alert deliveries to the remembered tenant context when filament tenant is absent', function (): void { +it('keeps alert deliveries workspace-wide when only remembered tenant context exists', function (): void { $tenantA = ManagedEnvironment::factory()->create(); [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); @@ -306,8 +307,8 @@ function getAlertDeliveryHeaderAction(Testable $component, string $name): ?Actio $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => $workspaceId]); - Livewire::test(ListAlertDeliveries::class) - ->assertSet('tableFilters.managed_environment_id.value', (string) $tenantA->getKey()) - ->assertCanSeeTableRecords([$tenantADelivery]) - ->assertCanNotSeeTableRecords([$tenantBDelivery]); + Livewire::withHeaders(['referer' => AlertDeliveryResource::getUrl(panel: 'admin')]) + ->test(ListAlertDeliveries::class) + ->assertSet('tableFilters.managed_environment_id.value', null) + ->assertCanSeeTableRecords([$tenantADelivery, $tenantBDelivery]); }); diff --git a/apps/platform/tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php b/apps/platform/tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php index 1f0c3aca..ebd81a01 100644 --- a/apps/platform/tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php +++ b/apps/platform/tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php @@ -24,7 +24,7 @@ function alertsKpiValues($component): array ->all(); } -it('filters KPI deliveries by tenant when context is set via lastTenantId fallback only', function (): void { +it('shows workspace-wide KPI deliveries when context is set via lastTenantId fallback only', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); $workspaceId = (int) $tenant->workspace_id; @@ -63,15 +63,15 @@ function alertsKpiValues($component): array (string) $workspaceId => (int) $tenant->getKey(), ]); - $values = alertsKpiValues(Livewire::test(AlertsKpiHeader::class)); + $values = alertsKpiValues(Livewire::withHeaders(['referer' => route('filament.admin.alerts')])->test(AlertsKpiHeader::class)); expect($values)->toMatchArray([ - 'Deliveries (24h)' => '1', + 'Deliveries (24h)' => '2', 'Failed (7d)' => '0', ]); })->group('ops-ux'); -it('filters KPI deliveries by tenant when context is set via Filament setTenant', function (): void { +it('shows workspace-wide KPI deliveries when context is set via Filament setTenant', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); $workspaceId = (int) $tenant->workspace_id; @@ -107,10 +107,10 @@ function alertsKpiValues($component): array session()->put(WorkspaceContext::SESSION_KEY, $workspaceId); - $values = alertsKpiValues(Livewire::test(AlertsKpiHeader::class)); + $values = alertsKpiValues(Livewire::withHeaders(['referer' => route('filament.admin.alerts')])->test(AlertsKpiHeader::class)); expect($values)->toMatchArray([ - 'Deliveries (24h)' => '1', + 'Deliveries (24h)' => '2', 'Failed (7d)' => '0', ]); })->group('ops-ux'); @@ -151,7 +151,7 @@ function alertsKpiValues($component): array session()->put(WorkspaceContext::SESSION_KEY, $workspaceId); - $values = alertsKpiValues(Livewire::test(AlertsKpiHeader::class)); + $values = alertsKpiValues(Livewire::withHeaders(['referer' => route('filament.admin.alerts')])->test(AlertsKpiHeader::class)); expect($values)->toMatchArray([ 'Deliveries (24h)' => '2', @@ -159,7 +159,7 @@ function alertsKpiValues($component): array ]); })->group('ops-ux'); -it('prefers the Filament tenant over remembered tenant fallback in KPI scope conflicts', function (): void { +it('keeps KPI deliveries workspace-wide when Filament and remembered tenant context conflict', function (): void { [$user, $tenantA] = createUserWithTenant(role: 'owner'); $workspaceId = (int) $tenantA->workspace_id; @@ -198,10 +198,10 @@ function alertsKpiValues($component): array (string) $workspaceId => (int) $tenantA->getKey(), ]); - $values = alertsKpiValues(Livewire::test(AlertsKpiHeader::class)); + $values = alertsKpiValues(Livewire::withHeaders(['referer' => route('filament.admin.alerts')])->test(AlertsKpiHeader::class)); expect($values)->toMatchArray([ - 'Deliveries (24h)' => '1', + 'Deliveries (24h)' => '2', 'Failed (7d)' => '1', ]); })->group('ops-ux'); diff --git a/apps/platform/tests/Feature/Filament/AuditLogPageTest.php b/apps/platform/tests/Feature/Filament/AuditLogPageTest.php index d53273ea..d12b1a76 100644 --- a/apps/platform/tests/Feature/Filament/AuditLogPageTest.php +++ b/apps/platform/tests/Feature/Filament/AuditLogPageTest.php @@ -18,7 +18,9 @@ function auditLogPageTestComponent(User $user, ?ManagedEnvironment $tenant = nul test()->actingAs($user); Filament::setTenant($tenant, true); - return Livewire::actingAs($user)->test(AuditLogPage::class); + return Livewire::withHeaders(['referer' => route('admin.monitoring.audit-log')]) + ->actingAs($user) + ->test(AuditLogPage::class); } function auditLogPageTestRecord(?ManagedEnvironment $tenant, array $attributes = []): AuditLogModel @@ -179,7 +181,7 @@ function auditLogPageTestRecord(?ManagedEnvironment $tenant, array $attributes = ->assertCanNotSeeTableRecords([$newest, $oldest]); }); -it('preselects the active tenant as the default audit filter', function (): void { +it('keeps the audit log unfiltered when an active tenant context exists', function (): void { [$user, $tenantA] = createUserWithTenant(role: 'owner'); $tenantB = ManagedEnvironment::factory()->create([ @@ -188,25 +190,24 @@ function auditLogPageTestRecord(?ManagedEnvironment $tenant, array $attributes = createUserWithTenant($tenantB, $user, role: 'owner'); - $visible = auditLogPageTestRecord($tenantA, [ + $tenantARecord = auditLogPageTestRecord($tenantA, [ 'resource_id' => '201', 'summary' => 'ManagedEnvironment A verification completed', 'action' => 'verification.completed', ]); - $hidden = auditLogPageTestRecord($tenantB, [ + $tenantBRecord = auditLogPageTestRecord($tenantB, [ 'resource_id' => '202', 'summary' => 'ManagedEnvironment B verification completed', 'action' => 'verification.completed', ]); auditLogPageTestComponent($user, $tenantA) - ->assertSet('tableFilters.managed_environment_id.value', (string) $tenantA->getKey()) - ->assertCanSeeTableRecords([$visible]) - ->assertCanNotSeeTableRecords([$hidden]); + ->assertSet('tableFilters.managed_environment_id.value', null) + ->assertCanSeeTableRecords([$tenantARecord, $tenantBRecord]); }); -it('preselects the remembered tenant as the default audit filter when the filament tenant is absent', function (): void { +it('keeps the audit log unfiltered when only a remembered tenant context exists', function (): void { $tenantA = ManagedEnvironment::factory()->create([ 'name' => 'Phoenicon', 'environment' => 'dev', @@ -221,13 +222,13 @@ function auditLogPageTestRecord(?ManagedEnvironment $tenant, array $attributes = createUserWithTenant($tenantB, $user, role: 'owner'); - $visible = auditLogPageTestRecord($tenantA, [ + $tenantARecord = auditLogPageTestRecord($tenantA, [ 'resource_id' => '301', 'summary' => 'Phoenicon verification completed', 'action' => 'verification.completed', ]); - $hidden = auditLogPageTestRecord($tenantB, [ + $tenantBRecord = auditLogPageTestRecord($tenantB, [ 'resource_id' => '302', 'summary' => 'YPTW2 verification completed', 'action' => 'verification.completed', @@ -241,13 +242,14 @@ function auditLogPageTestRecord(?ManagedEnvironment $tenant, array $attributes = (string) $tenantA->workspace_id => (int) $tenantA->getKey(), ]); - Livewire::actingAs($user)->test(AuditLogPage::class) - ->assertSet('tableFilters.managed_environment_id.value', (string) $tenantA->getKey()) - ->assertCanSeeTableRecords([$visible]) - ->assertCanNotSeeTableRecords([$hidden]); + Livewire::withHeaders(['referer' => route('admin.monitoring.audit-log')]) + ->actingAs($user) + ->test(AuditLogPage::class) + ->assertSet('tableFilters.managed_environment_id.value', null) + ->assertCanSeeTableRecords([$tenantARecord, $tenantBRecord]); }); -it('replaces a stale persisted audit tenant filter when the remembered tenant context changes', function (): void { +it('clears a stale persisted audit tenant filter when the workspace shell is tenantless', function (): void { $tenantA = ManagedEnvironment::factory()->create([ 'name' => 'YPTW2', 'environment' => 'dev', @@ -282,22 +284,24 @@ function auditLogPageTestRecord(?ManagedEnvironment $tenant, array $attributes = (string) $workspaceId => (int) $tenantA->getKey(), ]); - $component = Livewire::actingAs($user)->test(AuditLogPage::class) - ->assertSet('tableFilters.managed_environment_id.value', (string) $tenantA->getKey()) - ->assertCanSeeTableRecords([$tenantARecord]) - ->assertCanNotSeeTableRecords([$tenantBRecord]); + $component = Livewire::withHeaders(['referer' => route('admin.monitoring.audit-log')]) + ->actingAs($user) + ->test(AuditLogPage::class) + ->assertSet('tableFilters.managed_environment_id.value', null) + ->assertCanSeeTableRecords([$tenantARecord, $tenantBRecord]); expect(data_get(session()->get($component->instance()->getTableFiltersSessionKey()), 'managed_environment_id.value')) - ->toBe((string) $tenantA->getKey()); + ->toBeNull(); session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [ (string) $workspaceId => (int) $tenantB->getKey(), ]); - Livewire::actingAs($user)->test(AuditLogPage::class) - ->assertSet('tableFilters.managed_environment_id.value', (string) $tenantB->getKey()) - ->assertCanSeeTableRecords([$tenantBRecord]) - ->assertCanNotSeeTableRecords([$tenantARecord]); + Livewire::withHeaders(['referer' => route('admin.monitoring.audit-log')]) + ->actingAs($user) + ->test(AuditLogPage::class) + ->assertSet('tableFilters.managed_environment_id.value', null) + ->assertCanSeeTableRecords([$tenantARecord, $tenantBRecord]); }); it('shows a clear-filters empty state when no audit rows match the current view', function (): void { diff --git a/apps/platform/tests/Feature/Filament/PanelNavigationSegregationTest.php b/apps/platform/tests/Feature/Filament/PanelNavigationSegregationTest.php index fb4db4ee..59e0c02d 100644 --- a/apps/platform/tests/Feature/Filament/PanelNavigationSegregationTest.php +++ b/apps/platform/tests/Feature/Filament/PanelNavigationSegregationTest.php @@ -3,7 +3,10 @@ declare(strict_types=1); use App\Filament\Clusters\Inventory\InventoryCluster; +use App\Filament\Pages\Governance\DecisionRegister; +use App\Filament\Pages\Governance\GovernanceInbox; use App\Filament\Pages\InventoryCoverage; +use App\Filament\Pages\Reviews\CustomerReviewWorkspace; use App\Filament\Resources\BackupScheduleResource; use App\Filament\Resources\BackupSetResource; use App\Filament\Resources\BaselineProfileResource; @@ -14,9 +17,18 @@ use App\Filament\Resources\PolicyResource; use App\Filament\Resources\PolicyVersionResource; use App\Filament\Resources\RestoreRunResource; +use App\Models\Finding; +use App\Models\FindingException; +use App\Models\FindingExceptionDecision; +use App\Models\ManagedEnvironment; +use App\Models\User; use App\Support\ManagedEnvironmentLinks; +use App\Support\Navigation\NavigationScope; use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; +use Filament\Navigation\NavigationGroup; +use Filament\Navigation\NavigationItem; +use Filament\Navigation\NavigationManager; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\Request; @@ -39,6 +51,18 @@ FindingResource::class, ]); +dataset('workspace surface paths with environment query hints', [ + '/admin/workspaces/workspace-alpha/operations?tenant=environment-alpha', + '/admin/reviews/workspace?tenant=environment-alpha', + '/admin/governance/decisions?managed_environment_id=environment-alpha', + '/admin/governance/inbox?managed_environment_id=environment-alpha', + '/admin/evidence/overview?managed_environment_id=environment-alpha', + '/admin/audit-log?tenant=environment-alpha', + '/admin/provider-connections?managed_environment_id=environment-alpha', + '/admin/alerts?tenant=environment-alpha', + '/admin/workspaces/workspace-alpha/overview?tenant=environment-alpha', +]); + function bindNavigationRequestPath(string $path): void { $request = Request::create($path); @@ -49,6 +73,53 @@ function bindNavigationRequestPath(string $path): void app()->instance('request', $request); } +function workspaceSidebarLabelsByGroup(): array +{ + return collect(app(NavigationManager::class)->get()) + ->mapWithKeys(static function (NavigationGroup $group): array { + return [ + $group->getLabel() ?? '' => collect($group->getItems()) + ->map(static fn (NavigationItem $item): string => $item->getLabel()) + ->values() + ->all(), + ]; + }) + ->all(); +} + +function seedWorkspaceSidebarVisibleDecision(ManagedEnvironment $tenant, User $actor): void +{ + $finding = Finding::factory()->for($tenant)->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + $exception = FindingException::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'finding_id' => (int) $finding->getKey(), + 'requested_by_user_id' => (int) $actor->getKey(), + 'owner_user_id' => (int) $actor->getKey(), + 'status' => FindingException::STATUS_PENDING, + 'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT, + 'request_reason' => 'Workspace sidebar composition contract', + 'requested_at' => now()->subDay(), + 'review_due_at' => now()->addDay(), + 'evidence_summary' => ['reference_count' => 0], + ]); + + $decision = $exception->decisions()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'actor_user_id' => (int) $actor->getKey(), + 'decision_type' => FindingExceptionDecision::TYPE_REQUESTED, + 'reason' => 'Visible workspace sidebar decision', + 'metadata' => [], + 'decided_at' => now()->subDay(), + ]); + + $exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save(); +} + it('hides environment-owned navigation classes on workspace surfaces', function (string $class): void { Filament::setCurrentPanel('admin'); bindNavigationRequestPath('/admin/workspaces/workspace-alpha'); @@ -56,6 +127,119 @@ function bindNavigationRequestPath(string $path): void expect($class::shouldRegisterNavigation())->toBeFalse(); })->with('environment visible navigation classes'); +it('keeps workspace surface navigation independent from environment query hints', function (string $path): void { + Filament::setCurrentPanel('admin'); + bindNavigationRequestPath($path); + + expect(NavigationScope::isWorkspaceSurface())->toBeTrue() + ->and(NavigationScope::isEnvironmentSurface())->toBeFalse(); +})->with('workspace surface paths with environment query hints'); + +it('uses the canonical grouped workspace sidebar on representative workspace-wide surfaces', function (string $surface, callable $urlFactory): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + seedWorkspaceSidebarVisibleDecision($tenant, $user); + + Filament::setTenant($tenant, true); + + $workspace = $tenant->workspace()->firstOrFail(); + $url = $urlFactory($workspace, $tenant); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [ + (string) $workspace->getKey() => (int) $tenant->getKey(), + ], + ]) + ->get($url) + ->assertOk(); + + expect(workspaceSidebarLabelsByGroup())->toBe([ + '' => ['Overview'], + 'Monitoring' => ['Finding exceptions', 'Operations', 'Alerts', 'Audit Log'], + 'Reporting' => ['Reviews', 'Customer reviews'], + 'Settings' => ['Manage workspaces', 'Integrations', 'Settings'], + 'Governance' => ['Governance inbox', 'Decision register'], + ]); +})->with([ + 'workspace overview' => [ + 'workspace overview', + fn ($workspace): string => route('admin.workspace.home', ['workspace' => $workspace]), + ], + 'operations' => [ + 'operations', + fn ($workspace): string => route('admin.operations.index', ['workspace' => $workspace]), + ], + 'customer reviews' => [ + 'customer reviews', + fn (): string => CustomerReviewWorkspace::getUrl(panel: 'admin'), + ], + 'governance inbox' => [ + 'governance inbox', + fn (): string => GovernanceInbox::getUrl(panel: 'admin'), + ], + 'decision register' => [ + 'decision register', + fn (): string => DecisionRegister::getUrl(panel: 'admin'), + ], +]); + +it('keeps the grouped workspace sidebar when environment query filters are present', function (string $surface, callable $urlFactory): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + seedWorkspaceSidebarVisibleDecision($tenant, $user); + + Filament::setTenant($tenant, true); + + $workspace = $tenant->workspace()->firstOrFail(); + $url = $urlFactory($workspace, $tenant); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [ + (string) $workspace->getKey() => (int) $tenant->getKey(), + ], + ]) + ->get($url) + ->assertOk(); + + expect(workspaceSidebarLabelsByGroup())->toBe([ + '' => ['Overview'], + 'Monitoring' => ['Finding exceptions', 'Operations', 'Alerts', 'Audit Log'], + 'Reporting' => ['Reviews', 'Customer reviews'], + 'Settings' => ['Manage workspaces', 'Integrations', 'Settings'], + 'Governance' => ['Governance inbox', 'Decision register'], + ]); +})->with([ + 'operations with tenant query' => [ + 'operations', + fn ($workspace, ManagedEnvironment $tenant): string => route('admin.operations.index', [ + 'workspace' => $workspace, + 'tenant' => (string) $tenant->external_id, + ]), + ], + 'customer reviews with tenant query' => [ + 'customer reviews', + fn ($workspace, ManagedEnvironment $tenant): string => CustomerReviewWorkspace::getUrl(panel: 'admin', parameters: [ + 'tenant' => (string) $tenant->external_id, + ]), + ], + 'decision register with environment query' => [ + 'decision register', + fn ($workspace, ManagedEnvironment $tenant): string => DecisionRegister::getUrl(panel: 'admin', parameters: [ + 'managed_environment_id' => (string) $tenant->getKey(), + ]), + ], +]); + +it('keeps environment navigation on canonical environment routes even when query filters are present', function (): void { + Filament::setCurrentPanel('admin'); + bindNavigationRequestPath('/admin/workspaces/workspace-alpha/environments/environment-alpha/inventory?tenant=other-environment'); + + expect(NavigationScope::isEnvironmentSurface())->toBeTrue() + ->and(NavigationScope::isWorkspaceSurface())->toBeFalse(); +}); + it('registers environment-owned surfaces only on environment surfaces', function (string $class): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); diff --git a/apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php b/apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php index 21564d78..41b79b0c 100644 --- a/apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php +++ b/apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php @@ -66,7 +66,7 @@ ->assertDontSee('name="workspace_id"', escape: false); }); -test('workspace-scoped operations honor a valid tenant query hint over remembered tenant context', function () { +test('workspace-wide operations keep shell scope tenantless when a valid tenant query filter is present', function () { $rememberedTenant = ManagedEnvironment::factory()->create([ 'workspace_id' => null, 'status' => 'active', @@ -95,6 +95,8 @@ ]) ->get(route('admin.operations.index', ['workspace' => $workspaceId, 'managed_environment_id' => (int) $hintedTenant->getKey()])) ->assertOk() - ->assertSee(__('localization.shell.environment_scope').': Hinted Topbar ManagedEnvironment') + ->assertSee(__('localization.shell.no_environment_selected')) + ->assertSee(__('localization.shell.all_environments')) + ->assertDontSee(__('localization.shell.environment_scope').': Hinted Topbar ManagedEnvironment') ->assertDontSee(__('localization.shell.environment_scope').': Remembered Topbar ManagedEnvironment'); }); diff --git a/apps/platform/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php b/apps/platform/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php index 23b9b368..40daac49 100644 --- a/apps/platform/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php +++ b/apps/platform/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php @@ -129,7 +129,7 @@ ->assertSee('This tenant is currently onboarding'); }); -it('defaults the tenant filter from tenant context and can be cleared', function (): void { +it('keeps tenant context out of the operations filter unless an explicit page query is present', function (): void { $tenantA = ManagedEnvironment::factory()->create(); [$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner'); @@ -165,23 +165,18 @@ WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id, ]); - $component = Livewire::actingAs($user) + Livewire::withHeaders(['referer' => route('admin.operations.index', ['workspace' => $tenantA->workspace])]) + ->actingAs($user) + ->test(Operations::class) + ->assertCanSeeTableRecords([$runA, $runB]) + ->assertSet('tableFilters.managed_environment_id.value', null); + + Livewire::withHeaders(['referer' => route('admin.operations.index', ['workspace' => $tenantA->workspace])]) + ->withQueryParams(['managed_environment_id' => (string) $tenantA->getKey()]) ->test(Operations::class) ->assertCanSeeTableRecords([$runA]) ->assertCanNotSeeTableRecords([$runB]) ->assertSet('tableFilters.managed_environment_id.value', (string) $tenantA->getKey()); - - $component - ->callAction('operate_hub_show_all_tenants') - ->assertSet('tableFilters.managed_environment_id.value', null) - ->assertRedirect(OperationRunLinks::index(allTenants: true)); - - Filament::setTenant(null, true); - - Livewire::actingAs($user) - ->test(Operations::class) - ->assertSee('TenantA') - ->assertSee('TenantB'); }); it('shows an explicit back-link when canonical context is present on the operations index', function (): void { diff --git a/apps/platform/tests/Feature/Monitoring/OperationsKpiHeaderTenantContextTest.php b/apps/platform/tests/Feature/Monitoring/OperationsKpiHeaderTenantContextTest.php index 744bcd71..eca957a2 100644 --- a/apps/platform/tests/Feature/Monitoring/OperationsKpiHeaderTenantContextTest.php +++ b/apps/platform/tests/Feature/Monitoring/OperationsKpiHeaderTenantContextTest.php @@ -27,7 +27,7 @@ function operationsKpiValues($component): array ->all(); } -it('filters operations KPI stats by remembered tenant when filament tenant is absent', function (): void { +it('shows workspace-wide operations KPI stats when remembered tenant context exists', function (): void { $tenantA = ManagedEnvironment::factory()->create(); [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); @@ -68,16 +68,18 @@ function operationsKpiValues($component): array (string) $tenantA->workspace_id => (int) $tenantA->getKey(), ]); - $values = operationsKpiValues(Livewire::test(OperationsKpiHeader::class)); + $values = operationsKpiValues(Livewire::withHeaders([ + 'referer' => route('admin.operations.index', ['workspace' => $tenantA->workspace]), + ])->test(OperationsKpiHeader::class)); expect($values)->toMatchArray([ - 'Total Operations (30 days)' => '2', - 'Active Operations' => '1', + 'Total Operations (30 days)' => '3', + 'Active Operations' => '2', 'Failed/Partial (7 days)' => '1', ]); }); -it('prefers the filament tenant over remembered tenant in conflicting KPI context', function (): void { +it('shows workspace-wide operations KPI stats when filament tenant context exists', function (): void { $tenantA = ManagedEnvironment::factory()->create(); [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); @@ -111,11 +113,13 @@ function operationsKpiValues($component): array (string) $tenantA->workspace_id => (int) $tenantA->getKey(), ]); - $values = operationsKpiValues(Livewire::test(OperationsKpiHeader::class)); + $values = operationsKpiValues(Livewire::withHeaders([ + 'referer' => route('admin.operations.index', ['workspace' => $tenantA->workspace]), + ])->test(OperationsKpiHeader::class)); expect($values)->toMatchArray([ - 'Total Operations (30 days)' => '1', - 'Active Operations' => '0', + 'Total Operations (30 days)' => '2', + 'Active Operations' => '1', 'Failed/Partial (7 days)' => '0', ]); }); diff --git a/apps/platform/tests/Feature/Monitoring/OperationsTenantScopeTest.php b/apps/platform/tests/Feature/Monitoring/OperationsTenantScopeTest.php index c462818c..12d95490 100644 --- a/apps/platform/tests/Feature/Monitoring/OperationsTenantScopeTest.php +++ b/apps/platform/tests/Feature/Monitoring/OperationsTenantScopeTest.php @@ -11,7 +11,7 @@ Http::preventStrayRequests(); }); -it('defaults Monitoring → Operations list to the active tenant when tenant context is set', function () { +it('treats active tenant context as shell-only and filters operations only from explicit page query state', function () { $tenantA = ManagedEnvironment::factory()->create(); $tenantB = ManagedEnvironment::factory()->create(); @@ -41,7 +41,9 @@ $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id]); session([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id]); - Livewire::actingAs($user) + Livewire::withHeaders(['referer' => route('admin.operations.index', ['workspace' => $tenantA->workspace])]) + ->actingAs($user) + ->withQueryParams(['managed_environment_id' => (string) $tenantA->getKey()]) ->test(Operations::class) ->assertCanSeeTableRecords([$runA]) ->assertCanNotSeeTableRecords([$runB]) @@ -52,10 +54,12 @@ ->get(\App\Support\OperationRunLinks::index()) ->assertOk() ->assertSee('Policy sync') - ->assertSee($tenantA->name); + ->assertSee('Inventory sync') + ->assertSee(__('localization.shell.all_environments')) + ->assertDontSee(__('localization.shell.environment_scope').': '.$tenantA->name); }); -it('defaults Monitoring → Operations list to the remembered tenant when Filament tenant is not available', function () { +it('does not default Monitoring → Operations list to the remembered tenant', function () { $tenantA = ManagedEnvironment::factory()->create(); $tenantB = ManagedEnvironment::factory()->create(); @@ -88,21 +92,24 @@ $this->withSession([WorkspaceContext::SESSION_KEY => $workspaceId]); session([WorkspaceContext::SESSION_KEY => $workspaceId]); - Livewire::actingAs($user) + Livewire::withHeaders(['referer' => route('admin.operations.index', ['workspace' => $tenantA->workspace])]) + ->actingAs($user) ->test(Operations::class) - ->assertCanSeeTableRecords([$runA]) - ->assertCanNotSeeTableRecords([$runB]) - ->assertSet('tableFilters.managed_environment_id.value', (string) $tenantA->getKey()); + ->assertCanSeeTableRecords([$runA, $runB]) + ->assertSet('tableFilters.managed_environment_id.value', null); $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => $workspaceId]) ->get(\App\Support\OperationRunLinks::index()) ->assertOk() ->assertSee($tenantA->name) - ->assertSee('Policy sync'); + ->assertSee('Policy sync') + ->assertSee('Inventory sync') + ->assertSee(__('localization.shell.all_environments')) + ->assertDontSee(__('localization.shell.environment_scope').': '.$tenantA->name); }); -it('scopes Monitoring → Operations tabs to the active tenant', function () { +it('scopes Monitoring → Operations tabs to the workspace unless an explicit page filter is active', function () { $tenantA = ManagedEnvironment::factory()->create(); $tenantB = ManagedEnvironment::factory()->create(); @@ -185,19 +192,19 @@ WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id, ]); - Livewire::actingAs($user) + Livewire::withHeaders(['referer' => route('admin.operations.index', ['workspace' => $tenantA->workspace])]) + ->actingAs($user) ->test(Operations::class) - ->assertCanSeeTableRecords([$runActiveA, $runStaleA, $runSucceededA, $runPartialA, $runBlockedA, $runFailedA]) - ->assertCanNotSeeTableRecords([$runActiveB, $runFailedB]) + ->assertCanSeeTableRecords([$runActiveA, $runStaleA, $runSucceededA, $runPartialA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB]) ->set('activeTab', 'active') - ->assertCanSeeTableRecords([$runActiveA]) - ->assertCanNotSeeTableRecords([$runStaleA, $runSucceededA, $runPartialA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB]) + ->assertCanSeeTableRecords([$runActiveA, $runActiveB]) + ->assertCanNotSeeTableRecords([$runStaleA, $runSucceededA, $runPartialA, $runBlockedA, $runFailedA, $runFailedB]) ->set('activeTab', OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION) ->assertCanSeeTableRecords([$runStaleA]) ->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB]) ->set('activeTab', OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP) - ->assertCanSeeTableRecords([$runPartialA, $runBlockedA, $runFailedA]) - ->assertCanNotSeeTableRecords([$runActiveA, $runStaleA, $runSucceededA, $runActiveB, $runFailedB]) + ->assertCanSeeTableRecords([$runPartialA, $runBlockedA, $runFailedA, $runFailedB]) + ->assertCanNotSeeTableRecords([$runActiveA, $runStaleA, $runSucceededA, $runActiveB]) ->set('activeTab', 'succeeded') ->assertCanSeeTableRecords([$runSucceededA]) ->assertCanNotSeeTableRecords([$runActiveA, $runStaleA, $runPartialA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB]) @@ -205,8 +212,8 @@ ->assertCanSeeTableRecords([$runPartialA]) ->assertCanNotSeeTableRecords([$runActiveA, $runStaleA, $runSucceededA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB]) ->set('activeTab', 'failed') - ->assertCanSeeTableRecords([$runFailedA]) - ->assertCanNotSeeTableRecords([$runActiveA, $runStaleA, $runSucceededA, $runPartialA, $runBlockedA, $runActiveB, $runFailedB]); + ->assertCanSeeTableRecords([$runFailedA, $runFailedB]) + ->assertCanNotSeeTableRecords([$runActiveA, $runStaleA, $runSucceededA, $runPartialA, $runBlockedA, $runActiveB]); $this->actingAs($user) ->withSession([ diff --git a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php index 4d46e4f7..4145dc81 100644 --- a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php +++ b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php @@ -232,7 +232,7 @@ ->assertDontSee('Accepted risk influences this view'); }); -it('defaults the customer review workspace to the remembered tenant when tenant context is available', function (): void { +it('keeps the customer review workspace unfiltered when remembered tenant context is available', function (): void { $tenantA = ManagedEnvironment::factory()->create(['name' => 'Alpha ManagedEnvironment']); [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'readonly'); @@ -268,7 +268,8 @@ Livewire::actingAs($user) ->test(CustomerReviewWorkspace::class) - ->assertSet('tableFilters.managed_environment_id.value', (string) $tenantB->getKey()) + ->assertSet('tableFilters.managed_environment_id.value', null) + ->assertCanSeeTableRecords([$tenantA->fresh(), $tenantB->fresh()]) ->filterTable('managed_environment_id', (string) $tenantB->getKey()) ->assertCanSeeTableRecords([$tenantB->fresh()]) ->assertCanNotSeeTableRecords([$tenantA->fresh()]); diff --git a/apps/platform/tests/Feature/Spec085/OperationsIndexHeaderTest.php b/apps/platform/tests/Feature/Spec085/OperationsIndexHeaderTest.php index 704d22eb..c7a157fe 100644 --- a/apps/platform/tests/Feature/Spec085/OperationsIndexHeaderTest.php +++ b/apps/platform/tests/Feature/Spec085/OperationsIndexHeaderTest.php @@ -14,7 +14,7 @@ Http::preventStrayRequests(); }); -it('renders tenant scope label and CTAs when tenant context is active and entitled', function (): void { +it('renders workspace scope label when tenant context is active on the workspace operations route', function (): void { $tenant = ManagedEnvironment::factory()->create(); [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); @@ -24,9 +24,11 @@ ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->get(route('admin.operations.index', ['workspace' => $tenant->workspace])) ->assertOk() - ->assertSee(__('localization.shell.environment_scope').': '.$tenant->name) - ->assertSee('Back to '.$tenant->name) - ->assertSee(__('localization.shell.show_all_environments')); + ->assertSee(__('localization.shell.all_environments')) + ->assertSee(__('localization.shell.no_environment_selected')) + ->assertDontSee(__('localization.shell.environment_scope').': '.$tenant->name) + ->assertDontSee('Back to '.$tenant->name) + ->assertDontSee(__('localization.shell.show_all_environments')); }); it('treats stale tenant context as workspace-wide without tenant identity hints', function (): void { diff --git a/apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php b/apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php index 12ba3476..8f0dc035 100644 --- a/apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php +++ b/apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php @@ -2,10 +2,15 @@ declare(strict_types=1); +use App\Filament\Pages\Governance\DecisionRegister; +use App\Filament\Pages\Governance\GovernanceInbox; use App\Filament\Pages\EnvironmentDashboard; +use App\Filament\Pages\Reviews\CustomerReviewWorkspace; +use App\Filament\Resources\ProviderConnectionResource; use App\Models\ManagedEnvironment; use App\Models\User; use App\Support\Workspaces\WorkspaceContext; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -41,3 +46,91 @@ ->assertSee(__('localization.shell.no_environment_selected')) ->assertDontSee(__('localization.shell.environment_scope').': Rejected Foreign ManagedEnvironment'); }); + +it('keeps workspace-wide surfaces tenantless when valid environment query filters are present', function (string $surface, callable $urlFactory): void { + $rememberedTenant = ManagedEnvironment::factory()->active()->create([ + 'name' => 'Remembered ManagedEnvironment', + 'external_id' => 'remembered-managed-environment', + ]); + [$user, $rememberedTenant] = createUserWithTenant(tenant: $rememberedTenant, role: 'owner'); + + $hintedTenant = ManagedEnvironment::factory()->active()->create([ + 'workspace_id' => (int) $rememberedTenant->workspace_id, + 'name' => 'Hinted ManagedEnvironment', + 'external_id' => 'hinted-managed-environment', + ]); + + createUserWithTenant(tenant: $hintedTenant, user: $user, role: 'owner'); + + Filament::setTenant($rememberedTenant, true); + + $workspace = $rememberedTenant->workspace()->firstOrFail(); + $url = $urlFactory($workspace, $hintedTenant); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [ + (string) $workspace->getKey() => (int) $rememberedTenant->getKey(), + ], + ]) + ->followingRedirects() + ->get($url) + ->assertOk() + ->assertSee(__('localization.shell.no_environment_selected')) + ->assertDontSee(__('localization.shell.environment_scope').': Hinted ManagedEnvironment') + ->assertDontSee(__('localization.shell.environment_scope').': Remembered ManagedEnvironment') + ->assertDontSee('Back to Hinted ManagedEnvironment') + ->assertDontSee('Back to Remembered ManagedEnvironment'); +})->with([ + 'operations' => [ + 'operations', + fn ($workspace, ManagedEnvironment $tenant): string => route('admin.operations.index', [ + 'workspace' => $workspace, + 'managed_environment_id' => (int) $tenant->getKey(), + ]), + ], + 'customer review workspace' => [ + 'customer review workspace', + fn ($workspace, ManagedEnvironment $tenant): string => CustomerReviewWorkspace::getUrl(panel: 'admin', parameters: [ + 'tenant' => (string) $tenant->external_id, + ]), + ], + 'decision register' => [ + 'decision register', + fn ($workspace, ManagedEnvironment $tenant): string => DecisionRegister::getUrl(panel: 'admin', parameters: [ + 'managed_environment_id' => (string) $tenant->getKey(), + ]), + ], + 'governance inbox' => [ + 'governance inbox', + fn ($workspace, ManagedEnvironment $tenant): string => GovernanceInbox::getUrl(panel: 'admin', parameters: [ + 'managed_environment_id' => (string) $tenant->getKey(), + ]), + ], + 'audit log' => [ + 'audit log', + fn ($workspace, ManagedEnvironment $tenant): string => route('admin.monitoring.audit-log', [ + 'managed_environment_id' => (int) $tenant->getKey(), + ]), + ], + 'provider connections' => [ + 'provider connections', + fn ($workspace, ManagedEnvironment $tenant): string => ProviderConnectionResource::getUrl('index', [ + 'managed_environment_id' => (string) $tenant->external_id, + ], panel: 'admin'), + ], + 'alerts' => [ + 'alerts', + fn ($workspace, ManagedEnvironment $tenant): string => route('filament.admin.alerts', [ + 'tenant' => (string) $tenant->external_id, + ]), + ], + 'workspace overview' => [ + 'workspace overview', + fn ($workspace, ManagedEnvironment $tenant): string => route('admin.workspace.home', [ + 'workspace' => $workspace, + 'tenant' => (string) $tenant->external_id, + ]), + ], +]); diff --git a/apps/platform/tests/Unit/Tenants/TenantPageCategoryTest.php b/apps/platform/tests/Unit/Tenants/TenantPageCategoryTest.php index bf696d97..ac5639ca 100644 --- a/apps/platform/tests/Unit/Tenants/TenantPageCategoryTest.php +++ b/apps/platform/tests/Unit/Tenants/TenantPageCategoryTest.php @@ -17,10 +17,16 @@ 'retired tenant panel route' => ['/admin/t/tenant-123', TenantPageCategory::WorkspaceScoped], 'workspace environment detail' => ['/admin/workspaces/acme/environments/tenant-123', TenantPageCategory::TenantBound], 'tenant scoped evidence detail' => ['/admin/evidence/123', TenantPageCategory::TenantScopedEvidence], - 'evidence overview' => ['/admin/evidence/overview', TenantPageCategory::WorkspaceScoped], + 'evidence overview' => ['/admin/evidence/overview', TenantPageCategory::WorkspaceWideSurface], + 'customer review workspace' => ['/admin/reviews/workspace', TenantPageCategory::WorkspaceWideSurface], + 'review register' => ['/admin/reviews', TenantPageCategory::WorkspaceWideSurface], + 'governance decisions' => ['/admin/governance/decisions', TenantPageCategory::WorkspaceWideSurface], + 'alerts' => ['/admin/alerts', TenantPageCategory::WorkspaceWideSurface], + 'provider connections' => ['/admin/provider-connections', TenantPageCategory::WorkspaceWideSurface], + 'workspace home overview' => ['/admin/workspaces/acme/overview', TenantPageCategory::WorkspaceWideSurface], 'onboarding index' => ['/admin/onboarding', TenantPageCategory::OnboardingWorkflow], 'onboarding draft' => ['/admin/onboarding/42', TenantPageCategory::OnboardingWorkflow], - 'operations index' => ['/admin/workspaces/acme/operations', TenantPageCategory::WorkspaceScoped], + 'operations index' => ['/admin/workspaces/acme/operations', TenantPageCategory::WorkspaceWideSurface], 'retired operation run detail' => ['/admin/operations/44', TenantPageCategory::WorkspaceScoped], 'operation run detail' => ['/admin/workspaces/acme/operations/44', TenantPageCategory::CanonicalWorkspaceRecordViewer], ]); diff --git a/docs/AGENTS-draft.md b/docs/AGENTS-draft.md new file mode 100644 index 00000000..6138a918 --- /dev/null +++ b/docs/AGENTS-draft.md @@ -0,0 +1,50 @@ +# AGENTS.md Draft Addendum + +This draft is intentionally short. It is meant to be merged into the root `AGENTS.md` after review, not to replace the existing Spec Kit workflow and multi-agent coordination rules. + +## Enterprise Rule Sources + +Agents must treat these files as project rules: + +- `docs/stack-overview.md` +- `docs/architecture-guidelines.md` +- `docs/filament-guidelines.md` +- `docs/security-guidelines.md` +- `docs/testing-guidelines.md` +- `docs/performance-guidelines.md` +- `docs/deployment-checklist.md` +- `docs/package-governance.md` +- `docs/ai-coding-rules.md` + +## Version Rules + +- Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4, Pest 4, PHPUnit 12, PostgreSQL 16. +- Do not use Filament v3/v4 or Livewire v3 APIs. +- Laravel 12 panel providers are registered in `bootstrap/providers.php`. +- Search official version-specific docs before changing Laravel, Filament, Livewire, Pest, Tailwind, or PostgreSQL behavior. + +## Code Rules + +- Keep Filament resources/pages thin. Extract non-trivial business logic into actions/services/jobs. +- All Graph calls go through `GraphClientInterface`. +- No Graph calls during UI rendering. +- Every tenant-owned query is workspace- and tenant-scoped. +- Every new resource-backed model has a policy or a documented exception. +- Every destructive/high-impact Filament action has confirmation, authorization, audit logging, notification, and tests. +- New JSON payload storage defaults to PostgreSQL JSONB when it is retained or queryable. +- Jobs must be idempotent, observable, retry-aware, and safe to re-run. + +## Security and Testing Rules + +- Run `composer audit` and `corepack pnpm audit --audit-level moderate` for dependency work and before release. +- Use Pest 4 and Filament action testing helpers. +- Use the PostgreSQL test lane for migrations, JSONB, partial indexes, locks, and tenant-isolation constraints. +- Never log secrets, tokens, credential payloads, or raw sensitive Graph payloads. + +## Deployment Rules + +- Sail-first locally; Dokploy-first for staging/production. +- Production must set `APP_DEBUG=false`. +- Production workers use supervised `queue:work`, not `queue:listen`. +- Deployments that use Filament registered assets must run `php artisan filament:assets`. +- Staging validation is required before production promotion. diff --git a/docs/ai-coding-rules.md b/docs/ai-coding-rules.md new file mode 100644 index 00000000..abdaef3f --- /dev/null +++ b/docs/ai-coding-rules.md @@ -0,0 +1,94 @@ +# TenantPilot AI Coding Rules + +Status: 2026-05-15 +Use for: Codex, Claude, Cursor, Windsurf, and other AI coding agents. + +## Stack + +- PHP 8.4.15 runtime; Composer constraint currently `^8.2`. +- Laravel 12.52. +- Filament 5.2.1 with required upgrade target >=5.3.5 because of advisory. +- Livewire 4.1.4. Never propose Livewire v3 APIs. +- Tailwind CSS 4.2.2 with Vite 7.3.2. +- PostgreSQL 16 via Sail/Dokploy. +- Pest 4.3 and PHPUnit 12. +- Queue/cache default to database locally; Redis is available. + +## Mandatory First Reads + +- `AGENTS.md` +- `.specify/memory/constitution.md` +- Relevant `specs/-/spec.md`, `plan.md`, `tasks.md` +- `docs/research/filament-v5-notes.md` for Filament uncertainty +- Relevant guideline file in `docs/*-guidelines.md` + +## Forbidden APIs and Patterns + +- No Filament v3/v4 APIs. +- No Livewire v3 references. +- No panel providers in `bootstrap/app.php`; Laravel 12 panel providers belong in `bootstrap/providers.php`. +- No Graph calls outside `GraphClientInterface`. +- No Graph calls during UI render. +- No destructive action without `->action(...)`, `->requiresConfirmation()`, authorization, audit, and test. +- No business-critical logic buried in Filament closures. +- No public/local uploaded filenames from `preserveFilenames()` without approved mitigation. +- No new persisted entity, enum/status family, abstraction, or taxonomy without constitution proportionality review. +- No new package without package-governance review. + +## Architecture Rules + +- Filament is UI composition. +- Services/actions own business behavior. +- Jobs own remote/long-running work. +- Policies/gates own authorization. +- Models own persistence relationships, casts, scopes, and small invariants. +- Migrations own integrity through constraints and indexes. +- Tenant-owned data must always be workspace- and tenant-scoped. + +## Filament Rules + +- Every globally searchable resource needs a View or Edit page and `$recordTitleAttribute`; otherwise disable global search. +- Tables need default sort, domain-specific empty state, and query-safe columns. +- Relationship columns require eager loading or query optimization. +- Use `UiEnforcement` or `WorkspaceUiEnforcement` for capability-aware action state. +- Prefer render hooks and CSS hook classes over publishing Filament internals. +- Add `php artisan filament:assets` to deployment when Filament assets are registered. + +## Testing Rules + +- Use Pest 4. +- Test pages/relation managers/widgets as Livewire components. +- Use Filament action helpers for action tests. +- Add policy tests for new policies. +- Add tenant isolation tests for tenant-owned models. +- Use PostgreSQL lane for JSONB, partial indexes, locks, composite FKs, and migrations. +- Browser tests are for critical workflows and JS/visual smoke, not every CRUD page. + +## Security Rules + +- Deny unauthorized workspace/tenant access as not found. +- Never log secrets, tokens, raw credential payloads, or raw sensitive Graph payloads. +- Provider credentials use encrypted storage. +- File uploads default to private disks, random names, MIME/size validation, and tamper prevention when applicable. +- Production requires `APP_DEBUG=false`. +- Dependency audits must be clean or explicitly accepted. + +## Performance Rules + +- Use JSONB for queryable snapshot, backup, restore, evidence, and audit payloads. +- Add indexes for proven query paths only. +- Move Graph, restore, backup, export, and report work to queues. +- Make jobs idempotent and observable through `OperationRun`. +- Honor Microsoft Graph `Retry-After`; use exponential backoff with jitter when missing. + +## Response Format for AI Code Changes + +Every implementation response must state: + +1. Livewire v4 compliance. +2. Panel provider location if Filament panel code changed. +3. Global search status for changed resources. +4. Destructive/high-impact actions and how confirmation + authorization + audit are handled. +5. Asset strategy and whether `filament:assets` is required. +6. Tests run and tests added/updated. +7. Deployment impact: env, migrations, queues, scheduler, storage. diff --git a/docs/architecture-guidelines.md b/docs/architecture-guidelines.md new file mode 100644 index 00000000..562bd7fc --- /dev/null +++ b/docs/architecture-guidelines.md @@ -0,0 +1,177 @@ +# TenantPilot Architecture Guidelines + +Status: 2026-05-15 +Applies to: Laravel 12.52, PHP 8.4, Filament 5.2+, Livewire 4, PostgreSQL 16. + +## Target Architecture + +TenantPilot should remain a Laravel monolith with explicit bounded modules, not a speculative framework. The architecture target is: + +- Filament owns admin UI composition only. +- Domain/application services own Intune, backup, restore, audit, evidence, and permission behavior. +- Jobs own long-running or remote Graph work. +- Policies and gates own authorization. +- Models own persistence relationships, casts, scopes, and small invariants only. +- Migrations own data integrity through foreign keys, unique constraints, partial indexes, and JSONB where queryable. + +This aligns with the constitution: heavy architecture is allowed for tenant isolation, RBAC, auditability, immutable history, queue correctness, credential safety, and compliance evidence; speculative generic layers are not. + +## Current Architecture Signals + +Strong patterns already present: + +- `GraphClientInterface` is the required external Graph seam. +- `UiEnforcement` and `WorkspaceUiEnforcement` centralize UI authorization behavior. +- `OperationRun` provides observable queued operations. +- `ProviderCredential` uses encrypted casts for credential payloads. +- Workspace/tenant isolation migrations add non-null workspace ownership and composite constraints. +- Pest lanes and architecture/governance tests already exist. + +High-risk drift: + +- Large Filament classes concentrate UI, authorization, table configuration, modal logic, dispatching, notifications, and domain workflow glue in one place. +- Some resources use static `can*()` methods instead of dedicated policies, making authorization harder to audit globally. +- Historic JSON columns remain mixed with newer JSONB design. + +## Rules + +- Business logic must not live directly in Filament table/header actions except trivial UI orchestration. +- Every action that creates, mutates, deletes, restores, retries, syncs, dispatches, or exports must call a service/action class or queued job. +- Every new resource-backed model needs a policy, or a documented exception in the feature spec. +- Every tenant-owned query must scope by workspace and managed environment before rendering or mutation. +- Graph calls must never happen during UI render. They must happen in services/jobs through `GraphClientInterface`. +- New abstractions require the constitution proportionality check unless they are security, audit, queue, or isolation-critical. +- Do not add generic provider frameworks until at least two real providers require the variation. +- Prefer extracted builders only when they reduce real review burden. Do not extract one-off schema fragments into a new layer just for style. + +## Refactoring Backlog + +| Target | Problem | Recommendation | Priority | Effort | Risk if ignored | +|---|---|---|---|---:|---| +| `ManagedEnvironmentOnboardingWizard` | 5,748 LOC workflow page | Split into step schema builders, onboarding draft mutation service, and page-only orchestration. | P1 | L | High regression risk in onboarding and RBAC. | +| `ManagedEnvironmentResource` | 3,785 LOC resource | Extract table columns/filters/actions and tenant-scoped domain actions. | P1 | L | Difficult safe review of destructive environment actions. | +| `RestoreRunResource` | 2,779 LOC resource | Extract restore action builders and write-gate composition. | P1 | M | Restore safety logic becomes hard to audit. | +| `FindingResource` | 2,503 LOC resource | Extract bulk exception/assignment workflows. | P2 | M | Slower feature work and fragile tests. | +| `BackupScheduleResource` | repeated run/retry/bulk closures | Extract `StartBackupScheduleRunAction` service. | P1 | M | Duplicate authorization/audit behavior can drift. | + +## Preferred Code Patterns + +### Thin Filament Resource + +```php +use App\Filament\Resources\BackupScheduleResource\Actions\BackupScheduleActions; +use App\Filament\Resources\BackupScheduleResource\Schemas\BackupScheduleForm; +use App\Filament\Resources\BackupScheduleResource\Tables\BackupScheduleTable; +use App\Models\BackupSchedule; +use Filament\Resources\Resource; +use Filament\Schemas\Schema; +use Filament\Tables\Table; + +final class BackupScheduleResource extends Resource +{ + protected static ?string $model = BackupSchedule::class; + + protected static bool $isGloballySearchable = false; + + public static function form(Schema $schema): Schema + { + return BackupScheduleForm::configure($schema); + } + + public static function table(Table $table): Table + { + return BackupScheduleTable::configure($table); + } + + public static function makeRunNowAction(): Action + { + return BackupScheduleActions::runNow(); + } +} +``` + +### Service Action for Business Logic + +```php +namespace App\Actions\BackupSchedules; + +use App\Jobs\RunBackupScheduleJob; +use App\Models\BackupSchedule; +use App\Models\User; +use App\Services\OperationRunService; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Gate; + +final class StartBackupScheduleRun +{ + public function __construct( + private readonly OperationRunService $operationRuns, + ) {} + + public function handle(User $actor, BackupSchedule $schedule): int + { + Gate::forUser($actor)->authorize('run', $schedule); + + return DB::transaction(function () use ($schedule, $actor): int { + $run = $this->operationRuns->startBackupScheduleRun($schedule, $actor); + + RunBackupScheduleJob::dispatch($schedule->getKey(), $run->getKey()) + ->onQueue('graph'); + + return (int) $run->getKey(); + }); + } +} +``` + +### Idempotent Job Skeleton + +```php +use App\Models\OperationRun; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Foundation\Queue\Queueable; +use Illuminate\Support\Facades\DB; + +final class SyncManagedEnvironmentPoliciesJob implements ShouldQueue +{ + use Queueable; + + public int $tries = 3; + + public int $timeout = 300; + + public function __construct( + private readonly int $operationRunId, + ) {} + + public function handle(): void + { + $run = DB::transaction(function (): OperationRun { + $run = OperationRun::query()->lockForUpdate()->findOrFail($this->operationRunId); + + if ($run->isTerminal()) { + return $run; + } + + $run->markRunning(); + + return $run; + }); + + if ($run->isTerminal()) { + return; + } + + // Graph work happens here through GraphClientInterface-backed services. + } +} +``` + +## Acceptance Standard for New Features + +- Spec/plan/tasks exist when code changes runtime behavior. +- Resource/page logic remains UI-focused. +- Mutations have policy authorization, transaction boundaries where needed, audit logging, and tests. +- Remote work is queued and observable. +- Tenant/workspace isolation is proven by tests. +- PostgreSQL-specific behavior is covered in the PostgreSQL lane. diff --git a/docs/deployment-checklist.md b/docs/deployment-checklist.md new file mode 100644 index 00000000..3521a888 --- /dev/null +++ b/docs/deployment-checklist.md @@ -0,0 +1,102 @@ +# TenantPilot Deployment Checklist + +Status: 2026-05-15 +Target: Sail locally, Dokploy-first staging/production, PostgreSQL, container-based deployment. + +## Production Readiness Checklist + +- Staging environment exists and is the mandatory production gate. +- `APP_ENV=production` and `APP_DEBUG=false`. +- `APP_KEY` is stable, secret, and backed up securely. +- Database is PostgreSQL 16-compatible. +- Storage volumes/private object storage are persistent. +- Queue workers and scheduler are explicitly configured. +- Health check route `/up` is monitored. +- Logs are collected outside the container. +- Backups are encrypted and restore-tested. +- Dependency audits are clean or exceptions are approved. + +## Build and Release Checklist + +1. `cd apps/platform && composer install --no-dev --optimize-autoloader` +2. `cd apps/platform && corepack pnpm install --frozen-lockfile` +3. `cd apps/platform && corepack pnpm build` +4. `cd apps/platform && php artisan filament:assets` +5. `cd apps/platform && php artisan migrate --force` +6. `cd apps/platform && php artisan optimize` +7. Restart or reload long-running services with `php artisan reload` or `php artisan queue:restart` depending on runtime setup. +8. Verify `/up`. +9. Verify login, tenant selection, queue dispatch, and audit write on staging. + +## Queue Worker Checklist + +Do not use `queue:listen` for production workers. + +Recommended baseline: + +```bash +php artisan queue:work database --queue=high,default,graph,restore,reports,notifications --sleep=3 --tries=3 --timeout=300 +``` + +When Redis is enabled: + +```bash +php artisan queue:work redis --queue=high,default,graph,restore,reports,notifications --sleep=3 --tries=3 --timeout=300 +``` + +Rules: + +- Use process supervision so exited workers restart. +- Keep worker `--timeout` lower than queue `retry_after`. +- Reload/restart workers on deploy. +- Track queue depth and failed jobs. +- Run destructive restore/backups in separate queues when volume grows. + +## Scheduler Checklist + +- One scheduler instance per environment. +- Use Laravel scheduler with `withoutOverlapping()` for recurring jobs. +- Monitor last successful scheduler tick and per-command failures. +- Long-running scheduled work dispatches jobs rather than doing Graph work inline. + +## Migration Checklist + +- Review locks and table size before staging. +- Backfill in chunks where needed. +- Avoid irreversible destructive schema changes after production unless forward-only rollback is documented. +- JSON to JSONB conversions need staging timing proof. +- Composite FK and partial index changes need PostgreSQL CI/staging validation. + +## Rollback Checklist + +- Keep previous image available. +- Know whether rollback is code-only or code+schema. +- For forward-only migrations, ship a forward fix instead of unsafe down migration. +- Pause workers before risky rollback if queued payload formats changed. +- Verify audit logs and operation runs remain readable. + +## Backup/Restore Checklist + +- Database backups encrypted. +- Storage backups encrypted. +- Provider credentials excluded from logs and exports. +- Restore tested on staging from a real backup. +- Backup retention and deletion documented. +- Restore runbook includes queue/scheduler coordination. + +## Monitoring Checklist + +- `/up` uptime check. +- Laravel logs and container logs centralized. +- Queue failures and long-running jobs alerted. +- Scheduler missed-run alert. +- Database connections, slow queries, disk, and backup freshness monitored. +- Graph 429/503 rates visible. +- Error tracking integrated before production. + +## Dokploy Notes + +- Treat Dokploy as the process/orchestration layer, not as application governance. +- Ensure web, queue, and scheduler processes are separate service definitions or entrypoints. +- Persist `storage/`, database volumes, and uploaded/private files. +- Do not bake `.env` into images. diff --git a/docs/filament-guidelines.md b/docs/filament-guidelines.md new file mode 100644 index 00000000..a60740a0 --- /dev/null +++ b/docs/filament-guidelines.md @@ -0,0 +1,153 @@ +# TenantPilot Filament Guidelines + +Status: 2026-05-15 +Applies to: Filament v5, Livewire v4.1, Laravel 12. + +## Version Contract + +- Livewire v4.0+ compliance: satisfied by Livewire 4.1.4. +- Panel provider location: `apps/platform/bootstrap/providers.php` registers `AdminPanelProvider` and `SystemPanelProvider`. +- Admin panel path: `/admin`. +- System panel path: `/system`. +- Filament asset deployment: any registered Filament assets require `cd apps/platform && php artisan filament:assets` in deployment or release build. + +## Global Search Contract + +- A resource may use global search only when it has a View or Edit page and a `$recordTitleAttribute`. +- Relationship-backed global search details must eager-load relationships in `getGlobalSearchEloquentQuery()`. +- If a resource is tenant-sensitive or lacks safe View/Edit URLs, set `protected static bool $isGloballySearchable = false`. +- Current examples: `PolicyResource`, `ProviderConnectionResource`, and `ManagedEnvironmentResource` disable global search, which is correct for sensitive tenant-scoped surfaces. + +## Destructive and High-Impact Actions + +Every destructive or high-impact action must have: + +- `->action(...)`, not URL-only execution. +- `->requiresConfirmation()`. +- Policy or gate authorization inside the action handler. +- `UiEnforcement` or `WorkspaceUiEnforcement` on the visible/disabled UI state. +- Audit log entry. +- Success/error notification. +- Pest test for visible/disabled/denied/executed behavior. + +Destructive examples: delete, force delete, restore, archive, retry restore, run restore, disable provider connection, purge, revoke, credential rotation, backup/restore mutations. + +## Filament Do's + +- Use native Filament resources, pages, tables, forms, schemas, actions, relation managers, widgets, clusters, and notifications before custom Blade/JS. +- Use render hooks and CSS hook classes instead of publishing internal Filament views. +- Keep tables scan-first: default sort, explicit empty state, sensible pagination profile, hidden technical detail columns. +- Use `ActionSurfaceDeclaration` when the resource participates in the project action-surface contract. +- Keep RelationManagers lazy-loaded unless an operator workflow requires eager loading. +- Use policies for model authorization and `UiEnforcement` for UI affordance consistency. +- Use `rateLimit()` or Laravel rate limiting for actions that can trigger expensive remote or queued work repeatedly. + +## Filament Don'ts + +- Do not put business workflows directly in long action closures when they mutate data or dispatch remote work. +- Do not assume confirmation modals on `->url(...)` actions. +- Do not expose user-controlled URLs to `url()` without scheme validation. +- Do not use `preserveFilenames()` for uploads on local/public disks. +- Do not enable global search on resources that cannot safely link to View/Edit pages. +- Do not hide unauthorized UI as the only security control. +- Do not add custom pages when a Resource, RelationManager, or action modal covers the workflow. + +## Project-Specific Patterns + +### Safe Table Action + +```php +use App\Actions\BackupSchedules\StartBackupScheduleRun; +use App\Models\BackupSchedule; +use App\Support\Auth\Capabilities; +use App\Support\Rbac\UiEnforcement; +use Filament\Actions\Action; +use Filament\Notifications\Notification; + +UiEnforcement::forTableAction( + Action::make('runNow') + ->label('Run now') + ->icon('heroicon-o-play') + ->requiresConfirmation() + ->modalHeading('Run backup schedule now?') + ->action(function (BackupSchedule $record, StartBackupScheduleRun $starter): void { + $runId = $starter->handle(auth()->user(), $record); + + Notification::make() + ->title('Backup run queued') + ->body("Operation run #{$runId} was created.") + ->success() + ->send(); + }), + fn (BackupSchedule $record): mixed => $record->managedEnvironment, +) + ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN) + ->apply(); +``` + +### Extracted Schema + +```php +namespace App\Filament\Resources\BackupScheduleResource\Schemas; + +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; +use Filament\Schemas\Schema; + +final class BackupScheduleForm +{ + public static function configure(Schema $schema): Schema + { + return $schema->schema([ + TextInput::make('name')->required()->maxLength(255), + Select::make('frequency')->required()->options([ + 'daily' => 'Daily', + 'weekly' => 'Weekly', + ]), + Toggle::make('is_enabled')->label('Enabled'), + ]); + } +} +``` + +### Extracted Table + +```php +namespace App\Filament\Resources\BackupScheduleResource\Tables; + +use App\Support\Filament\TablePaginationProfiles; +use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Table; + +final class BackupScheduleTable +{ + public static function configure(Table $table): Table + { + return $table + ->defaultSort('next_run_at') + ->paginationPageOptions(TablePaginationProfiles::resource()) + ->columns([ + TextColumn::make('name')->searchable()->sortable(), + TextColumn::make('status')->badge(), + TextColumn::make('next_run_at')->since()->sortable(), + ]) + ->emptyStateHeading('No backup schedules') + ->emptyStateDescription('Create a schedule after selecting a managed environment.'); + } +} +``` + +## Migration Plan for Bad Patterns + +1. Identify resource files above 1,000 LOC or actions above 60 LOC. +2. Extract repeated action orchestration into `app/Actions//...`. +3. Extract table columns/filters/actions into resource-local builder classes only when they reduce review risk. +4. Add policy tests before deleting resource-level authorization logic. +5. Keep one feature branch per refactor slice to avoid broad conflicts. + +## Testing Plan + +- Resource pages and relation managers are Livewire components and must be tested through Pest/Livewire. +- Mutating actions must use Filament action testing helpers such as `callAction`, `mountAction`, `callTableAction`, `assertActionDisabled`, and `assertTableActionVisible`. +- Browser tests are reserved for critical multi-step workflows, JS errors, accessibility regressions, and visual smoke checks. diff --git a/docs/package-governance.md b/docs/package-governance.md new file mode 100644 index 00000000..8d33005c --- /dev/null +++ b/docs/package-governance.md @@ -0,0 +1,84 @@ +# TenantPilot Package Governance + +Status: 2026-05-15 +Applies to: Composer, pnpm workspace, Filament plugins, Laravel packages, frontend tooling. + +## Policy + +New packages are allowed only when they solve a current release problem that existing Laravel/Filament/project patterns cannot reasonably solve. + +Every new package requires: + +- Maintenance check. +- License check. +- Security advisory check. +- Version compatibility check with PHP 8.4, Laravel 12, Filament 5, Livewire 4, Tailwind 4. +- Removal plan if it is experimental. +- Spec/plan update when it changes runtime behavior. + +## Current Risk Matrix + +| Package | Current | Risk | Recommendation | +|---|---:|---|---| +| `filament/filament` | 5.2.1 | High advisory via Filament Tables XSS range | Upgrade to >=5.3.5, preferably current 5.x, then run Filament/action/browser lanes. | +| `league/commonmark` | transitive | Medium advisories | Patch through Composer update. | +| `phpseclib/phpseclib` | transitive | High advisories | Patch through Composer update. | +| `phpunit/phpunit` | 12.5.4 | High dev advisory | Upgrade to >=12.5.8. | +| `psy/psysh` | transitive/dev | Medium advisory | Patch through Composer update. | +| `axios` | 1.14.0 | High/moderate SSRF/header/prototype pollution advisories | Upgrade to >=1.16.1. | +| `postcss` | 8.5.9 transitive | Moderate XSS | Upgrade transitive via package update. | +| `esbuild` | transitive via drizzle tooling | Moderate dev-server issue | Upgrade dependency chain to esbuild >=0.25.0. | +| `devalue` | workspace transitive | High DoS | Upgrade to >=5.8.1 through website/workspace dependency update. | +| `socialiteproviders/microsoft-azure` | 4.2.1 | Major version behind | Review 5.x migration separately with auth tests. | +| `barryvdh/laravel-debugbar` | 3.16.5 dev | Major behind | Keep dev-only; upgrade or remove if unused. | + +## Approved Packages + +- Laravel framework first-party packages already in use. +- Filament first-party v5 packages. +- Pest 4 and official Pest plugins used by the current test lanes. +- Tailwind CSS v4 and `@tailwindcss/vite`. +- Drizzle tooling for local PostgreSQL workflows when repo scripts require it. + +## Packages Under Review + +- `socialiteproviders/microsoft-azure` 4.x to 5.x. +- `torchlight/engine` 0.1 to 1.x. +- `barryvdh/laravel-debugbar` 3.x to 4.x or removal. +- Vite 7 to 8 and `laravel-vite-plugin` 2 to 3. + +## Do Not Use Without Approval + +- Unmaintained Filament plugins. +- Packages that require Filament v3/v4 APIs. +- Packages that bypass Laravel authorization, validation, storage, or queue systems. +- Packages that store secrets in plaintext. +- UI frameworks that duplicate Filament for admin workflows. +- SDKs that bypass `GraphClientInterface` for Microsoft Graph calls. + +## CI Gates + +Required before release: + +```bash +cd apps/platform +composer validate --strict +composer audit +corepack pnpm audit --audit-level moderate +``` + +Advisory exceptions require: + +- Advisory ID. +- Affected package/version. +- Reason not exploitable in TenantPilot. +- Expiry date. +- Owner. +- Compensating control. + +## Upgrade Rules + +- Patch security advisories before feature work when severity is high and package is runtime-exposed. +- Minor Laravel/Filament updates require Filament action tests and browser smoke on critical admin workflows. +- Major upgrades require a spec, upgrade guide review, staging validation, and rollback plan. +- Do not update lock files incidentally in feature PRs unless the feature is a dependency update. diff --git a/docs/performance-guidelines.md b/docs/performance-guidelines.md new file mode 100644 index 00000000..92180754 --- /dev/null +++ b/docs/performance-guidelines.md @@ -0,0 +1,101 @@ +# TenantPilot Performance Guidelines + +Status: 2026-05-15 +Applies to: Laravel 12, Filament 5, Livewire 4, PostgreSQL 16, Microsoft Graph. + +## Performance Target + +TenantPilot should keep interactive admin requests short and move remote, large, retryable, or long-running work into queued operations with visible `OperationRun` state. + +## Current Performance Risks + +| Risk | Evidence | Priority | Mitigation | +|---|---|---:|---| +| Queryable payloads still in `json` | policy versions, backup items, restore runs, audit logs | P1 | Convert to JSONB where queried; add targeted GIN/expression indexes. | +| Large Filament pages/resources | 1,000-5,700 LOC classes | P1 | Extract tables/actions and review N+1 risks per surface. | +| Database queue for all work | `.env.example` and queue config | P2 | Move high-volume Graph/restore work to Redis queue when load grows. | +| Dashboard/widget query cost | multiple KPI/list widgets | P2 | Cache or precompute expensive aggregate metrics. | +| Graph throttling | Microsoft Graph 429/503 behavior | P1 | Honor `Retry-After`, use exponential backoff with jitter, avoid polling. | + +## Synchronous vs Asynchronous + +Keep synchronous: + +- Rendering Filament pages. +- Validating form/action input. +- Creating operation intent records. +- Small DB-only state transitions. +- Showing preview summaries from already persisted data. + +Move asynchronous: + +- Microsoft Graph reads/writes. +- Backup set item capture. +- Restore execution. +- Bulk export/import. +- Compliance/evidence snapshots. +- Long report generation. +- Notification delivery retries. +- Any workflow likely to exceed 2-5 seconds. + +## Filament Table Rules + +- Always define a default sort. +- Eager-load relationships used by visible columns. +- Use `withCount()`/aggregate subqueries instead of per-row counts. +- Hide technical columns by default. +- Use session persistence only on investigative resources. +- Avoid computed columns that perform per-row service calls. +- Avoid Graph calls during table render. + +## Database Rules + +- Prefer `jsonb` for raw Graph snapshots, backup payloads, restore previews/results, evidence summaries, and audit metadata that must be queried. +- Add GIN indexes only when a query path exists; prefer expression indexes for common JSON paths. +- Add composite indexes for workspace/tenant/time/status list filters. +- Add partial unique indexes for active run/idempotency constraints. +- Keep migrations incremental and reversible where practical. + +## Queue Strategy + +MVP: + +- Database queue is acceptable for local and low-volume staging. +- Jobs must be idempotent and observable. +- Worker timeout must be lower than `retry_after`. + +Scale-up: + +- Move production queues to Redis. +- Split queues: `high`, `default`, `graph`, `restore`, `reports`, `notifications`. +- Run separate worker counts per queue. +- Use process supervision in Dokploy/container runtime. +- Restart/reload workers on every deploy. + +## Caching Strategy + +- Cache stable config-derived capability maps. +- Cache dashboard aggregates only when invalidation is clear. +- Do not cache tenant authorization decisions across membership changes unless invalidation is proven. +- Avoid caching raw Graph secrets or token payloads. +- Use Redis for locks and cache in production when queue/scheduler scale increases. + +## Monitoring Metrics + +- HTTP p50/p95/p99 response time by route/panel. +- Livewire request duration and error rate. +- DB query count and slow queries by page/action. +- Queue depth, job latency, failures, retries, max runtime. +- Scheduler last-success timestamp per scheduled command. +- Graph 429/503 count, retry-after seconds, retry exhaustion. +- OperationRun created/running/failed/partial counts. +- Audit log write failures. +- Backup/restore duration and item failure rate. + +## Load Test Recommendations + +- List 10k policies and 100k policy versions per workspace. +- Render backup and restore tables with 50k backup items. +- Simulate concurrent backup schedule runs for multiple tenants. +- Simulate Graph 429/503 responses and verify retry/backoff budgets. +- Exercise dashboard widgets with realistic operation/finding history. diff --git a/docs/product/spec-candidates.md b/docs/product/spec-candidates.md index b8e2c41b..8985ea71 100644 --- a/docs/product/spec-candidates.md +++ b/docs/product/spec-candidates.md @@ -4,7 +4,7 @@ # Spec Candidates > **Last reviewed:** 2026-05-15 > **Use for:** The active repo-based queue of spec candidates that may still need new or refreshed specs > **Do not use for:** Proof that a candidate is already specced, implemented, or prioritized above the roadmap without repo verification -> **Scoped maintenance:** 2026-05-15 Spec 310 product-truth/docs-drift reconciliation after Specs 307-309; 2026-05-15 Spec 309 RBAC role matrix and access boundary hardening update; 2026-05-15 Spec 308 customer-safe Decision Summary and Review Pack inclusion update; 2026-05-15 Spec 307 Decision Register proof-link implementation update; 2026-05-15 Spec 306 Decision Register reconciliation update; 2026-05-15 Spec 304 Tenant Panel dead-code retirement guardrail update; 2026-05-12 admin workspace navigation and tenant-owned surface repair candidate intake after the repo-verified navigation/panel audit; 2026-05-06 cross-domain progress and indicator semantics candidate intake; 2026-05-04 OperationRun progress maturity plus Tenant Dashboard active-operations summary candidate intake; 2026-05-03 OperationRun activity feedback candidate intake plus the 2026-05-02 repo-based queue re-audit and enterprise-SaaS deep-research alignment against current `specs/` truth, including Specs 263 and current-branch 264. +> **Scoped maintenance:** 2026-05-15 post-Spec-311 legacy/productization/scope follow-up candidate update; 2026-05-15 Spec 310 product-truth/docs-drift reconciliation after Specs 307-309; 2026-05-15 Spec 309 RBAC role matrix and access boundary hardening update; 2026-05-15 Spec 308 customer-safe Decision Summary and Review Pack inclusion update; 2026-05-15 Spec 307 Decision Register proof-link implementation update; 2026-05-15 Spec 306 Decision Register reconciliation update; 2026-05-15 Spec 304 Tenant Panel dead-code retirement guardrail update; 2026-05-12 admin workspace navigation and tenant-owned surface repair candidate intake after the repo-verified navigation/panel audit; 2026-05-06 cross-domain progress and indicator semantics candidate intake; 2026-05-04 OperationRun progress maturity plus Tenant Dashboard active-operations summary candidate intake; 2026-05-03 OperationRun activity feedback candidate intake plus the 2026-05-02 repo-based queue re-audit and enterprise-SaaS deep-research alignment against current `specs/` truth, including Specs 263 and current-branch 264. > > Repo-based next-spec queue for TenantPilot. > This file is not a wishlist. It tracks only open gaps that are still worth turning into new or refreshed specs. @@ -41,7 +41,7 @@ ### Customer Review Workspace v1 - **Status markers**: repo-verified, productization gap - **Roadmap lane**: Next -- **Current repo truth**: Specs 249 and 258, plus Spec 308 customer-safe Decision Summary / Review Pack inclusion, the implementation ledger, and current review surfaces, already prove the foundational path for customer-safe review consumption. +- **Current repo truth**: Specs 249 and 258, plus Spec 308 customer-safe Decision Summary / Review Pack inclusion, Spec 311 Workspace / Environment Surface Scope Contract, the implementation ledger, and current review surfaces, already prove the foundational path for customer-safe review consumption. - **Problem**: Customer-safe review consumption remains the clearest sellability gap whenever calm status, reason, impact, evidence basis, accepted risks, decision summary, review-pack download, and one primary next action still require operator translation. - **Deep-Research-derived sharpening**: Keep the lane focused on one customer-safe read-only review surface, findings summary, accepted-risk visibility, evidence viewer, review-pack download, management summary, RBAC/capability enforcement, and audit trail. - **Non-goals**: no generic customer portal, no helpdesk surface, no raw diagnostics by default, no admin mirror. @@ -152,6 +152,10 @@ ## Active Candidate Queue - `Decision Register Customer-Safe Summary / Review-Pack Inclusion` -> Spec 308, completed historical customer-safe summary and review-pack inclusion - `RBAC Role Matrix & Access Boundary Audit` -> Spec 309, completed scoped security hardening +Spec 311 is also treated as completed foundation on the current repo artifacts: + +- `Workspace / Environment Surface Scope Contract` -> Spec 311, completed shell/scope foundation for route-owned workspace-wide versus environment-bound context. Follow-up work must not reopen shell/sidebar/topbar scope unless fresh repo evidence shows regression. + Two manual-promotion items have since moved out of backlog status on the current repo state: - `Auditor Pack Delivery & Executive Export v1` -> Spec 263 @@ -163,18 +167,307 @@ ## Promotable Candidate Backlog **Boundary**: manual promotion only, not auto-prep. These items are intentionally outside `next-best-prep` and require an explicit product decision before any future spec refresh or follow-up work. -### Recommended Next Spec Sequence After Spec 310 +### Recommended Next Candidate Sequence After Spec 311 -| Order | Recommended next spec | Candidate status | Boundary | -|---:|---|---|---| -| 311 | `customer-review-workspace-v1-completion` | open gap / P1 | Complete customer-safe review consumption without claiming a generic customer portal. | -| 312 | `localization-v1-customer-facing-surfaces` | open gap / P1 | Productize DE/EN customer-facing review, pack, notification, reason, impact, and next-action language over the existing localization foundation. | -| 313 | `decision-based-governance-inbox-v1` | open gap / P1 | Extend decision-centered operator workflow over existing Governance Inbox and Decision Register truth; do not rebuild the Decision Register. | -| 314 | `commercial-entitlements-billing-state-maturity` | open gap / P1/P2 | Mature commercial lifecycle and billing-state truth after customer-facing surfaces are clearer. | -| 315 | `cross-tenant-compare-promotion-execution` | spec-backed / open execution maturity | Continue the governance-first compare/promotion execution path if Spec 264 still needs runtime/product refresh. | -| 316 | `governance-artifact-lifecycle-retention` | open gap / P2 | Productize artifact lifecycle and retention runtime over existing taxonomy/truth foundations. | -| 317 | `external-support-desk-psa-handoff` | repo-real foundation / open productization | Productize support-desk/PSA handoff only after higher-priority review and decision paths settle. | -| 318 | `private-ai-execution-governance-foundation` | foundation/open / P3 | Promote only as a bounded governed runtime consumer, not as an AI feature island. | +No new spec numbers are assigned here. This table is a manual-promotion sequence only. + +| Order | Recommended next candidate | Candidate status | Boundary | Depends on | +|---:|---|---|---|---| +| 1 | `customer-review-workspace-v1-completion` | open gap / P1 | Complete customer-safe review consumption without claiming a generic customer portal or reopening shell/sidebar scope. | Spec 311 | +| 2 | `provider-connection-scope-hardening` | open gap / P1 | Harden workspace/provider-level provider connection scope and credential authority. | Spec 311 | +| 3 | `canonical-link-query-cleanup` | open gap / P1 | Clean route/link/query semantics after the surface scope contract. | Spec 311; provider hardening preferred | +| 4 | `product-truth-docs-drift-cleanup` | open gap / P2 | Align docs/templates/product truth with retired `/admin/t` and route-scope contract. | Spec 311 | +| 5 | `environment-resource-context-follow-through` | open gap / P2 | Reduce hidden Filament tenant fallback in selected canonical environment resources. | Spec 311; canonical link cleanup preferred | +| 6 | `legacy-compatibility-dead-code-retirement` | open gap / P2 | Remove or classify confirmed stale compatibility leftovers while keeping guard tests. | product truth cleanup preferred | +| 7 | `tenant-helper-naming-cleanup` | open gap / P3 | Rename or isolate tenant-named helpers that now encode legacy mental models. | canonical link cleanup; product truth cleanup preferred | +| 8 | `localization-v1-customer-facing-surfaces` | existing open gap / P1 | Productize DE/EN customer-facing review, pack, notification, reason, impact, and next-action language over the existing localization foundation. | customer review completion preferred | +| 9 | `decision-based-governance-inbox-v1` | existing open gap / P1 | Extend decision-centered operator workflow over existing Governance Inbox and Decision Register truth; do not rebuild the Decision Register. | customer review completion preferred | +| 10 | `commercial-entitlements-billing-state-maturity` | existing open gap / P1/P2 | Mature commercial lifecycle and billing-state truth after customer-facing surfaces are clearer. | productization lanes | + +### Post-311 Legacy / Productization / Scope follow-up candidates + +These candidates are concrete follow-ups after Spec 311. They must not be merged into one umbrella spec. Spec 311 is the completed foundation: route scope determines shell/sidebar/topbar/breadcrumb; page filters determine data inside the current surface. + +#### Customer Review Workspace v1 Completion + +- **Priority**: P1 +- **Complexity**: M +- **Impact**: High +- **Type**: Productization / customer-safe review consumption +- **Depends on**: Workspace / Environment Surface Scope Contract +- **Problem**: Customer Review Workspace can now build on a stable workspace-wide shell/sidebar contract, but the actual customer-safe productization remains incomplete: latest released review, Decision Summary, accepted risks, evidence basis, review pack status, download state, and clear empty states still need to be made coherent. +- **Why now**: Spec 311 removed the shell/sidebar ambiguity, so the next narrow step can focus on customer-safe consumption rather than scope repair. +- **Risk**: High productization risk if deferred, because review value still requires operator translation and the workspace could remain "repo-real" but not sellable. +- **Goal**: make Customer Review Workspace a workspace-wide hub for customer-safe released review consumption. Environment selection remains a page-level filter, not global context. +- **Repo evidence**: + - `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` + - `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` + - `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php` + - `apps/platform/app/Filament/Resources/ReviewPackResource.php` + - `apps/platform/app/Jobs/GenerateReviewPackJob.php` + - `apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewComposer.php` + - `specs/308-decision-register-summary-review-pack/spec.md` + - `specs/311-workspace-environment-surface-scope-contract/spec.md` +- **Scope**: + - Customer Review Workspace overview + - latest released review section + - review pack status and download availability + - customer-safe Decision Summary + - accepted risks and customer-safe evidence basis + - empty states for no released review, filtered no results, no decisions requiring awareness, incomplete evidence, and pack missing/generating/expired + - focused tests and browser smoke when UI changes +- **Non-scope**: + - no shell, sidebar, topbar, `OperateHubShell`, `TenantPageCategory`, or `NavigationScope` work + - no RBAC changes, migrations, new tables, or new `OperationRun` types + - no Provider Connection scope work + - no Product Truth broad cleanup + - no AI summary +- **Acceptance criteria**: + - released reviews are understandable and customer-safe + - environment filter is shown as a filter, not global context + - latest review, Decision Summary, accepted risks, evidence basis, and pack status are visible + - no internal IDs, fingerprints, `OperationRun` URLs, debug data, or internal reason families leak + - no approve, reject, renew, revoke, expire, or regenerate actions appear on the customer-safe surface + - existing authorization remains intact + +#### Provider Connection Scope Hardening + +- **Priority**: P1 +- **Complexity**: M +- **Impact**: High +- **Type**: Security / scope / provider boundary +- **Depends on**: Workspace / Environment Surface Scope Contract +- **Problem**: Provider Connections are workspace/provider-level, but legacy audit evidence shows they can still be influenced by hidden context such as remembered tenant, `lastTenantId`, Filament tenant, or query filters. This is high-risk because the surface sits near credentials, consent, permissions, and target connection logic. +- **Why now**: Spec 311 establishes the workspace-wide shell contract, but Provider Connections still need their own authority and filter semantics hardened before credential-adjacent work grows. +- **Risk**: High security/scope risk if hidden context can influence credential-level authority or connection operations. +- **Goal**: Provider Connections are unambiguously workspace/provider-level. Environment is an explicit filter or record relationship, not hidden shell/session context. Create/edit/verify/delete/rotate/disconnect actions are capability- and scope-safe. +- **Repo evidence**: + - `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` + - `apps/platform/app/Policies/ProviderConnectionPolicy.php` + - `apps/platform/app/Support/ManagedEnvironmentLinks.php` + - `apps/platform/app/Support/Workspaces/WorkspaceContext.php` + - `apps/platform/tests/Feature/ProviderConnections/*` + - `specs/309-rbac-role-matrix-access-boundary-audit/tasks.md` + - `specs/311-workspace-environment-surface-scope-contract/spec.md` +- **Scope**: + - Provider Connection list, view, create, edit, and credential-adjacent actions + - `ProviderConnectionPolicy` + - Provider Connection filters and `managed_environment_id` query behavior + - remembered tenant / Filament tenant fallback review + - Owner, Manager, Operator, and Readonly behavior + - wrong workspace and wrong environment tests +- **Non-scope**: + - no new provider registry rebuild + - no new ProviderConnection table + - no Graph contract policy + - no new credential rotation feature expansion + - no billing or entitlement work + - no broad provider UX redesign + - no environment resource cutover +- **Acceptance criteria**: + - Provider Connection list, view, and action scopes are workspace-safe + - `managed_environment_id` is an explicit filter, not hidden context + - `lastTenantId` / remembered tenant does not decide credential-level authority + - non-member and out-of-scope access remains denied + - Manager/Operator cannot perform credential-level actions unless repo-real policy explicitly permits it + - legitimate existing Provider Connection flows remain green + +#### Canonical Link / Query Cleanup + +- **Priority**: P1 +- **Complexity**: M +- **Impact**: High +- **Type**: Route hygiene / link canonicalization +- **Depends on**: Workspace / Environment Surface Scope Contract; Provider Connection Scope Hardening preferred +- **Problem**: Link and query seams still contain ambiguous or legacy-shaped names such as `tenantPrefilterUrl`, `?tenant=...`, `tenantScopedUrl`, raw `/admin/...` URLs, mixed `tenant` versus `managed_environment_id`, and helper names that encode the old tenant mental model. +- **Why now**: Once scope is route-owned, links and query keys need to stop teaching future specs the old tenant-context model. +- **Risk**: Medium/high regression risk if new productization work keeps copying legacy helper names or query semantics. +- **Goal**: Links and query parameters are semantically explicit. Workspace-wide links stay workspace-wide; environment-owned details use canonical workspace/environment routes; environment filters use explicit environment filter query names; no `/admin/t` links are generated. +- **Repo evidence**: + - `CustomerReviewWorkspace::tenantPrefilterUrl` + - `OperationRunLinks` + - `ManagedEnvironmentLinks` + - `WorkspaceScopedTenantRoutes` + - `EnvironmentReviewResource::tenantScopedUrl` + - Evidence, stored report, review pack, notification, and "View operation" links + - `EnsureFilamentTenantSelected` navigation links +- **Scope**: + - link helper inventory + - query key standardization for page-level environment filters + - bounded raw URL replacement where route helpers exist + - canonical route assertions and no `/admin/t` guard tests + - test updates that stop asserting legacy query names where safe +- **Non-scope**: + - no route restructuring + - no migrations + - no RBAC changes + - no Provider Connection security changes + - no environment resource cutover + - no UI redesign + - no full docs rewrite +- **Acceptance criteria**: + - workspace-wide surfaces use explicit filter query names + - Customer Review, Review Register, Governance, and Operations links no longer rely on legacy tenant query semantics + - environment-owned detail links are canonical workspace/environment URLs + - `OperationRun` links remain canonical + - no `/admin/t` links are generated + +#### Product Truth / Docs Drift Cleanup + +- **Priority**: P2 +- **Complexity**: S/M +- **Impact**: Medium/High +- **Type**: Documentation / product truth / spec hygiene +- **Depends on**: Workspace / Environment Surface Scope Contract +- **Problem**: Repo docs and templates still contain stale route and naming guidance: `docs/HANDOVER.md` describes three panels including `/admin/t`, constitution/templates include `/admin/t` examples, older specs use Tenant Panel or tenant-context language, and implementation ledger entries still reference old class names. +- **Why now**: Spec 311 finalizes the scope contract; docs/templates should not keep generating new specs with retired `/admin/t` examples or query-as-context language. +- **Risk**: Medium/high process risk if stale templates continue to seed wrong route semantics into future specs. +- **Goal**: docs, spec templates, and product truth reflect the current runtime: `/admin/t` is retired, `/admin` and `/system` are active panels, workspace-wide versus environment-bound scope is documented, and query filter versus context is explicit. +- **Repo evidence**: + - `docs/HANDOVER.md` + - `.specify/memory/constitution.md` + - `.specify/templates/spec-template.md` + - `docs/product/implementation-ledger.md` + - `docs/product/spec-candidates.md` + - `docs/product/roadmap.md` + - `specs/279-*` through `specs/288-*` + - `specs/300+` + - `specs/311-workspace-environment-surface-scope-contract/*` +- **Scope**: + - docs-only correction + - template updates + - constitution language cleanup + - implementation ledger alignment + - spec candidates alignment + - roadmap note update +- **Non-scope**: + - no code changes + - no test changes unless docs validation already exists and requires it + - no product feature implementation + - no broad rewrite + - no new roadmap strategy +- **Acceptance criteria**: + - no active docs/template guidance recommends `/admin/t` for new work + - workspace-wide versus environment-bound contract is documented + - product docs distinguish filter from context + - completed specs are not reopened as Greenfield + - open follow-ups remain clearly marked + - docs diff is minimal and reviewable + +#### Environment Resource Context Follow-through + +- **Priority**: P2 +- **Complexity**: L +- **Impact**: High +- **Type**: Route/resource cutover / context hardening +- **Depends on**: Workspace / Environment Surface Scope Contract; Canonical Link / Query Cleanup preferred +- **Problem**: Many environment-owned resources are already on canonical workspace/environment routes, but internally still rely on Filament tenant bridge, `getTenant` fallback, `tenantScopedUrl` naming, or legacy compatibility seams. That bridge is partly necessary today, but it is fragile as a hidden data source. +- **Why now**: The canonical route family exists; the remaining risk is hidden fallback inside high-value resources, which should be reduced only after link/query semantics are cleaner. +- **Risk**: Medium/high correctness risk if route params and hidden Filament/session context disagree. +- **Goal**: selected high-value environment-owned resources derive primary context from canonical route parameters: workspace, managed environment, and record scope. Filament tenant bridge remains a controlled Filament integration layer, not hidden product truth. +- **Repo evidence**: + - `apps/platform/app/Filament/Concerns/WorkspaceScopedTenantRoutes.php` + - `Filament::getTenant` usages + - `ManagedEnvironment::current` + - Policy and PolicyVersion resources + - Backup and Restore resources + - `EvidenceSnapshotResource` + - `StoredReportResource` + - `FindingExceptionResource` + - Required Permissions and Diagnostics pages + - related resource tests +- **Scope**: + - inventory remaining environment-owned fallback usage + - bounded replacement in selected high-value resources + - route-param context tests + - reduce hidden Filament tenant dependency where safe + - keep canonical environment routes +- **Non-scope**: + - no full rewrite of all resources + - no route family redesign + - no RBAC changes + - no migrations + - no customer review productization + - no helper-renaming-only work unless required for safety +- **Acceptance criteria**: + - selected high-value environment resources work from canonical route context + - direct URLs with workspace/environment params resolve correctly + - hidden remembered tenant does not override canonical route params + - no `/admin/t` compatibility route is introduced + - existing resource tests remain green + +#### Legacy Compatibility / Dead Code Retirement + +- **Priority**: P2 +- **Complexity**: S/M +- **Impact**: Medium +- **Type**: Cleanup / dead code / guarded retirement +- **Depends on**: Product Truth / Docs Drift Cleanup preferred +- **Problem**: The active `/admin/t` runtime panel is removed, but stale or suspicious leftovers remain: `apps/platform/patch.diff`, TenantDashboard references, TenantPanelProvider references in docs/tests, ChooseTenant references, TenantRequiredPermissions docs, legacy redirect guards, and old compatibility examples. Some are valuable guardrails; some are stale artifacts. +- **Why now**: After docs truth is corrected, remaining leftovers can be classified without confusing historical guardrails with active runtime dependencies. +- **Risk**: Medium cleanup risk, because deleting guard tests or compatibility assertions without proof could weaken no-legacy protections. +- **Goal**: classify and retire legacy compatibility leftovers without deleting active runtime behavior by accident. +- **Repo evidence**: + - `apps/platform/patch.diff` + - `docs/HANDOVER.md` + - `tests/Feature/Guards/NoLegacyTenantPanelRuntimeTest.php` + - `tests/Feature/ProviderConnections/LegacyRedirectTest.php` + - `tests/Feature/ManagedEnvironment/LegacyTenantCoreGuardTest.php` + - implementation ledger references + - old specs/templates +- **Scope**: + - dead-code inventory verification + - remove confirmed stale patch/docs leftovers + - keep guard tests + - update docs references where minimal + - ensure no runtime route resurrection +- **Non-scope**: + - no active route cutover work + - no environment resource migration + - no RBAC changes + - no broad docs rewrite + - no test deletion without proof +- **Acceptance criteria**: + - confirmed dead artifacts are removed or explicitly documented + - guard tests for retired routes remain + - no active runtime behavior changes + - no `/admin/t` route returns + - cleanup diff stays small and safe + +#### Tenant Helper Naming Cleanup + +- **Priority**: P3 +- **Complexity**: M +- **Impact**: Medium +- **Type**: Naming / domain language cleanup +- **Depends on**: Canonical Link / Query Cleanup; Product Truth / Docs Drift Cleanup preferred +- **Problem**: Many helpers and tests still use `tenant*` naming even though the target model is workspace/managed-environment first: `tenantScopedUrl`, `tenantPrefilterUrl`, `tenant` parameter names, tenant-bound wording in tests/docs, and helper aliases. +- **Why now**: This should follow behavior cleanup, because renaming first would create churn without eliminating the underlying route/query ambiguity. +- **Risk**: Medium terminology drift risk, but lower runtime risk than the P1/P2 scope and link candidates. +- **Goal**: new public and internal helper names use managed-environment, environment, workspace, environment-scoped, and environment-filter semantics. Old names remain only as justified compatibility aliases. +- **Repo evidence**: + - `tenantScopedUrl` usages + - `tenantPrefilterUrl` usages + - `WorkspaceScopedTenantRoutes` + - tests with tenant naming + - docs/spec templates +- **Scope**: + - naming inventory + - safe internal renames in bounded helpers + - compatibility aliases only where required + - tests adjusted where names encode product semantics + - no behavior change +- **Non-scope**: + - no route behavior changes + - no RBAC changes + - no migration + - no broad resource cutover + - no docs rewrite beyond touched names +- **Acceptance criteria**: + - new helper names reflect ManagedEnvironment/Workspace semantics + - existing behavior remains unchanged + - tests prove no route/link behavior changed + - remaining tenant-named helpers are documented as compatibility or pending follow-up ### OperationRun Activity Feedback & UI Governance candidate group diff --git a/docs/security-guidelines.md b/docs/security-guidelines.md new file mode 100644 index 00000000..db39e1e5 --- /dev/null +++ b/docs/security-guidelines.md @@ -0,0 +1,137 @@ +# TenantPilot Security Guidelines + +Status: 2026-05-15 +Reference model: OWASP ASVS 5.0.0, OWASP Top 10, NIST SSDF, Laravel 12, Filament 5. + +## Security Target + +TenantPilot manages critical Intune configuration and restore workflows. Treat tenant data, backup payloads, provider credentials, policy snapshots, audit logs, and operation runs as sensitive enterprise data. + +## Current Strengths + +- Workspace and tenant isolation are constitutional non-negotiables. +- Many policies return `Response::denyAsNotFound()`. +- `UiEnforcement` centralizes disabled/hidden UI affordance behavior. +- `ProviderCredential` uses encrypted array casts and hides payloads. +- Graph access is routed through `GraphClientInterface`. +- Audit and operation-run models already provide traceability. + +## Top Security Findings + +| Risk | Evidence | Priority | Control | +|---|---|---:|---| +| Vulnerable dependencies | `composer audit`, `pnpm audit` | P0 | Patch, audit gates, approved exceptions only. | +| Inconsistent policy coverage | Some resource-backed models lack obvious policies | P1 | Resource-policy matrix and tests. | +| Production session/debug defaults need gating | `.env.example` has `APP_DEBUG=true`, `SESSION_ENCRYPT=false` for local | P1 | Deployment checklist enforces production env. | +| File upload future risk | Filament warns about file path tampering and filenames | P2 | Private disks, random names, MIME validation, path tamper prevention. | +| Graph beta default | `config/graph.php` defaults to `beta` | P2 | Endpoint-level version registry and contract tests. | + +## Release Security Checklist + +- `composer audit` clean or explicitly risk-accepted. +- `corepack pnpm audit --audit-level moderate` clean or explicitly risk-accepted. +- `APP_DEBUG=false` in staging/production. +- `APP_KEY` present and not rotated casually. +- Session cookies are secure, same-site, and domain-scoped for production. +- Provider credentials remain encrypted and never logged. +- No secrets in config, docs, tests, fixtures, screenshots, or audit metadata. +- Every write operation has policy authorization, explicit confirmation, and audit log. +- Backup and restore flows have dry-run/preview where applicable. +- Queue payloads contain identifiers, not secrets or raw credential payloads. +- Health endpoint and uptime monitor are active. + +## Checklist for New Filament Resources + +- Policy exists for the model or a spec documents why no policy is needed. +- `canViewAny`, `canCreate`, `canEdit`, `canDelete` call policies or capability resolver consistently. +- Tenant-owned resources scope queries by workspace and managed environment. +- Global search is disabled unless View/Edit pages are safe and scoped. +- Tables eager-load relationships shown in columns. +- Empty states do not leak tenant existence. +- Mutating actions are confirmation-gated and tested. +- Bulk actions intentionally choose `*Any` policy semantics or per-record authorization. + +## Checklist for File Uploads + +- Store on a private disk by default. +- Use random storage filenames. +- Store original filenames in a separate column if needed. +- Restrict `acceptedFileTypes()` and `maxSize()`. +- Use Laravel file validation rules for server-side validation. +- Use `preventFilePathTampering()` when the workflow does not intentionally allow choosing existing disk files. +- Do not render uploaded HTML/SVG inline unless sanitized and explicitly approved. +- Signed URLs must be short-lived and tenant-authorized. + +## Checklist for Admin Actions + +- Action name describes the business effect. +- UI state uses `UiEnforcement` or `WorkspaceUiEnforcement`. +- Server handler calls `Gate::authorize()` or a policy method. +- Destructive/high-impact action has `requiresConfirmation()`. +- Handler writes an audit event with actor, workspace, managed environment, target, outcome, and safe metadata. +- Long-running work dispatches a job and creates/updates an `OperationRun`. +- Duplicate clicks are idempotent or guarded by locks/unique run identity. +- Test covers allowed, disabled/denied, side effect, audit, and tenant isolation. + +## Checklist for Multi-Tenancy + +- Workspace context is established before tenant context. +- Non-members receive deny-as-not-found. +- Queries filter by `workspace_id` and tenant id before access. +- Cross-tenant surfaces are explicit and aggregation-based. +- IDs from request/query strings are resolved through scoped resolvers. +- Tests include tenant A cannot see or mutate tenant B. +- Audit logs include workspace and tenant context when applicable. + +## Security Code Pattern: Policy + +```php +namespace App\Policies; + +use App\Models\BackupSet; +use App\Models\User; +use Illuminate\Auth\Access\Response; + +final class BackupSetPolicy +{ + public function view(User $user, BackupSet $backupSet): Response + { + if (! $backupSet->workspace || ! $user->belongsToWorkspace($backupSet->workspace)) { + return Response::denyAsNotFound(); + } + + return $user->can('tenant.view', $backupSet->managedEnvironment) + ? Response::allow() + : Response::denyAsNotFound(); + } + + public function restore(User $user, BackupSet $backupSet): Response + { + if ($this->view($user, $backupSet)->denied()) { + return Response::denyAsNotFound(); + } + + return $user->can('tenant.restore.run', $backupSet->managedEnvironment) + ? Response::allow() + : Response::deny('Missing restore capability.'); + } +} +``` + +## Security Code Pattern: Audit Event + +```php +$audit->record( + action: 'backup_schedule.run_requested', + actor: $actor, + workspace: $schedule->workspace, + managedEnvironment: $schedule->managedEnvironment, + target: $schedule, + metadata: [ + 'operation_run_id' => $run->getKey(), + 'schedule_id' => $schedule->getKey(), + ], +); +``` + +Never include tokens, client secrets, raw credential payloads, or raw Graph error bodies in audit metadata. diff --git a/docs/stack-overview.md b/docs/stack-overview.md new file mode 100644 index 00000000..fa0bcf38 --- /dev/null +++ b/docs/stack-overview.md @@ -0,0 +1,206 @@ +# TenantPilot Stack Overview and Enterprise Assessment + +Status: 2026-05-15 +Scope: `apps/platform` Laravel/Filament application in `wt-plattform` +Project phase assumption: pre-production / MVP-to-scale-up, high criticality because Intune configuration, restore, audit, and tenant isolation are in scope. + +## Executive Summary + +TenantPilot already has a stronger-than-average governance foundation: Spec Kit is active, workspace and tenant isolation are explicit constitutional rules, Graph calls are centralized through `GraphClientInterface`, queued operations are observable via `OperationRun`, and many Filament actions use `UiEnforcement`, confirmation, audit logging, and capability checks. + +The main enterprise gaps are not conceptual; they are operational hardening gaps: + +1. P0: `composer audit` and `pnpm audit` currently report high/medium advisories affecting Filament Tables, phpseclib, PHPUnit, axios, devalue, esbuild, postcss, and related packages. +2. P1: Several critical historical JSON payload columns still use PostgreSQL `json` where the project data strategy requires `jsonb` for queryable snapshots and backup/restore payloads. +3. P1: Multiple Filament resources/pages exceed 1,000-5,700 lines, increasing change risk around admin workflows. +4. P1: Policy coverage is inconsistent: many resources use resource-level `can*()` plus gates, but not every resource-backed model has a dedicated policy. +5. P1: The local/Docker queue command uses `queue:listen`; production should use supervised `queue:work` or Laravel 12 `reload` semantics. +6. P2: Admin panel registration mixes explicit resources with discovery. This can be valid, but needs a documented rule to avoid accidental double mental models. +7. P2: Production configuration rules need to be encoded as deployment gates: `APP_DEBUG=false`, encrypted/secure sessions where needed, health checks, audit-safe logging, queue restart/reload, and backup restore testing. + +## Stack and Version Analysis + +| Bereich | Erkannte Version | Quelle/Datei | Status | Risiko | Empfehlung | +|---|---:|---|---|---|---| +| PHP runtime | 8.4.15 | Laravel Boost `application_info` | supported | low | Keep 8.4; track active support until 2026-12-31 and security support until 2028-12-31. | +| PHP constraint | `^8.2` | `apps/platform/composer.json` | broad | low | Keep if needed, but CI should test the actual runtime 8.4. | +| Laravel | 12.52.0 | Boost / `composer.lock` | current LTS-family app version | medium | Stay on 12.x for now; treat Laravel 13 as a planned major upgrade, not incidental. | +| Filament | 5.2.1 | Boost / `composer.lock` | vulnerable range | high | Upgrade to at least 5.3.5; preferably current 5.x after regression tests. | +| Livewire | 4.1.4 | Boost / `composer.lock` | compliant | low | Filament v5 + Livewire v4 compliance is satisfied. | +| Tailwind CSS | 4.2.2 | Boost / `pnpm-lock.yaml` | current minor behind | low | Tailwind v4 Vite integration is correct; update during frontend dependency patch window. | +| Alpine.js | unclear direct version | bundled transitively by Filament assets | unclear | low | Do not pin separately unless a project asset needs it. | +| PostgreSQL | 16 | `docker-compose.yml` | aligned | low | Use PostgreSQL-specific CI for JSONB, partial index, FK, and isolation assertions. | +| Redis | 7-alpine | `docker-compose.yml` | available | medium | Use for cache/queue when scale requires it; database queue is acceptable for MVP but not the long-term default. | +| Queue | database | `.env.example`, `config/queue.php` | MVP-grade | medium | Production should use supervised `queue:work`; split high/low/default queues for Graph/restore workloads. | +| Cache | database | `.env.example`, `config/cache.php` | MVP-grade | medium | Use Redis for production if queue restart signals, locks, and scheduler overlap become load-sensitive. | +| Session | database, encrypted false | `.env.example` | local default | medium | Production must set secure cookie/domain/same-site policy and consider `SESSION_ENCRYPT=true`. | +| Mail | log | `.env.example` | local default | low | Production needs SMTP/SES/Postmark decision and alert delivery tests. | +| Storage | local | `.env.example` | local default | medium | Production backup/report artifacts should use private object storage and tested restore paths. | +| Auth | Socialite + Microsoft Azure provider | `composer.json`, providers | aligned | medium | Review SocialiteProviders Microsoft-Azure 4.x to 5.x upgrade separately. | +| Testing | Pest 4.3.1, PHPUnit 12.5.4 | Boost / `composer.lock` | strong but vulnerable dev dep | high | Upgrade PHPUnit to a patched 12.5.x and keep Pest 4 lanes. | +| Frontend build | pnpm 10.33, Vite 7.3.2 | root/app package files | aligned | medium | Patch axios/postcss/esbuild/devalue advisories before production. | + +## Enterprise Maturity Score + +| Bereich | Score | Begründung | Zielzustand | +|---|---:|---|---| +| Architektur | 3.0 | Strong service/job/support layers, but some Filament surfaces are very large. | Thin UI classes, explicit services/actions for business workflows, no speculative frameworking. | +| Filament | 3.0 | Correct v5/Livewire 4 basis, panel providers in `bootstrap/providers.php`, central RBAC helper. | Standardized resource patterns, policy per resource, extracted schema/table/action builders where size justifies it. | +| Security | 3.0 | Tenant isolation, encrypted credential payloads, audit logs are strong. Supply-chain and policy coverage need work. | Audit gates in CI, patched dependencies, policy/resource coverage matrix, production security config gate. | +| Testing | 4.0 | Rich Pest/Filament/browser/governance lanes exist. | PostgreSQL lane required for schema/isolation changes; dependency audit gates mandatory. | +| Performance | 3.0 | Eager loading and queues exist; JSONB strategy is partially implemented. | Query budgets, JSONB indexes for queried payloads, worker separation, dashboard metrics. | +| DevOps | 3.0 | Sail-first local and Gitea CI exist. | Dokploy runbook, health checks, supervised workers, staging gate, rollback drills. | +| Observability | 3.0 | `OperationRun` and `AuditLog` create useful internal observability. | External error tracking/APM, queue/scheduler alerts, SLO dashboards. | +| Compliance | 2.5 | Audit/isolation foundations exist; GDPR/retention docs are incomplete. | Data inventory, retention matrix, DPA/vendor review, backup encryption proof. | +| Maintainability | 3.0 | Spec Kit and constitution reduce drift; large UI files raise regression risk. | Enforced file-size/refactor triggers and feature-local extraction patterns. | + +## Findings Register + +| ID | Kategorie | Finding | Evidenz | Risiko | Priorität | Aufwand | Empfehlung | Akzeptanzkriterium | +|---|---|---|---|---|---|---|---|---| +| F-001 | Supply Chain | Composer audit reports 8 advisories affecting 5 packages, including high severity Filament Tables XSS and phpseclib/PHPUnit advisories. | `composer audit --format=plain`; `filament/filament` 5.2.1, `phpunit/phpunit` 12.5.4 | XSS, crypto/DoS, unsafe dev tooling | P0 | M | Upgrade Filament to >=5.3.5, patch transitive packages, rerun full Filament/Pest lanes. | `composer audit` returns no high/medium advisories accepted by default policy. | +| F-002 | Supply Chain | pnpm audit reports high/moderate advisories for axios, devalue, esbuild, postcss and workspace packages. | `corepack pnpm audit --audit-level moderate --json` | SSRF, header injection, XSS, DoS | P0 | M | Update axios >=1.16.1, postcss >=8.5.10, devalue >=5.8.1, esbuild chain via dependency upgrade. | `pnpm audit --audit-level moderate` is clean or has approved exceptions. | +| F-003 | Datenbank | Core snapshot/backup/restore payload columns still use `json`, not `jsonb`. | `policy_versions.snapshot`, `backup_items.payload`, `restore_runs.preview/results/requested_items`, `audit_logs.metadata` migrations | Slow query paths, weaker indexing, inconsistency with product rule | P1 | M | Convert queryable payloads to JSONB with reversible migrations where feasible; add GIN/expression indexes only for proven queries. | Schema uses JSONB for policy snapshots, backup payloads, restore previews/results, and audit metadata query paths. | +| F-004 | Filament | Large workflow classes create high change risk. | `ManagedEnvironmentOnboardingWizard.php` 5748 LOC, `ManagedEnvironmentResource.php` 3785 LOC, `RestoreRunResource.php` 2779 LOC, `FindingResource.php` 2503 LOC | Regression risk, difficult review, slow onboarding | P1 | L | Extract schema/table/action factories and service actions at natural workflow boundaries; keep extra layers narrow. | Largest admin workflows have test-covered extracted builders/services and no single file exceeds agreed threshold without exception. | +| F-005 | Security | Resource/model policy coverage is inconsistent. | Policies exist for many models, but resources like `Policy`, `PolicyVersion`, `BackupSet`, `RestoreRun`, `ManagedEnvironment`, `BaselineProfile`, `InventoryItem`, `StoredReport` lack obvious dedicated policies. | Authorization drift between UI and server | P1 | M | Add policy classes or documented exceptions; make Filament resources call policies for CRUD and domain actions. | Resource-policy matrix is complete and tested. | +| F-006 | DevOps | Queue container uses `php artisan queue:listen`. | `docker-compose.yml:65` | Inefficient workers, production reload ambiguity | P1 | S | Production/Dokploy should run `php artisan queue:work --sleep=3 --tries=3 --timeout=300` under process supervision and reload/restart on deploy. | Deployment checklist has worker command, process monitor, restart/reload, queue metrics. | +| F-007 | Filament | Admin panel registers explicit resources and also discovers resources. | `AdminPanelProvider.php:198`, `:211` | Accidental resource exposure or inconsistent registration ownership | P2 | S | Choose explicit allowlist for enterprise panels or document discovery boundaries. | Panel registration rule is documented and covered by navigation/surface tests. | +| F-008 | Graph/Integration | Default Graph version is `beta`. | `apps/platform/config/graph.php:12` | API drift and production contract instability | P2 | M | Keep beta only where endpoint requires it; document endpoint version in `config/graph_contracts.php` and specs. | Each Graph contract records v1.0/beta, permission, retry behavior, and production risk. | +| F-009 | Testing/CI | PostgreSQL test lane exists but should be mandatory for schema/isolation changes. | `composer.json` has `test:pgsql`; default PHPUnit uses SQLite in memory. | SQLite misses JSONB, partial index, FK, and lock behavior | P2 | M | Add CI rule: migrations, tenant isolation, JSONB, operation locks require `sail:test:pgsql` or CI PostgreSQL lane. | PR checks show PostgreSQL lane on relevant path changes. | +| F-010 | Compliance | GDPR retention and backup security are not yet consolidated in one operational matrix. | Product docs exist; no single retention/backup/privacy matrix found in requested scope. | Incomplete audit readiness | P2 | M | Create retention matrix for audit logs, backups, reports, credentials, run payloads; include deletion/export procedure. | Retention matrix exists and is referenced from deployment/security docs. | + +## Recommended Next 10 Actions + +1. Patch Composer and pnpm advisories, starting with Filament >=5.3.5 and axios/postcss/devalue. +2. Add `composer audit` and `pnpm audit --audit-level moderate` to the confidence or release lane. +3. Create a resource-policy matrix and add missing policy classes or documented exceptions. +4. Convert core queryable JSON payloads to JSONB with targeted indexes. +5. Replace production queue guidance from `queue:listen` to supervised `queue:work` plus Laravel 12 `reload`/`queue:restart`. +6. Extract repeated Filament action closures from `BackupScheduleResource`, `RestoreRunResource`, and `ManagedEnvironmentResource` into focused action/service classes. +7. Require PostgreSQL CI for migrations, tenant isolation, operation locks, and JSONB behavior. +8. Document Graph endpoint version and permission truth in `config/graph_contracts.php` for every new integration. +9. Add production security config checklist: debug false, secure cookies, private storage, no secret logging, encrypted credentials. +10. Create retention/backup restore drill checklist before staging promotion. + +## Best-Practice Target State + +- Architecture: Laravel monolith with clear UI/application/domain/infrastructure boundaries; no speculative platform framework. +- Filament: v5-native resources/pages/widgets/actions with thin UI orchestration, policies, `UiEnforcement`, empty states, table standards, and tested actions. +- Backend: services/actions/jobs own business behavior; controllers and resources stay thin; remote work is queued and idempotent. +- Database: PostgreSQL integrity first: workspace/tenant constraints, partial unique indexes for active operations, JSONB for retained/queryable payloads. +- Security: OWASP ASVS-informed controls, deny-as-not-found isolation, least privilege, encrypted credentials, clean dependency audits. +- Testing: Pest 4 lanes protect business truth, Filament actions, policy semantics, PostgreSQL constraints, and browser-critical workflows. +- Deployment: Dokploy runbook with staging gate, health checks, migrations, asset build, supervised workers, rollback, backup restore proof. +- Observability: audit logs, operation runs, queue/scheduler metrics, Graph throttle metrics, error tracking, and production dashboards. +- Documentation: compact project rules in `docs/*-guidelines.md`, current AGENTS rules, and spec-linked decisions. +- AI coding: agents must follow `docs/ai-coding-rules.md`, Spec Kit, and version-specific official docs. + +## Anti-Pattern Catalog + +| Anti-Pattern | Warum problematisch | Besseres Pattern | Priorität | +|---|---|---|---| +| Fat Filament Resources/Pages | Hard to review, test, and safely change | Extract focused schema/table/action builders and domain actions | P1 | +| Business logic in closures | Authorization/audit/transaction behavior drifts | Service/action class called from UI action | P1 | +| Missing policies | UI checks become the only guard | Policy per resource-backed model or documented exception | P1 | +| N+1 table/global-search queries | Slow admin surfaces | Eager loading, `withCount`, aggregate subqueries | P2 | +| Unsafe uploads | RCE/path tampering/data leakage risk | Private disk, random filenames, MIME/size validation, tamper prevention | P2 | +| Missing transactions | Partial writes in critical workflows | Transaction around intent/run/audit state changes | P1 | +| JSON where JSONB is queried | Weak indexing and repeated parsing | JSONB plus targeted GIN/expression indexes | P1 | +| Fragile broad tests | Slow suite, unclear signal | Lane-scoped tests that prove business truth | P2 | +| Plugin/package drift | Security and maintenance risk | Package governance and audit gates | P0 | +| Admin actions without audit | No accountability for critical changes | Audit event for every sensitive mutation | P1 | +| Unclear roles/rights | Tenant data exposure risk | Capability matrix, policies, deny-as-not-found | P1 | +| No queue retry strategy | Duplicate/failed operations | Idempotent jobs, locks, run identity, backoff | P1 | + +## Roadmap + +### Phase 1: Stabilisieren + +Goal: remove immediate production blockers. + +Tasks: + +- Patch Composer and pnpm advisories. +- Add audit gates to CI/release. +- Replace production queue guidance with supervised `queue:work`. +- Add production env/security checklist enforcement. +- Add or confirm tests for destructive restore/backup/provider actions. + +Effort: M. +Risk: dependency upgrades can reveal Filament regressions. +Acceptance: audits clean, critical tests pass, staging deploy runbook works. + +### Phase 2: Standardisieren + +Goal: reduce admin workflow change risk. + +Tasks: + +- Build resource-policy matrix. +- Extract repeated action closure logic from largest resources. +- Standardize explicit vs discovered Filament resource registration. +- Document Graph contract version and permission rules per endpoint. +- Require PostgreSQL lane for schema/isolation changes. + +Effort: L. +Risk: refactors can conflict with active feature work. +Acceptance: resource-policy matrix complete, top three largest surfaces have bounded extraction plan/tests. + +### Phase 3: Skalieren + +Goal: prepare for higher tenant/data volume. + +Tasks: + +- Convert queryable JSON payloads to JSONB. +- Add targeted indexes for policy/backup/restore/audit query paths. +- Split queues and consider Redis for production. +- Add dashboard/queue/scheduler/Graph metrics. +- Cache stable aggregates where invalidation is clear. + +Effort: L. +Risk: migrations need staging timing proof. +Acceptance: query plans and queue metrics are documented; staging migration time is acceptable. + +### Phase 4: Enterprise Readiness + +Goal: compliance and operating model. + +Tasks: + +- Data inventory and retention matrix. +- Backup restore drill and incident response runbook. +- External error tracking/APM integration. +- Vendor/DPA/security review for mail/storage/hosting providers. +- Scheduled package/security review cadence. + +Effort: XL. +Risk: organizational dependencies outside code. +Acceptance: production readiness checklist is signed off and rehearseable. + +## Open Questions + +- Which production mail provider will be used: SMTP, SES, Mailgun, Postmark, or another service? +- Which production storage backend will hold backup/report artifacts: local volume, S3, R2, Spaces, or another private object store? +- Should production queue/cache move to Redis before first customer data, or after volume signals? +- What are the required retention periods for audit logs, backups, restore results, operation runs, reports, and support access logs? +- Is Microsoft Graph `beta` acceptable for each production endpoint, or must some flows be v1.0-only? +- What compliance bar is expected: internal GDPR readiness, enterprise customer security review, regulated procurement, or formal certification? + +## Sources + +- Official Laravel 12 docs: [deployment](https://laravel.com/docs/12.x/deployment), [authorization](https://laravel.com/docs/12.x/authorization), [queues](https://laravel.com/docs/12.x/queues), [validation](https://laravel.com/docs/12.x/validation). +- Official Filament 5 docs: [global search](https://filamentphp.com/docs/5.x/resources/global-search), [actions](https://filamentphp.com/docs/5.x/actions/overview), [security](https://filamentphp.com/docs/5.x/advanced/security), [testing actions](https://filamentphp.com/docs/5.x/testing/testing-actions). +- Official Pest 4 docs: [browser testing release notes](https://pestphp.com/docs/pest-v4-is-here-now-with-browser-testing). +- Official PostgreSQL 16 docs: [JSON/JSONB and GIN indexing](https://www.postgresql.org/docs/16/datatype-json.html). +- OWASP: [ASVS](https://owasp.org/www-project-application-security-verification-standard/). +- NIST: [SP 800-218 SSDF](https://csrc.nist.gov/pubs/sp/800/218/final). +- Microsoft Learn: [Graph throttling guidance](https://learn.microsoft.com/en-us/graph/throttling). +- PHP: [supported versions](https://www.php.net/supported-versions.php). + +## Assumptions and Uncertainties + +- Production is assumed to be Dokploy on VPS with container-based deployment, as stated in `AGENTS.md`. +- Exact production mail/storage/cache providers are not set in `.env.example`; recommendations are provider-neutral. +- Alpine.js exact version is not declared directly; it is treated as Filament-managed unless the project adds custom Alpine assets. +- The current branch has unrelated modified app/test files; this assessment did not alter them. diff --git a/docs/testing-guidelines.md b/docs/testing-guidelines.md new file mode 100644 index 00000000..9ba36075 --- /dev/null +++ b/docs/testing-guidelines.md @@ -0,0 +1,113 @@ +# TenantPilot Testing Guidelines + +Status: 2026-05-15 +Applies to: Pest 4.3+, PHPUnit 12, Laravel 12, Filament 5, Livewire 4. + +## Test Philosophy + +Tests protect business truth: workspace isolation, tenant isolation, RBAC, auditability, immutable snapshots, restore safety, queued operation correctness, and Graph contract safety. + +Do not create broad tests for thin presentation helpers unless the helper encodes operator-critical behavior. + +## Test Pyramid + +| Layer | Use for | Default lane | +|---|---|---| +| Unit | Pure services, value objects, mappers, policy helpers | fast-feedback | +| Feature | HTTP, DB, policies, queued jobs, audit side effects | fast-feedback/confidence | +| Filament/Livewire | Pages, widgets, relation managers, actions | confidence | +| PostgreSQL | migrations, JSONB, partial indexes, locks, FK isolation | pgsql | +| Browser | critical multi-step UI, JS smoke, visual/user workflow checks | browser | +| Heavy governance | broad surface discovery and drift checks | heavy-governance | + +## Minimum Standard for New Features + +- Every new policy gets allowed and denied tests. +- Every new destructive/high-impact Filament action gets action tests. +- Every new tenant-owned model gets cross-tenant isolation tests. +- Every new migration touching constraints/indexes gets PostgreSQL lane coverage when SQLite cannot prove the behavior. +- Every job that calls Graph is tested for idempotency, terminal-state handling, retry/throttle classification, and safe logging. +- Every feature spec states test impact and lane classification. + +## Critical User Journeys to Keep Covered + +- Workspace selection and tenant selection. +- Provider connection create/verify/disable/health-check. +- Policy sync, snapshot capture, version history, diff navigation. +- Backup set creation, add policies, schedule run/retry. +- Restore preview, confirmation, execution, partial failure handling. +- Finding triage, assignment, exception, evidence review. +- Audit log visibility and tenant-scope enforcement. +- System panel login/session isolation and platform capability checks. + +## Filament Action Test Pattern + +```php +use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules; +use App\Jobs\RunBackupScheduleJob; +use Illuminate\Support\Facades\Bus; +use function Pest\Livewire\livewire; + +it('queues a backup schedule run for an authorized tenant member', function () { + Bus::fake(); + + [$user, $tenant, $schedule] = tenantUserWithBackupScheduleRunCapability(); + + actingAs($user); + Filament::setTenant($tenant); + + livewire(ListBackupSchedules::class) + ->assertTableActionVisible('runNow', $schedule) + ->callTableAction('runNow', $schedule); + + Bus::assertDispatched(RunBackupScheduleJob::class); + + expectAuditLogged('backup_schedule.run_requested', $schedule); +}); +``` + +## Policy Test Pattern + +```php +it('hides another workspace backup set as not found', function () { + [$actor, $ownWorkspace] = workspaceMember(); + $foreignBackupSet = BackupSet::factory()->forWorkspace()->create(); + + $response = Gate::forUser($actor)->inspect('view', $foreignBackupSet); + + expect($response->denied())->toBeTrue() + ->and($response->status())->toBe(404); +}); +``` + +## PostgreSQL Lane Rule + +Use `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml` or the equivalent CI PostgreSQL lane for: + +- JSONB migrations and GIN indexes. +- Partial unique indexes. +- Composite foreign keys. +- `lockForUpdate()` behavior. +- Tenant/workspace constraint migrations. +- Query plans where performance depends on PostgreSQL-specific operators. + +## CI Recommendation + +Release/confidence CI should run: + +1. `composer validate --strict` +2. `composer audit` +3. `corepack pnpm audit --audit-level moderate` +4. `./vendor/bin/pint --test` +5. `composer run test` +6. `composer run test:pgsql` when database paths changed +7. `composer run test:browser` for UI workflow changes +8. `corepack pnpm build:platform` + +## Fragility Controls + +- Prefer factories with explicit state over global seed assumptions. +- Keep full workspace/tenant/member setup opt-in. +- Assert outcomes, audit events, and authorization behavior, not implementation details. +- Use fake Graph clients that fail hard if UI rendering accidentally calls Graph. +- Avoid snapshot tests for volatile admin markup unless visual regression is the real goal. diff --git a/specs/311-workspace-environment-surface-scope-contract/plan.md b/specs/311-workspace-environment-surface-scope-contract/plan.md new file mode 100644 index 00000000..28438ef6 --- /dev/null +++ b/specs/311-workspace-environment-surface-scope-contract/plan.md @@ -0,0 +1,189 @@ +# Implementation Plan: Workspace / Environment Surface Scope Contract + +**Branch**: `311-workspace-environment-surface-scope-contract` | **Date**: 2026-05-15 | **Spec**: `specs/311-workspace-environment-surface-scope-contract/spec.md` +**Input**: Feature specification from `/specs/311-workspace-environment-surface-scope-contract/spec.md` + +## Summary + +Implement the global admin surface scope contract: route scope controls shell/navigation, page filters control data. Explicit workspace-wide surfaces remain tenantless in the shell even with query filters or remembered/Filament tenant context. Canonical environment routes remain environment-bound. Legacy tenant-owned admin lists keep their existing data scoping until a separate route cutover handles them. + +## Technical Context + +**Language/Version**: PHP 8.4, Laravel 12, Filament v5, Livewire v4 +**Primary Dependencies**: Existing Filament panel, `TenantPageCategory`, `NavigationScope`, `OperateHubShell`, `CanonicalAdminTenantFilterState` +**Storage**: No database changes +**Testing**: Pest v4 / Livewire component tests / HTTP feature tests +**Validation Lanes**: focused feature/unit tests, Pint dirty, `git diff --check` +**Target Platform**: TenantPilot admin panel +**Project Type**: Laravel monorepo +**Performance Goals**: No extra database work beyond existing widget/list queries +**Constraints**: No migrations, no RBAC changes, no Customer Review Workspace product completion, no broad navigation rebuild, no sidebar query magic +**Scale/Scope**: Representative workspace-wide surfaces, canonical environment routes, and legacy tenant-owned regression coverage + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: shell scope, topbar labels, sidebar classification, and table/widget filter defaults. +- **Native vs custom classification summary**: Filament-native pages/resources/widgets only; no custom styling. +- **Shared-family relevance**: global admin navigation shell and workspace-wide monitoring/governance/reporting surfaces. +- **State layers in scope**: route path, query filter values, table filter session state, remembered tenant session, Filament tenant context. +- **Audience modes in scope**: workspace operator/admin only. +- **Decision/diagnostic/raw hierarchy plan**: N/A. +- **Raw/support gating plan**: N/A. +- **One-primary-action / duplicate-truth control**: prevent duplicate truth by making shell route-owned and filters page-owned. +- **Handling modes by drift class or surface**: explicit workspace-wide surfaces become tenantless shell; canonical environment routes remain tenant-bound; legacy tenant-owned lists remain current behavior. +- **Repository-signal treatment**: test-first red/green on representative surfaces and parity tests. +- **Special surface test profiles**: Livewire tests must set a realistic `referer` when asserting route-derived shell behavior. +- **Required tests or manual smoke**: focused Pest/Livewire tests; browser smoke not required because no styling/layout change. +- **Exception path and spread control**: future workspace-wide surfaces must be added to `TenantPageCategory` and covered by the surface matrix. +- **Active feature PR close-out entry**: Workspace / Environment Surface Scope Contract. + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: admin shell/navigation contract. +- **Systems touched**: route taxonomy, shell context, workspace-wide filters, monitoring/alerts widgets. +- **Shared abstractions reused**: `TenantPageCategory`, `TenantInteractionLane`, `OperateHubShell`, `NavigationScope`, `CanonicalAdminTenantFilterState`. +- **New abstraction introduced? why?**: No new service or registry. Existing enum gains `WorkspaceWideSurface`. +- **Why the existing abstraction was sufficient or insufficient**: The existing taxonomy already owns page category; it needed one extra category to separate explicit workspace-wide hubs from legacy workspace-scoped tenant-owned lists. +- **Bounded deviation / spread control**: explicit path matching stays centralized in `TenantPageCategory`. + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: Yes, Operations list/KPI scope only. +- **Central contract reused**: `OperationRunLinks` and existing Operations page remain. +- **Delegated UX behaviors**: no start/completion changes. +- **Surface-owned behavior kept local**: Operations tabs/table query remain local. +- **Queued DB-notification policy**: unchanged. +- **Terminal notification path**: unchanged. +- **Exception path**: none. + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: provider connections and alerts as workspace-wide/provider-level surfaces. +- **Provider-owned seams**: Microsoft-specific inventory/policy/backup legacy tenant-owned lists remain out of this minimal cutover. +- **Platform-core seams**: workspace, environment, provider connection, alert delivery, audit log, governance/workspace hubs. +- **Neutral platform terms / contracts preserved**: workspace-wide, environment-bound, provider connection, environment filter. +- **Retained provider-specific semantics and why**: legacy tenant-owned direct lists retain current tenant data context pending their own route migration. +- **Bounded extraction or follow-up path**: 312 Customer Review Workspace v1 Completion follows after this contract; tenant-owned route cutover remains separate. + +## Constitution Check + +- Inventory-first: PASS. No inventory truth changes. +- Read/write separation: PASS. Read-only shell/list/widget behavior only. +- Graph contract path: PASS. No Graph calls changed. +- Deterministic capabilities: PASS. No capability mapping changes. +- Workspace isolation: PASS. Workspace query scopes remain enforced. +- Tenant isolation: PASS. Environment-bound routes and legacy tenant-owned lists remain protected. +- Run observability: PASS. OperationRun data visibility remains entitlement-scoped. +- Test governance: PASS. Focused tests plus parity regression; no new heavy family. +- Proportionality: PASS. One enum category in an existing taxonomy is narrower than a registry or navigation rebuild. +- No premature abstraction: PASS. No new service/interface/framework. +- Persisted truth: PASS. No persisted runtime truth. +- Behavioral state: PASS. Route category affects shell behavior only. +- Shared pattern first: PASS. Existing shell/taxonomy path reused. +- Provider boundary: PASS. Platform-core route contract stays provider-neutral. +- Filament-native UI: PASS. No assets, no view publishing. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Feature tests for admin shell and Livewire pages/widgets; Unit test for route category classification. +- **Affected validation lanes**: focused Feature/Unit lane. +- **Why this lane mix is narrowest sufficient proof**: The bug is route/shell/filter behavior, provable by HTTP and Livewire component tests without browser layout verification. +- **Narrowest proving command(s)**: + - Focused contract and neighboring surface tests under `tests/Feature/...` + - `tests/Unit/Tenants/TenantPageCategoryTest.php` + - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + - `git diff --check` +- **Fixture / helper / factory / seed / context cost risks**: Reuses existing factories/helpers; no new heavy fixture family. +- **Expensive defaults or shared helper growth introduced?**: no. +- **Heavy-family additions, promotions, or visibility changes**: none. +- **Surface-class relief / special coverage rule**: Browser smoke not required; no visual layout/styling change. +- **Closing validation and reviewer handoff**: Verify representative workspace-wide surfaces, environment-bound routes, and legacy tenant-owned parity tests. +- **Budget / baseline / trend follow-up**: none expected. +- **Review-stop questions**: any per-page shell exception, query-driven sidebar, RBAC change, migration, or Customer Review product logic. +- **Escalation path**: remaining legacy route taxonomy questions belong to a follow-up route cutover/audit spec. +- **Active feature PR close-out entry**: Workspace / Environment Surface Scope Contract. + +## Project Structure + +```text +apps/platform/app/Support/Tenants/TenantPageCategory.php +apps/platform/app/Support/Tenants/TenantInteractionLane.php +apps/platform/app/Support/OperateHub/OperateHubShell.php +apps/platform/app/Filament/Pages/Reviews/* +apps/platform/app/Filament/Resources/AlertDeliveryResource.php +apps/platform/app/Filament/Resources/ProviderConnectionResource.php +apps/platform/app/Filament/Widgets/Alerts/AlertsKpiHeader.php +apps/platform/app/Filament/Widgets/Operations/OperationsKpiHeader.php +apps/platform/tests/Feature/** +apps/platform/tests/Unit/Tenants/TenantPageCategoryTest.php +specs/311-workspace-environment-surface-scope-contract/ +``` + +**Structure Decision**: Extend existing taxonomy and tests in place. Do not introduce a new surface registry. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|---|---|---| +| Add enum category | Existing `WorkspaceScoped` combined explicit workspace hubs with legacy tenant-owned lists | Forcing all `WorkspaceScoped` tenantless regressed legacy tenant-owned resource data scoping; local page exceptions would spread | + +## Proportionality Review + +- **Current operator problem**: Workspace hubs can display environment context in the shell while also showing page-level environment filters. +- **Existing structure is insufficient because**: `WorkspaceScoped` was too broad for both workspace hubs and legacy tenant-owned direct lists. +- **Narrowest correct implementation**: Add `WorkspaceWideSurface` to the existing category enum and force tenantless shell only there. +- **Ownership cost created**: Future workspace-wide routes must be added centrally with tests. +- **Alternative intentionally rejected**: CRW-only patch, `?tenant` sidebar switching, broad nav rewrite, or converting all legacy tenant-owned routes in this branch. +- **Release truth**: Current platform shell contract. + +## Phase 0: Read-Only Findings + +- Operations already behaved correctly because it had explicit all-environments/page-filter semantics. +- Customer Review Workspace, Review Register, Audit Log, Alerts, and Alert Delivery could inherit remembered/Filament tenant through shell or filter defaults. +- Provider Connections defaulted its list filter from the resolved scoped tenant instead of explicit query state. +- Legacy tenant-owned resource pages still rely on remembered tenant data context; this branch preserves them and protects them with parity tests. + +## Implementation Notes + +- `TenantPageCategory::fromRequest()` now uses Livewire referer paths for Livewire update requests so shell resolution matches browser route scope. +- `WorkspaceWideSurface` covers explicit workspace-wide admin surfaces, including Operations, Reviews workspace/register, Governance Inbox/Decision Register, Evidence Overview, Audit Log, Provider Connections, Alerts, and Workspace Overview. +- `OperateHubShell` returns tenantless workspace context before query/Filament/remembered tenant resolution for `WorkspaceWideSurface`. +- Customer Review Workspace and Review Register no longer default their environment filters from remembered tenant and no longer clear global last-tenant state when clearing page filters. +- Alert Delivery list and Alerts KPI are workspace-wide and no longer use shell tenant as data default. +- Operations KPI now counts the workspace entitlement scope when the shell is tenantless. +- Provider Connection list filter defaults only from explicit request state, not remembered/global context. + +## Validation Plan + +Run the focused contract and neighboring tests, then Pint and whitespace checks: + +```bash +cd apps/platform +./vendor/bin/sail artisan test --compact +./vendor/bin/sail bin pint --dirty --format agent +cd ../.. +git diff --check +``` + +## Implementation Close-Out + +- **Changed runtime areas**: route taxonomy, shell resolution, workspace-wide page filter defaults, Operations KPI, Alerts KPI/list, Provider Connections filter default. +- **Changed tests**: focused shell/navigation, Operations, Customer Review Workspace, Review Register, Audit Log, Alerts, Provider Connections, tenant-owned parity, and route category coverage. +- **Validation completed**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact ...` for the focused 311 selection: 172 passed, 703 assertions. + - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`: pass. + - `git diff --check`: pass. +- **No migrations**: confirmed. +- **No RBAC changes**: confirmed. +- **No Customer Review Workspace product logic**: confirmed; only shell/filter-scope behavior changed. +- **No asset changes**: confirmed. +- **Follow-up**: 312 can continue Customer Review Workspace v1 Completion against this shell contract. + +## Filament v5 Output Contract + +1. **Livewire v4.0+ compliance**: This app uses Livewire v4 with Filament v5; tests mount Filament Livewire pages/widgets. +2. **Provider registration**: No panel provider registration changes. Laravel 11+/12 provider registration remains in `apps/platform/bootstrap/providers.php`. +3. **Global search**: No globally searchable resource behavior changed. Provider Connection remains globally searchable disabled; Alert Delivery changes do not enable search. +4. **Destructive actions**: No destructive actions added or changed. +5. **Assets**: No assets added or changed; `filament:assets` deployment process unchanged. +6. **Testing**: HTTP feature tests, Livewire component tests, and route category unit tests cover the contract. diff --git a/specs/311-workspace-environment-surface-scope-contract/spec.md b/specs/311-workspace-environment-surface-scope-contract/spec.md new file mode 100644 index 00000000..83375cb0 --- /dev/null +++ b/specs/311-workspace-environment-surface-scope-contract/spec.md @@ -0,0 +1,134 @@ +# Feature Specification: Workspace / Environment Surface Scope Contract + +**Feature Branch**: `311-workspace-environment-surface-scope-contract` +**Created**: 2026-05-15 +**Status**: Draft +**Input**: User decision: split former mixed 311 work so 311 delivers the global workspace/environment navigation-scope contract and 312 delivers Customer Review Workspace v1 completion. + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: Workspace-wide admin surfaces can inherit global or remembered environment context, causing topbar/sidebar/breadcrumb to imply an environment-bound route while the page is actually workspace-wide with an environment filter. +- **Today's failure**: Customer Review Workspace can show `workspace > environment` in the shell and an active environment table filter at the same time. The same pattern can recur in Operations, Decision Register, Governance Inbox, Evidence Overview, Audit Log, Provider Connections, and Alerts if scope is solved page-by-page. +- **User-visible improvement**: Operators can distinguish route context from data filters: workspace-wide pages keep workspace navigation, and environment filters remain page-level controls. +- **Smallest enterprise-capable version**: A central route-taxonomy contract plus focused tests for representative workspace-wide, environment-bound, and legacy tenant-owned surfaces. +- **Explicit non-goals**: No Customer Review Workspace product completion, Review Pack changes, RBAC changes, migrations, new tables, broad navigation rebuild, billing, localization, artifact lifecycle, PSA, or AI work. +- **Permanent complexity imported**: One narrow route-category classification for explicit workspace-wide admin surfaces. +- **Why now**: Continuing Customer Review Workspace v1 would otherwise encode a shell/sidebar exception into one page and repeat the bug on the next workspace hub. +- **Why not local**: A local CRW fix would not protect Decision Register, Governance Inbox, Audit Log, Provider Connections, Alerts, or future workspace-wide hubs. +- **Approval class**: Core Enterprise. +- **Red flags triggered**: Cross-cutting taxonomy/classification. Covered by proportionality review below. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: platform admin shell, route taxonomy, topbar/sidebar scope, and page-level environment filter defaults. +- **Primary Routes**: `/admin/workspaces/{workspace}/operations`, `/admin/reviews/workspace`, `/admin/reviews`, `/admin/governance/decisions`, `/admin/governance/inbox`, `/admin/evidence/overview`, `/admin/audit-log`, `/admin/provider-connections`, `/admin/alerts`, `/admin/workspaces/{workspace}/overview`, and `/admin/workspaces/{workspace}/environments/{environment}/...`. +- **Data Ownership**: No ownership model changes. Workspace-wide surfaces aggregate workspace-authorized data; environment-bound routes remain canonical environment context. +- **RBAC**: No RBAC capability changes. Existing server-side authorization remains the security boundary. + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: Explicit workspace-wide surfaces must not default their page filter from Filament tenant or remembered tenant context. Query parameters may set page-level filters when the page already supports that filter. +- **Explicit entitlement checks preventing cross-tenant leakage**: Existing workspace and environment entitlement checks continue to apply. UI shell scope is not authorization. + +## Cross-Cutting / Shared Pattern Reuse *(mandatory)* + +- **Cross-cutting feature?**: yes, admin shell and navigation scope. +- **Interaction class(es)**: topbar context, sidebar registration, page-level filters, header KPI/list widgets. +- **Systems touched**: `TenantPageCategory`, `TenantInteractionLane`, `OperateHubShell`, Operations KPI, Alert Delivery list/KPI, Provider Connection filter default, Review Register, Customer Review Workspace, and focused tests. +- **Existing pattern(s) to extend**: existing `TenantPageCategory`, `NavigationScope`, `OperateHubShell`, and `CanonicalAdminTenantFilterState`. +- **Shared contract / presenter / builder / renderer to reuse**: reuse route taxonomy and shell context resolution; no new service or registry. +- **Why the existing shared path is sufficient or insufficient**: Existing taxonomy distinguishes tenant-bound and workspace-scoped routes but did not distinguish explicit workspace-wide hubs from legacy tenant-owned admin lists. +- **Allowed deviation and why**: Add one route-category case for explicit workspace-wide surfaces. This is narrower than a new contract registry. +- **Consistency impact**: Query parameters must not alter navigation scope. Page filters must stay visible as filters. +- **Review focus**: Verify no per-page hardcoded shell exception and no `?tenant` sidebar magic. + +## OperationRun UX Impact *(mandatory)* + +- **Touches OperationRun start/completion/link UX?**: Operations list and KPI shell/filter behavior only. +- **Shared OperationRun UX contract/layer reused**: Existing Operations page and `OperationRunLinks` remain canonical. +- **Delegated start/completion UX behaviors**: unchanged. +- **Local surface-owned behavior that remains**: Operations table query and tabs remain page-owned. +- **Queued DB-notification policy**: unchanged. +- **Terminal notification path**: unchanged. +- **Exception required?**: none. + +## Provider Boundary / Platform Core Check *(mandatory)* + +- **Shared provider/platform boundary touched?**: yes, provider-neutral admin surface terminology. +- **Boundary classification**: platform-core shell/navigation. +- **Seams affected**: provider connections and alerts as workspace-owned/provider-level surfaces. +- **Neutral platform terms preserved or introduced**: Use workspace, environment, provider connection, target scope. +- **Provider-specific semantics retained and why**: Microsoft/Intune resource pages outside the explicit workspace-wide surface set retain current tenant-owned behavior until their route cutover is separately specified. +- **Why this does not deepen provider coupling accidentally**: Route-scope classification is platform-owned and provider-neutral. +- **Follow-up path**: remaining legacy tenant-owned direct admin lists stay covered by existing route-audit specs, not by this minimal 311 branch. + +## UI / Surface Guardrail Impact *(mandatory)* + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---:|---|---|---|---:|---| +| Admin shell topbar/sidebar scope | yes | Filament/native hooks | global shell/navigation | route category, shell context | no | No styling or asset changes | +| Operations / Review / Governance / Audit / Alerts filters | yes | Filament tables/widgets | workspace-wide filters | table filter defaults | no | Existing filters remain | +| Environment-bound canonical routes | no behavioral expansion | Filament resources/pages | canonical environment context | route category only | no | Must remain environment-bound | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no new persisted truth. Route path remains the source of route scope. +- **New persisted entity/table/artifact?**: no. +- **New abstraction?**: no new service. One enum category is added to an existing taxonomy. +- **New enum/state/reason family?**: yes, `WorkspaceWideSurface` in the existing `TenantPageCategory`. +- **New cross-domain UI framework/taxonomy?**: no framework. Narrow taxonomy hardening only. +- **Current operator problem**: The shell can show an environment context while the page is workspace-wide and separately filtered. +- **Existing structure is insufficient because**: `WorkspaceScoped` covers both explicit workspace hubs and legacy tenant-owned admin lists, so forcing all of it tenantless breaks legacy data surfaces while not forcing any of it leaves CRW/Alerts/Audit ambiguous. +- **Narrowest correct implementation**: Classify only explicit workspace-wide surface routes centrally and make `OperateHubShell` tenantless for that category. +- **Ownership cost**: Future workspace-wide surfaces must be added to the taxonomy and tests. +- **Alternative intentionally rejected**: Hardcoding `/admin/reviews/workspace` in `OperateHubShell`, switching sidebar based on `?tenant`, or broad-rebuilding all navigation. +- **Release truth**: Current-release platform shell behavior. + +### Compatibility posture + +Pre-production compatibility allows route taxonomy hardening without migration shims. Legacy tenant-owned direct admin lists are not rewritten in this slice. + +## Global Admin Surface Scope Contract + +1. Route scope determines sidebar, topbar, and breadcrumb. +2. Query parameters such as `tenant`, `environment`, `managed_environment_id`, and `tenant_scope` never change navigation scope. +3. Workspace-wide pages remain workspace-wide even when an environment filter is active. +4. Environment-bound navigation is allowed only on canonical `/admin/workspaces/{workspace}/environments/{environment}/...` routes. +5. Topbar must reflect route scope. +6. Page-level filters must appear as filters, not global context. +7. Remembered or last selected environment must not auto-bind workspace-wide hubs. +8. Workspace-wide pages need explicit all-environments or filtered-by-environment semantics. +9. Environment-owned detail links should use canonical environment-bound URLs when the detail is environment context. +10. Filtered empty states must mention active filters. +11. Direct URL and RBAC remain server-side protected; UI context is not authorization. +12. No sidebar magic based on `?tenant`. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Workspace-wide shell stays workspace-wide (Priority: P1) + +As an operator on a workspace hub, I want the topbar/sidebar to stay at workspace scope even when I arrive with an environment query filter. + +**Independent Test**: Visit representative workspace-wide routes with `tenant` or `managed_environment_id` query parameters and assert no environment shell scope appears. + +### User Story 2 - Environment-bound routes keep environment context (Priority: P1) + +As an operator on a canonical environment route, I want the shell to show the selected environment and environment navigation. + +**Independent Test**: Visit `/admin/workspaces/{workspace}/environments/{environment}/...` and assert environment route classification remains environment-bound. + +### User Story 3 - Legacy tenant-owned lists do not regress (Priority: P1) + +As an operator using legacy tenant-owned admin lists, I want existing data scoping to remain intact until those surfaces are explicitly cut over. + +**Independent Test**: Existing tenant-owned resource parity tests still pass while explicit workspace-wide surfaces use the new shell contract. + +## Anti-Patterns To Avoid + +- `?tenant` switches sidebar. +- Global selected environment on workspace-wide hubs. +- Double semantics: topbar environment plus table environment filter. +- Customer-safe detail forced onto a workspace-only route. +- New per-page shell exceptions. diff --git a/specs/311-workspace-environment-surface-scope-contract/tasks.md b/specs/311-workspace-environment-surface-scope-contract/tasks.md new file mode 100644 index 00000000..a5a6a51b --- /dev/null +++ b/specs/311-workspace-environment-surface-scope-contract/tasks.md @@ -0,0 +1,81 @@ +# Tasks: Workspace / Environment Surface Scope Contract + +**Input**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/311-workspace-environment-surface-scope-contract/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/311-workspace-environment-surface-scope-contract/plan.md` +**Prerequisites**: Current branch is `311-workspace-environment-surface-scope-contract`; Customer Review Workspace product completion is deferred to 312. +**Scope**: Global admin shell/navigation/filter contract only. + +**Tests**: Pest/Livewire feature tests and route-category unit test. + +## Test Governance Checklist + +- [x] Lane assignment is named and is the narrowest sufficient proof: focused Feature/Unit lane. +- [x] New or changed tests stay in existing surface families. +- [x] No new shared test helper, seed, factory, or heavy family is introduced. +- [x] Browser smoke is not required because no visual layout/styling changed. +- [x] Legacy tenant-owned resource parity remains covered to prevent over-broad scope regression. + +## Format: `[ID] [P?] [Story?] Description with file path` + +## Phase 1: Branch and WIP Split + +- [x] T001 Save dirty Customer Review Workspace WIP patch to `/tmp/311-customer-review-workspace-wip.patch`. +- [x] T002 Stash dirty WIP, including untracked files, before switching branches. +- [x] T003 Switch from the mixed branch to `platform-dev`, pull, and create `311-workspace-environment-surface-scope-contract`. +- [x] T004 Confirm no commits are made during implementation. + +## Phase 2: Contract Tests First + +- [x] T005 [US1] Add workspace-wide query-independence cases to `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/PanelNavigationSegregationTest.php`. +- [x] T006 [US1] Add representative shell-contract coverage to `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php`. +- [x] T007 [US1] Update Operations topbar/filter tests in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Monitoring/OperationsTenantScopeTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Monitoring/OperationsKpiHeaderTenantContextTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Spec085/OperationsIndexHeaderTest.php`. +- [x] T008 [US1] Update Customer Review Workspace and Review Register filter tests in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewRegisterPrefilterTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewRegisterTest.php`. +- [x] T009 [US1] Update Audit Log and Alerts workspace-wide tests in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/AuditLogPageTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php`. +- [x] T010 [US2] Add route-category coverage to `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Tenants/TenantPageCategoryTest.php`. +- [x] T011 [US3] Keep `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/TenantOwnedResourceScopeParityTest.php` green as a legacy tenant-owned regression guard. + +## Phase 3: Runtime Contract + +- [x] T012 [US1] Add `WorkspaceWideSurface` classification to `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Tenants/TenantPageCategory.php`. +- [x] T013 [US1] Make `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Tenants/TenantPageCategory.php` resolve Livewire update/request paths from referer when needed. +- [x] T014 [US1] Update `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Tenants/TenantInteractionLane.php` for the new route category. +- [x] T015 [US1] Update `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/OperateHub/OperateHubShell.php` so `WorkspaceWideSurface` resolves tenantless before query, Filament tenant, or remembered tenant hints. +- [x] T016 [US1] Remove remembered-tenant default filters and global-context clearing from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php`. +- [x] T017 [US1] Make Provider Connection list filtering query-driven only in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ProviderConnectionResource.php`. +- [x] T018 [US1] Make Alert Delivery list and Alerts KPI workspace-wide in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/AlertDeliveryResource.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Widgets/Alerts/AlertsKpiHeader.php`. +- [x] T019 [US1] Make Operations KPI use workspace entitlement scope when shell is tenantless in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Widgets/Operations/OperationsKpiHeader.php`. + +## Phase 4: Spec Artifacts + +- [x] T020 Create `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/311-workspace-environment-surface-scope-contract/spec.md`. +- [x] T021 Create `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/311-workspace-environment-surface-scope-contract/plan.md`. +- [x] T022 Create `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/311-workspace-environment-surface-scope-contract/tasks.md`. + +## Phase 5: Validation and Close-Out + +- [x] T023 Run the focused 311 Feature/Unit test selection from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform`. +- [x] T024 Run `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`. +- [x] T025 Run `git diff --check` from `/Users/ahmeddarrazi/Documents/projects/wt-plattform`. +- [x] T026 Record final changed files, validation, no-migration/no-RBAC/no-CRW-product-logic close-out. + +## Dependencies + +- T001-T004 before runtime edits. +- T005-T011 before or alongside T012-T019. +- T012-T019 before final validation. +- T020-T022 before final close-out because runtime files changed. +- T023-T026 last. + +## Parallel Work Examples + +- T005-T011 can be maintained by surface family after the contract is known. +- T016-T019 are disjoint runtime surface updates after T012-T015. +- T023 can run before T024/T025; T024 and T025 are final formatting/whitespace checks. + +## Implementation Strategy + +1. Preserve 312 WIP by patch/stash. +2. Establish route-scope tests first. +3. Harden central taxonomy and shell. +4. Remove remembered/global tenant defaults from explicit workspace-wide surfaces. +5. Preserve legacy tenant-owned list parity. +6. Validate focused suite, format, and whitespace.