From 446afff5ecf4737419611a1b337ad62ebab154a1 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 17 May 2026 02:25:52 +0200 Subject: [PATCH] feat: implement environment filtering for alerts and audit logs --- .../Clusters/Monitoring/AlertsCluster.php | 43 ++ .../app/Filament/Pages/Monitoring/Alerts.php | 81 +++ .../Filament/Pages/Monitoring/AuditLog.php | 93 +++- .../Pages/ListAlertDeliveries.php | 130 ++++- .../Widgets/Alerts/AlertsKpiHeader.php | 13 +- .../GovernanceInboxSectionBuilder.php | 12 +- .../SupportDiagnosticBundleBuilder.php | 5 +- .../Workspaces/WorkspaceOverviewBuilder.php | 26 +- .../pages/monitoring/alerts.blade.php | 17 +- .../pages/monitoring/audit-log.blade.php | 9 + ...ertsAuditEnvironmentFilterContractTest.php | 382 ++++++++++++++ .../checklists/requirements.md | 52 ++ .../decision.md | 58 +++ .../plan.md | 240 +++++++++ .../spec.md | 473 ++++++++++++++++++ .../tasks.md | 123 +++++ 16 files changed, 1727 insertions(+), 30 deletions(-) create mode 100644 apps/platform/tests/Feature/Navigation/Spec321AlertsAuditEnvironmentFilterContractTest.php create mode 100644 specs/321-alerts-audit-log-environment-filter-contract-decision/checklists/requirements.md create mode 100644 specs/321-alerts-audit-log-environment-filter-contract-decision/decision.md create mode 100644 specs/321-alerts-audit-log-environment-filter-contract-decision/plan.md create mode 100644 specs/321-alerts-audit-log-environment-filter-contract-decision/spec.md create mode 100644 specs/321-alerts-audit-log-environment-filter-contract-decision/tasks.md diff --git a/apps/platform/app/Filament/Clusters/Monitoring/AlertsCluster.php b/apps/platform/app/Filament/Clusters/Monitoring/AlertsCluster.php index d0daa765..9806495a 100644 --- a/apps/platform/app/Filament/Clusters/Monitoring/AlertsCluster.php +++ b/apps/platform/app/Filament/Clusters/Monitoring/AlertsCluster.php @@ -4,6 +4,10 @@ namespace App\Filament\Clusters\Monitoring; +use App\Models\Workspace; +use App\Support\Navigation\WorkspaceHubEnvironmentFilter; +use App\Support\Navigation\WorkspaceHubFilterStateResetter; +use App\Support\Workspaces\WorkspaceContext; use BackedEnum; use Filament\Clusters\Cluster; use Filament\Facades\Filament; @@ -24,4 +28,43 @@ public static function shouldRegisterNavigation(): bool { return Filament::getCurrentPanel()?->getId() === 'admin'; } + + public function mount(): void + { + app(WorkspaceHubFilterStateResetter::class)->neutralizeEnvironmentLikeQueryState(request()); + + $environmentFilterQuery = $this->environmentFilterQuery(); + + foreach ($this->getCachedSubNavigation() as $navigationGroup) { + foreach ($navigationGroup->getItems() as $navigationItem) { + $url = $navigationItem->getUrl(); + + if (is_string($url) && $url !== '' && $environmentFilterQuery !== []) { + $url = url()->query($url, $environmentFilterQuery); + } + + redirect($url); + + return; + } + } + } + + /** + * @return array + */ + private function environmentFilterQuery(): array + { + $workspace = app(WorkspaceContext::class)->currentWorkspace(request()); + + if (! $workspace instanceof Workspace) { + return []; + } + + $filter = WorkspaceHubEnvironmentFilter::fromRequest(request(), $workspace); + + return $filter instanceof WorkspaceHubEnvironmentFilter + ? $filter->queryParameters() + : []; + } } diff --git a/apps/platform/app/Filament/Pages/Monitoring/Alerts.php b/apps/platform/app/Filament/Pages/Monitoring/Alerts.php index e57cc2ea..8a23b28d 100644 --- a/apps/platform/app/Filament/Pages/Monitoring/Alerts.php +++ b/apps/platform/app/Filament/Pages/Monitoring/Alerts.php @@ -5,12 +5,17 @@ namespace App\Filament\Pages\Monitoring; use App\Filament\Clusters\Monitoring\AlertsCluster; +use App\Filament\Concerns\ClearsWorkspaceHubEnvironmentFilterState; +use App\Filament\Resources\AlertDeliveryResource; +use App\Filament\Resources\AlertDestinationResource; +use App\Filament\Resources\AlertRuleResource; use App\Filament\Widgets\Alerts\AlertsKpiHeader; use App\Models\User; use App\Models\Workspace; use App\Services\Auth\WorkspaceCapabilityResolver; use App\Support\Auth\Capabilities; use App\Support\Navigation\CanonicalNavigationContext; +use App\Support\Navigation\WorkspaceHubEnvironmentFilter; use App\Support\OperateHub\OperateHubShell; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; @@ -25,6 +30,8 @@ class Alerts extends Page { + use ClearsWorkspaceHubEnvironmentFilterState; + protected static ?string $cluster = AlertsCluster::class; protected static ?int $navigationSort = 20; @@ -41,6 +48,12 @@ class Alerts extends Page protected string $view = 'filament.pages.monitoring.alerts'; + public function mount(): void + { + $this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request()); + $this->environmentFilter(); + } + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport) @@ -89,6 +102,44 @@ protected function getHeaderWidgets(): array ]; } + /** + * @return array{label: string, clear_url: string, description: string}|null + */ + public function environmentFilterChip(): ?array + { + $filter = $this->environmentFilter(); + + if (! $filter instanceof WorkspaceHubEnvironmentFilter) { + return null; + } + + return [ + 'label' => $filter->displayName(), + 'clear_url' => $this->cleanWorkspaceHubUrl(route('filament.admin.alerts')), + 'description' => 'Delivery signal is filtered. Rules and targets remain workspace configuration.', + ]; + } + + public function alertDeliveriesUrl(): string + { + return AlertDeliveryResource::getUrl('index', $this->filteredNavigationParameters(), panel: 'admin'); + } + + public function alertRulesUrl(): string + { + return $this->cleanWorkspaceHubUrl(AlertRuleResource::getUrl(panel: 'admin')); + } + + public function alertDestinationsUrl(): string + { + return $this->cleanWorkspaceHubUrl(AlertDestinationResource::getUrl(panel: 'admin')); + } + + public function auditLogUrl(): string + { + return route('admin.monitoring.audit-log', $this->filteredNavigationParameters()); + } + /** * @return array */ @@ -113,4 +164,34 @@ protected function getHeaderActions(): array return $actions; } + + /** + * @return array + */ + private function filteredNavigationParameters(): array + { + return array_filter( + array_merge( + $this->navigationContext()?->toQuery() ?? [], + $this->environmentFilter()?->queryParameters() ?? [], + ), + static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== [], + ); + } + + private function navigationContext(): ?CanonicalNavigationContext + { + return CanonicalNavigationContext::fromRequest(request()); + } + + private function environmentFilter(): ?WorkspaceHubEnvironmentFilter + { + $workspace = app(WorkspaceContext::class)->currentWorkspace(request()); + + if (! $workspace instanceof Workspace) { + return null; + } + + return WorkspaceHubEnvironmentFilter::fromRequest(request(), $workspace); + } } diff --git a/apps/platform/app/Filament/Pages/Monitoring/AuditLog.php b/apps/platform/app/Filament/Pages/Monitoring/AuditLog.php index d640aa5f..be39c87e 100644 --- a/apps/platform/app/Filament/Pages/Monitoring/AuditLog.php +++ b/apps/platform/app/Filament/Pages/Monitoring/AuditLog.php @@ -4,6 +4,7 @@ namespace App\Filament\Pages\Monitoring; +use App\Filament\Concerns\ClearsWorkspaceHubEnvironmentFilterState; use App\Models\AuditLog as AuditLogModel; use App\Models\ManagedEnvironment; use App\Models\SupportAccessGrant; @@ -20,6 +21,7 @@ use App\Support\Filament\TablePaginationProfiles; use App\Support\Navigation\CanonicalNavigationContext; use App\Support\Navigation\RelatedNavigationResolver; +use App\Support\Navigation\WorkspaceHubEnvironmentFilter; use App\Support\OperateHub\OperateHubShell; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDefaults; @@ -45,6 +47,7 @@ class AuditLog extends Page implements HasTable { + use ClearsWorkspaceHubEnvironmentFilterState; use InteractsWithTable; protected const MONITORING_PAGE_STATE_CONTRACT = [ @@ -72,11 +75,11 @@ class AuditLog extends Page implements HasTable 'invalidFallback' => 'discard_and_continue', ], [ - 'stateKey' => 'managed_environment_id', + 'stateKey' => 'environment_id', 'stateClass' => 'contextual_prefilter', - 'carrier' => 'session', + 'carrier' => 'query_param', 'queryRole' => 'durable_restorable', - 'shareable' => false, + 'shareable' => true, 'restorableOnRefresh' => true, 'tenantSensitive' => true, 'invalidFallback' => 'discard_and_continue', @@ -96,7 +99,7 @@ class AuditLog extends Page implements HasTable 'precedenceOrder' => ['query', 'session', 'default'], 'appliesOnInitialMountOnly' => true, 'activeStateBecomesAuthoritativeAfterMount' => true, - 'clearsOnTenantSwitch' => ['managed_environment_id', 'action', 'actor_label', 'resource_type'], + 'clearsOnTenantSwitch' => ['environment_id', 'managed_environment_id', 'action', 'actor_label', 'resource_type'], 'invalidRequestedStateFallback' => 'clear_selection_and_continue', ], 'inspectContract' => [ @@ -164,9 +167,11 @@ public function mount(): void $this->supportAccessOnly = request()->boolean('supportAccess'); $requestedEventId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null; - app(CanonicalAdminEnvironmentFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request()); + $this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request()); $this->mountInteractsWithTable(); + $this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request()); + $this->applyRequestedEnvironmentFilter(); if ($requestedEventId !== null) { $this->selectedAuditLogId = $this->resolveSelectedAuditLogId($requestedEventId); @@ -328,8 +333,14 @@ public function table(Table $table): Table ->icon('heroicon-o-x-mark') ->color('gray') ->action(function (): void { + $hadEnvironmentFilter = $this->currentTenantFilterId() !== null; $this->selectedAuditLogId = null; $this->resetTable(); + $this->clearWorkspaceHubEnvironmentFilterState(request()); + + if ($hadEnvironmentFilter) { + $this->redirectToCleanWorkspaceHubUrl(route('admin.monitoring.audit-log'), request()); + } }), ]); } @@ -359,6 +370,24 @@ public function authorizedTenants(): array return $this->authorizedTenants = $tenants; } + /** + * @return array{label: string, clear_url: string, description: string}|null + */ + public function environmentFilterChip(): ?array + { + $tenant = $this->filteredTenant(); + + if (! $tenant instanceof ManagedEnvironment) { + return null; + } + + return [ + 'label' => (string) ($tenant->name ?: $tenant->external_id ?: ('Environment '.$tenant->getKey())), + 'clear_url' => $this->cleanWorkspaceHubUrl(route('admin.monitoring.audit-log')), + 'description' => 'Audit events are filtered by direct environment attribution.', + ]; + } + private function authorizePageAccess(): void { $user = auth()->user(); @@ -388,6 +417,7 @@ private function authorizePageAccess(): void private function auditBaseQuery(): Builder { $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + $tenantFilter = $this->currentTenantFilterId(); $authorizedTenantIds = array_map( static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey(), $this->authorizedTenants(), @@ -406,6 +436,10 @@ private function auditBaseQuery(): Builder ->when($this->supportAccessOnly, function (Builder $query): void { $query->whereIn('action', SupportAccessGrant::supportAccessAuditActions()); }) + ->when( + $tenantFilter !== null, + fn (Builder $query): Builder => $query->where('audit_logs.managed_environment_id', $tenantFilter), + ) ->latestFirst(); } @@ -463,10 +497,35 @@ private function auditTargetLink(AuditLogModel $record): ?array return app(RelatedNavigationResolver::class)->auditTargetLink($record); } + private function applyRequestedEnvironmentFilter(): void + { + $workspace = app(WorkspaceContext::class)->currentWorkspace(request()); + + if (! $workspace instanceof Workspace) { + return; + } + + $filter = WorkspaceHubEnvironmentFilter::fromRequest(request(), $workspace); + + if (! $filter instanceof WorkspaceHubEnvironmentFilter) { + return; + } + + $environmentId = $filter->environmentId(); + + if (! array_key_exists($environmentId, $this->authorizedTenants())) { + throw new NotFoundHttpException; + } + + $this->tableFilters['managed_environment_id']['value'] = (string) $environmentId; + $this->tableDeferredFilters['managed_environment_id']['value'] = (string) $environmentId; + } + private function auditLogUrl(array $overrides = []): string { $parameters = array_merge( $this->navigationContext()?->toQuery() ?? [], + ['environment_id' => $this->currentTenantFilterId()], ['supportAccess' => $this->supportAccessOnly ? true : null], ['event' => $this->selectedAuditLogId], $overrides, @@ -616,15 +675,29 @@ private function tenantFilterOptions(): array private function defaultTenantFilter(): ?string { - $activeEnvironment = app(OperateHubShell::class)->activeEntitledTenant(request()); + return null; + } - if (! $activeEnvironment instanceof ManagedEnvironment) { + private function currentTenantFilterId(): ?int + { + $tenantFilter = app(CanonicalAdminEnvironmentFilterState::class)->currentFilterValue( + $this->getTableFiltersSessionKey(), + $this->tableFilters ?? [], + request(), + ); + + return is_numeric($tenantFilter) ? (int) $tenantFilter : null; + } + + private function filteredTenant(): ?ManagedEnvironment + { + $tenantId = $this->currentTenantFilterId(); + + if (! is_int($tenantId)) { return null; } - return array_key_exists((int) $activeEnvironment->getKey(), $this->authorizedTenants()) - ? (string) $activeEnvironment->getKey() - : null; + return $this->authorizedTenants()[$tenantId] ?? null; } /** diff --git a/apps/platform/app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php b/apps/platform/app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php index 1e8bd24e..85460ca3 100644 --- a/apps/platform/app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php +++ b/apps/platform/app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php @@ -4,22 +4,104 @@ namespace App\Filament\Resources\AlertDeliveryResource\Pages; +use App\Filament\Concerns\ClearsWorkspaceHubEnvironmentFilterState; use App\Filament\Resources\AlertDeliveryResource; +use App\Models\ManagedEnvironment; +use App\Models\User; +use App\Models\Workspace; use App\Support\Filament\CanonicalAdminEnvironmentFilterState; use App\Support\Navigation\CanonicalNavigationContext; +use App\Support\Navigation\WorkspaceHubEnvironmentFilter; use App\Support\OperateHub\OperateHubShell; +use App\Support\Workspaces\WorkspaceContext; use Filament\Actions\Action; +use Filament\Facades\Filament; use Filament\Resources\Pages\ListRecords; +use Filament\Schemas\Components\EmbeddedTable; +use Filament\Schemas\Components\RenderHook; +use Filament\Schemas\Components\View; +use Filament\Schemas\Schema; +use Filament\View\PanelsRenderHook; class ListAlertDeliveries extends ListRecords { + use ClearsWorkspaceHubEnvironmentFilterState; + protected static string $resource = AlertDeliveryResource::class; + /** + * @var array|null + */ + private ?array $authorizedTenants = null; + public function mount(): void { - app(CanonicalAdminEnvironmentFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request()); + $this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request()); parent::mount(); + + $this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request()); + $this->applyRequestedEnvironmentFilter(); + } + + public function content(Schema $schema): Schema + { + return $schema + ->components([ + $this->getTabsContentComponent(), + View::make('filament.partials.workspace-hub-environment-filter-chip') + ->viewData(fn (): array => [ + 'label' => $this->environmentFilterChip()['label'] ?? null, + 'clearUrl' => $this->environmentFilterChip()['clear_url'] ?? null, + 'description' => $this->environmentFilterChip()['description'] ?? null, + ]) + ->visible(fn (): bool => $this->environmentFilterChip() !== null), + RenderHook::make(PanelsRenderHook::RESOURCE_PAGES_LIST_RECORDS_TABLE_BEFORE), + EmbeddedTable::make(), + RenderHook::make(PanelsRenderHook::RESOURCE_PAGES_LIST_RECORDS_TABLE_AFTER), + ]); + } + + /** + * @return array{label: string, clear_url: string, description: string}|null + */ + public function environmentFilterChip(): ?array + { + $tenant = $this->filteredTenant(); + + if (! $tenant instanceof ManagedEnvironment) { + return null; + } + + return [ + 'label' => (string) ($tenant->name ?: $tenant->external_id ?: ('Environment '.$tenant->getKey())), + 'clear_url' => $this->cleanWorkspaceHubUrl(AlertDeliveryResource::getUrl('index', panel: 'admin')), + 'description' => 'Rows are filtered by direct delivery attribution.', + ]; + } + + /** + * @return array + */ + public function authorizedTenants(): array + { + if ($this->authorizedTenants !== null) { + return $this->authorizedTenants; + } + + $user = auth()->user(); + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if (! $user instanceof User || ! is_int($workspaceId)) { + return $this->authorizedTenants = []; + } + + $tenants = collect($user->getTenants(Filament::getCurrentOrDefaultPanel())) + ->filter(static fn (ManagedEnvironment $tenant): bool => (int) $tenant->workspace_id === $workspaceId) + ->keyBy(fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey()) + ->all(); + + return $this->authorizedTenants = $tenants; } protected function getHeaderActions(): array @@ -43,4 +125,50 @@ protected function getHeaderActions(): array return $actions; } + + private function applyRequestedEnvironmentFilter(): void + { + $workspace = app(WorkspaceContext::class)->currentWorkspace(request()); + + if (! $workspace instanceof Workspace) { + return; + } + + $filter = WorkspaceHubEnvironmentFilter::fromRequest(request(), $workspace); + + if (! $filter instanceof WorkspaceHubEnvironmentFilter) { + return; + } + + $environmentId = $filter->environmentId(); + + if (! array_key_exists($environmentId, $this->authorizedTenants())) { + abort(404); + } + + $this->tableFilters['managed_environment_id']['value'] = (string) $environmentId; + $this->tableDeferredFilters['managed_environment_id']['value'] = (string) $environmentId; + } + + private function currentTenantFilterId(): ?int + { + $tenantFilter = app(CanonicalAdminEnvironmentFilterState::class)->currentFilterValue( + $this->getTableFiltersSessionKey(), + $this->tableFilters ?? [], + request(), + ); + + return is_numeric($tenantFilter) ? (int) $tenantFilter : null; + } + + private function filteredTenant(): ?ManagedEnvironment + { + $tenantId = $this->currentTenantFilterId(); + + if (! is_int($tenantId)) { + return null; + } + + return $this->authorizedTenants()[$tenantId] ?? null; + } } diff --git a/apps/platform/app/Filament/Widgets/Alerts/AlertsKpiHeader.php b/apps/platform/app/Filament/Widgets/Alerts/AlertsKpiHeader.php index 411e27a8..d3e2d356 100644 --- a/apps/platform/app/Filament/Widgets/Alerts/AlertsKpiHeader.php +++ b/apps/platform/app/Filament/Widgets/Alerts/AlertsKpiHeader.php @@ -11,7 +11,9 @@ use App\Models\AlertDestination; use App\Models\AlertRule; use App\Models\User; +use App\Models\Workspace; use App\Services\Auth\ManagedEnvironmentAccessScopeResolver; +use App\Support\Navigation\WorkspaceHubEnvironmentFilter; use App\Support\Workspaces\WorkspaceContext; use Filament\Widgets\StatsOverviewWidget; use Filament\Widgets\StatsOverviewWidget\Stat; @@ -53,7 +55,7 @@ protected function getStats(): array ->count(); $stats[] = Stat::make('Enabled rules', $enabledRules) - ->description('Total '.$totalRules); + ->description('Workspace total '.$totalRules); } if (AlertDestinationResource::canViewAny()) { @@ -67,7 +69,7 @@ protected function getStats(): array ->count(); $stats[] = Stat::make('Enabled targets', $enabledDestinations) - ->description('Total '.$totalDestinations); + ->description('Workspace total '.$totalDestinations); } if (AlertDeliveryResource::canViewAny()) { @@ -101,6 +103,13 @@ private function deliveriesQueryForViewer(User $user, int $workspaceId): Builder qualifiedEnvironmentColumn: 'alert_deliveries.managed_environment_id', ); + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if ($workspace instanceof Workspace) { + WorkspaceHubEnvironmentFilter::fromRequest(request(), $workspace) + ?->applyToQuery($query, 'alert_deliveries.managed_environment_id'); + } + return $query; } } diff --git a/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php b/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php index 7e056e64..05994c78 100644 --- a/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php +++ b/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php @@ -443,14 +443,14 @@ private function alertsSection( AlertDeliveryResource::getUrl(panel: 'admin'), array_replace_recursive( $navigationContext?->toQuery() ?? [], - [ + array_filter([ + 'environment_id' => $selectedTenant instanceof ManagedEnvironment + ? (int) $selectedTenant->getKey() + : null, 'tableFilters' => array_filter([ 'status' => ['value' => AlertDelivery::STATUS_FAILED], - 'managed_environment_id' => $selectedTenant instanceof ManagedEnvironment - ? ['value' => (string) $selectedTenant->getKey()] - : null, - ], static fn (mixed $value): bool => $value !== null), - ], + ]), + ], static fn (mixed $value): bool => $value !== null), ), ), 'entries' => $entries, diff --git a/apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php b/apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php index bca3d06b..1394d39d 100644 --- a/apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php +++ b/apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php @@ -817,7 +817,10 @@ private function auditHistorySection(Collection $auditLogs): array record: $auditLog, label: $auditLog->summaryText(), actionLabel: 'Inspect event', - url: route('admin.monitoring.audit-log', ['event' => (int) $auditLog->getKey()]), + url: route('admin.monitoring.audit-log', array_filter([ + 'event' => (int) $auditLog->getKey(), + 'environment_id' => $auditLog->managed_environment_id, + ], static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== [])), freshnessAt: $auditLog->recorded_at, )) ->values() diff --git a/apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php b/apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php index 99f2f1e1..66c4e28c 100644 --- a/apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php +++ b/apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php @@ -746,7 +746,7 @@ private function attentionItems( ), badge: 'Alerts', badgeColor: 'danger', - destination: $this->alertsOverviewTarget($navigationContext, true), + destination: $this->alertsOverviewTarget($navigationContext, true, tenant: $tenant), )]; } @@ -1729,8 +1729,12 @@ private function operationDetailTarget(OperationRun $run, CanonicalNavigationCon /** * @return array */ - private function alertsOverviewTarget(CanonicalNavigationContext $navigationContext, bool $enabled, string $label = 'Open alerts'): array - { + private function alertsOverviewTarget( + CanonicalNavigationContext $navigationContext, + bool $enabled, + string $label = 'Open alerts', + ?ManagedEnvironment $tenant = null, + ): array { if (! $enabled) { return $this->disabledDestination( kind: 'alerts_overview', @@ -1740,14 +1744,24 @@ private function alertsOverviewTarget(CanonicalNavigationContext $navigationCont return $this->destination( kind: 'alerts_overview', - url: $this->alertsOverviewUrl($navigationContext), + url: $this->alertsOverviewUrl($navigationContext, $tenant), label: $label, + tenant: $tenant, + filters: $tenant instanceof ManagedEnvironment + ? ['environment_id' => (int) $tenant->getKey()] + : [], ); } - private function alertsOverviewUrl(CanonicalNavigationContext $navigationContext): string + private function alertsOverviewUrl(CanonicalNavigationContext $navigationContext, ?ManagedEnvironment $tenant = null): string { - return $this->appendQuery(route('filament.admin.alerts'), $navigationContext->toQuery()); + $query = $navigationContext->toQuery(); + + if ($tenant instanceof ManagedEnvironment) { + $query['environment_id'] = (int) $tenant->getKey(); + } + + return $this->appendQuery(route('filament.admin.alerts'), $query); } private function canTenantView(User $user, ManagedEnvironment $tenant): bool diff --git a/apps/platform/resources/views/filament/pages/monitoring/alerts.blade.php b/apps/platform/resources/views/filament/pages/monitoring/alerts.blade.php index 93898b41..d07768c2 100644 --- a/apps/platform/resources/views/filament/pages/monitoring/alerts.blade.php +++ b/apps/platform/resources/views/filament/pages/monitoring/alerts.blade.php @@ -1,5 +1,6 @@ @php($navigationContext = \App\Support\Navigation\CanonicalNavigationContext::fromRequest(request())) + @php($environmentFilterChip = $this->environmentFilterChip()) @if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) @@ -21,26 +22,34 @@ Configure alert targets and rules, then review delivery history. + @if ($environmentFilterChip !== null) + @include('filament.partials.workspace-hub-environment-filter-chip', [ + 'label' => $environmentFilterChip['label'], + 'clearUrl' => $environmentFilterChip['clear_url'], + 'description' => $environmentFilterChip['description'], + ]) + @endif +
@if (\App\Filament\Resources\AlertDestinationResource::canViewAny()) - + Alert targets @endif @if (\App\Filament\Resources\AlertRuleResource::canViewAny()) - + Alert rules @endif @if (\App\Filament\Resources\AlertDeliveryResource::canViewAny()) - + Alert deliveries @endif - + Audit Log
diff --git a/apps/platform/resources/views/filament/pages/monitoring/audit-log.blade.php b/apps/platform/resources/views/filament/pages/monitoring/audit-log.blade.php index 45431798..d91d145c 100644 --- a/apps/platform/resources/views/filament/pages/monitoring/audit-log.blade.php +++ b/apps/platform/resources/views/filament/pages/monitoring/audit-log.blade.php @@ -1,6 +1,7 @@ @php($selectedAudit = $this->selectedAuditRecord()) @php($selectedAuditLink = $this->selectedAuditTargetLink()) + @php($environmentFilterChip = $this->environmentFilterChip())
@@ -19,6 +20,14 @@
The selected event is URL-addressable through the event query parameter. If the event is no longer visible in the current history view, the page quietly falls back to the unselected log.
+ + @if ($environmentFilterChip !== null) + @include('filament.partials.workspace-hub-environment-filter-chip', [ + 'label' => $environmentFilterChip['label'], + 'clearUrl' => $environmentFilterChip['clear_url'], + 'description' => $environmentFilterChip['description'], + ]) + @endif
diff --git a/apps/platform/tests/Feature/Navigation/Spec321AlertsAuditEnvironmentFilterContractTest.php b/apps/platform/tests/Feature/Navigation/Spec321AlertsAuditEnvironmentFilterContractTest.php new file mode 100644 index 00000000..7617743b --- /dev/null +++ b/apps/platform/tests/Feature/Navigation/Spec321AlertsAuditEnvironmentFilterContractTest.php @@ -0,0 +1,382 @@ +active()->create([ + 'name' => 'Spec321 Environment A', + 'external_id' => 'spec321-environment-a', + ]); + + [$user, $environmentA] = createUserWithTenant(tenant: $environmentA, role: 'owner', workspaceRole: 'owner'); + + $environmentB = ManagedEnvironment::factory()->active()->create([ + 'workspace_id' => (int) $environmentA->workspace_id, + 'name' => 'Spec321 Environment B', + 'external_id' => 'spec321-environment-b', + ]); + + createUserWithTenant(tenant: $environmentB, user: $user, role: 'owner', workspaceRole: 'owner'); + + $workspaceId = (int) $environmentA->workspace_id; + + $rule = AlertRule::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + + $deliveryA = AlertDelivery::factory()->create([ + 'workspace_id' => $workspaceId, + 'managed_environment_id' => (int) $environmentA->getKey(), + 'alert_rule_id' => (int) $rule->getKey(), + 'alert_destination_id' => (int) $destination->getKey(), + 'status' => AlertDelivery::STATUS_FAILED, + 'created_at' => now()->subHour(), + ]); + + $deliveryB = AlertDelivery::factory()->create([ + 'workspace_id' => $workspaceId, + 'managed_environment_id' => (int) $environmentB->getKey(), + 'alert_rule_id' => (int) $rule->getKey(), + 'alert_destination_id' => (int) $destination->getKey(), + 'status' => AlertDelivery::STATUS_SENT, + 'created_at' => now()->subHour(), + ]); + + $auditA = spec321AuditRecord($environmentA, [ + 'summary' => 'Spec321 audit event A', + 'resource_id' => '321-a', + ]); + + $auditB = spec321AuditRecord($environmentB, [ + 'summary' => 'Spec321 audit event B', + 'resource_id' => '321-b', + ]); + + $auditWorkspace = spec321AuditRecord(null, [ + 'workspace_id' => $workspaceId, + 'summary' => 'Spec321 workspace audit event', + 'resource_id' => '321-workspace', + ]); + + session()->put(WorkspaceContext::SESSION_KEY, $workspaceId); + + return [$user, $environmentA, $environmentB, compact( + 'workspaceId', + 'rule', + 'destination', + 'deliveryA', + 'deliveryB', + 'auditA', + 'auditB', + 'auditWorkspace', + )]; +} + +function spec321AuditRecord(?ManagedEnvironment $environment, array $attributes = []): AuditLogModel +{ + $workspaceId = array_key_exists('workspace_id', $attributes) + ? (int) $attributes['workspace_id'] + : (int) ($environment?->workspace_id); + + return AuditLogModel::query()->create(array_merge([ + 'workspace_id' => $workspaceId, + 'managed_environment_id' => $environment?->getKey(), + 'actor_email' => 'spec321@example.test', + 'actor_name' => 'Spec321 Operator', + 'action' => 'operation.completed', + 'status' => 'success', + 'resource_type' => 'operation_run', + 'resource_id' => '321', + 'summary' => 'Spec321 audit event', + 'metadata' => [], + 'recorded_at' => now(), + ], $attributes)); +} + +function spec321AlertsKpiValues($component): array +{ + $method = new ReflectionMethod(AlertsKpiHeader::class, 'getStats'); + $method->setAccessible(true); + + return collect($method->invoke($component->instance())) + ->mapWithKeys(fn (Stat $stat): array => [ + (string) $stat->getLabel() => (string) $stat->getValue(), + ]) + ->all(); +} + +function spec321QueryKeys(string $url): array +{ + $query = []; + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + + return $query; +} + +it('documents_alerts_and_audit_log_filter_contract_decisions', function (): void { + $decision = file_get_contents(repo_path('specs/321-alerts-audit-log-environment-filter-contract-decision/decision.md')); + + expect($decision)->toBeString() + ->and($decision)->toContain('Alerts and Audit Log remain Workspace-owned surfaces.') + ->and($decision)->toContain('environment_filterable_workspace_hub') + ->and($decision)->toContain('configuration_workspace_surface') + ->and($decision)->toContain('environment_id') + ->and($decision)->toContain('No legacy aliases are accepted'); +}); + +it('alerts_support_environment_id_filter_with_visible_chip_and_clear', function (): void { + [$user, $environmentA, , $records] = spec321WorkspaceFixture(); + + $this->actingAs($user); + setAdminPanelContext(); + + $this->get(route('filament.admin.alerts', ['environment_id' => (int) $environmentA->getKey()])) + ->assertRedirect(AlertDeliveryResource::getUrl('index', ['environment_id' => (int) $environmentA->getKey()], panel: 'admin')); + + $this->followingRedirects() + ->get(route('filament.admin.alerts', ['environment_id' => (int) $environmentA->getKey()])) + ->assertOk() + ->assertSee('Environment filter:') + ->assertSee('Spec321 Environment A') + ->assertSee('Clear filter'); + + $values = spec321AlertsKpiValues( + Livewire::withQueryParams(['environment_id' => (int) $environmentA->getKey()]) + ->actingAs($user) + ->test(AlertsKpiHeader::class) + ); + + expect($values)->toMatchArray([ + 'Deliveries (24h)' => '1', + 'Failed (7d)' => '1', + ]); + + $this->followingRedirects() + ->get(route('filament.admin.alerts')) + ->assertOk() + ->assertDontSee('Environment filter:'); + + Livewire::withQueryParams([]) + ->actingAs($user) + ->test(ListAlertDeliveries::class) + ->assertCanSeeTableRecords([$records['deliveryA'], $records['deliveryB']]); +}); + +it('alert_deliveries_support_environment_id_filter_with_visible_chip_and_clear', function (): void { + [$user, $environmentA, , $records] = spec321WorkspaceFixture(); + + $this->actingAs($user); + setAdminPanelContext(); + + $component = Livewire::withQueryParams(['environment_id' => (int) $environmentA->getKey()]) + ->actingAs($user) + ->test(ListAlertDeliveries::class) + ->assertSee('Environment filter:') + ->assertSee('Spec321 Environment A') + ->assertSee('Clear filter') + ->assertSet('tableFilters.managed_environment_id.value', (string) $environmentA->getKey()) + ->assertCanSeeTableRecords([$records['deliveryA']]) + ->assertCanNotSeeTableRecords([$records['deliveryB']]); + + session()->put($component->instance()->getTableFiltersSessionKey(), [ + 'managed_environment_id' => ['value' => (string) $environmentA->getKey()], + 'tableFilters' => [ + 'managed_environment_id' => ['value' => (string) $environmentA->getKey()], + ], + ]); + + $this->get(AlertDeliveryResource::getUrl(panel: 'admin')) + ->assertOk() + ->assertDontSee('Environment filter:'); + + expect(data_get(session()->get($component->instance()->getTableFiltersSessionKey(), []), 'managed_environment_id.value')) + ->toBeNull(); + + Livewire::withQueryParams([]) + ->actingAs($user) + ->test(ListAlertDeliveries::class) + ->assertSet('tableFilters.managed_environment_id.value', null) + ->assertCanSeeTableRecords([$records['deliveryA'], $records['deliveryB']]); +}); + +it('audit_log_supports_environment_id_filter_with_visible_chip_and_clear', function (): void { + [$user, $environmentA, , $records] = spec321WorkspaceFixture(); + + $this->actingAs($user); + setAdminPanelContext(); + + Livewire::withQueryParams([ + 'environment_id' => (int) $environmentA->getKey(), + 'event' => (int) $records['auditB']->getKey(), + ]) + ->actingAs($user) + ->test(AuditLogPage::class) + ->assertSee('Environment filter:') + ->assertSee('Spec321 Environment A') + ->assertSet('tableFilters.managed_environment_id.value', (string) $environmentA->getKey()) + ->assertSet('selectedAuditLogId', null) + ->assertCanSeeTableRecords([$records['auditA']]) + ->assertCanNotSeeTableRecords([$records['auditB'], $records['auditWorkspace']]); + + $this->get(route('admin.monitoring.audit-log')) + ->assertOk() + ->assertDontSee('Environment filter:'); + + Livewire::withQueryParams([]) + ->actingAs($user) + ->test(AuditLogPage::class) + ->assertSet('tableFilters.managed_environment_id.value', null) + ->assertCanSeeTableRecords([$records['auditA'], $records['auditB'], $records['auditWorkspace']]); +}); + +it('alerts_and_audit_log_do_not_accept_legacy_environment_query_aliases', function (): void { + [$user, $environmentA, , $records] = spec321WorkspaceFixture(); + + $this->actingAs($user); + setAdminPanelContext(); + + $this->get(route('filament.admin.alerts', ['managed_environment_id' => (int) $environmentA->getKey()])) + ->assertRedirect(AlertDeliveryResource::getUrl(panel: 'admin')); + + Livewire::withQueryParams(['managed_environment_id' => (int) $environmentA->getKey()]) + ->actingAs($user) + ->test(ListAlertDeliveries::class) + ->assertSet('tableFilters.managed_environment_id.value', null) + ->assertCanSeeTableRecords([$records['deliveryA'], $records['deliveryB']]); + + Livewire::withQueryParams([ + 'tenant' => (int) $environmentA->getKey(), + 'tenant_id' => (int) $environmentA->getKey(), + 'managed_environment_id' => (int) $environmentA->getKey(), + 'environment' => (int) $environmentA->getKey(), + 'tenant_scope' => 'environment', + 'tableFilters' => [ + 'managed_environment_id' => ['value' => (string) $environmentA->getKey()], + ], + ]) + ->actingAs($user) + ->test(AuditLogPage::class) + ->assertSet('tableFilters.managed_environment_id.value', null) + ->assertCanSeeTableRecords([$records['auditA'], $records['auditB'], $records['auditWorkspace']]); +}); + +it('alerts_and_audit_log_reject_cross_workspace_environment_filters', function (): void { + [$user, $environmentA] = spec321WorkspaceFixture(); + + $otherEnvironment = ManagedEnvironment::factory()->active()->create([ + 'name' => 'Spec321 Other Workspace Environment', + ]); + + $this->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $environmentA->workspace_id); + + $this->get(route('filament.admin.alerts', ['environment_id' => (int) $otherEnvironment->getKey()])) + ->assertNotFound(); + + $this->get(route('admin.monitoring.audit-log', ['environment_id' => (int) $otherEnvironment->getKey()])) + ->assertNotFound(); +}); + +it('alerts_and_audit_log_sidebar_entry_is_workspace_wide', function (): void { + [, $environmentA] = spec321WorkspaceFixture(); + + $alertUrl = route('filament.admin.alerts'); + $auditUrl = route('admin.monitoring.audit-log'); + + expect(WorkspaceHubRegistry::cleanUrl($alertUrl))->toBe($alertUrl) + ->and(WorkspaceHubRegistry::cleanUrl($auditUrl))->toBe($auditUrl) + ->and(WorkspaceHubRegistry::cleanUrl(route('filament.admin.alerts', ['environment_id' => (int) $environmentA->getKey()]))) + ->toBe($alertUrl) + ->and(WorkspaceHubRegistry::cleanUrl(route('admin.monitoring.audit-log', ['environment_id' => (int) $environmentA->getKey()]))) + ->toBe($auditUrl); +}); + +it('environment_ctas_to_alerts_and_audit_log_use_environment_id', function (): void { + [$user, $environmentA] = spec321WorkspaceFixture(); + + $this->actingAs($user); + setAdminPanelContext(); + + $targetMethod = new ReflectionMethod(WorkspaceOverviewBuilder::class, 'alertsOverviewTarget'); + $targetMethod->setAccessible(true); + + $alertsDestination = $targetMethod->invoke( + app(WorkspaceOverviewBuilder::class), + new CanonicalNavigationContext( + sourceSurface: 'workspace.overview', + canonicalRouteName: 'admin.home', + backLinkLabel: 'Back to overview', + backLinkUrl: route('admin.home'), + ), + true, + 'Open alerts', + $environmentA, + ); + + expect(spec321QueryKeys((string) $alertsDestination['url'])) + ->toHaveKey('environment_id', (string) $environmentA->getKey()) + ->not->toHaveKey('managed_environment_id'); + + $alertsPage = Livewire::withQueryParams(['environment_id' => (int) $environmentA->getKey()]) + ->actingAs($user) + ->test(Alerts::class) + ->instance(); + + expect(spec321QueryKeys($alertsPage->alertDeliveriesUrl())) + ->toHaveKey('environment_id', (string) $environmentA->getKey()) + ->and(spec321QueryKeys($alertsPage->auditLogUrl())) + ->toHaveKey('environment_id', (string) $environmentA->getKey()); +}); + +it('alert_configuration_surfaces_do_not_emit_environment_filters', function (): void { + [$user, $environmentA] = spec321WorkspaceFixture(); + + $this->actingAs($user); + setAdminPanelContext(); + + $alertsPage = Livewire::withQueryParams(['environment_id' => (int) $environmentA->getKey()]) + ->actingAs($user) + ->test(Alerts::class) + ->instance(); + + expect(spec321QueryKeys($alertsPage->alertRulesUrl())) + ->not->toHaveKey('environment_id') + ->and(spec321QueryKeys($alertsPage->alertDestinationsUrl())) + ->not->toHaveKey('environment_id'); + + $this->get(AlertRuleResource::getUrl('index', ['environment_id' => (int) $environmentA->getKey()], panel: 'admin')) + ->assertOk() + ->assertDontSee('Environment filter:'); + + $this->get(AlertDestinationResource::getUrl('index', ['environment_id' => (int) $environmentA->getKey()], panel: 'admin')) + ->assertOk() + ->assertDontSee('Environment filter:'); + + expect(AlertDeliveryResource::makeViewAlertRulesAction()->getUrl()) + ->toBe(AlertRuleResource::getUrl(panel: 'admin')); +}); diff --git a/specs/321-alerts-audit-log-environment-filter-contract-decision/checklists/requirements.md b/specs/321-alerts-audit-log-environment-filter-contract-decision/checklists/requirements.md new file mode 100644 index 00000000..6d4cd176 --- /dev/null +++ b/specs/321-alerts-audit-log-environment-filter-contract-decision/checklists/requirements.md @@ -0,0 +1,52 @@ +# Requirements Quality Checklist: Spec 321 + +**Purpose**: Validate that the Spec 321 artifacts are complete enough for implementation. +**Created**: 2026-05-17 +**Feature**: `321-alerts-audit-log-environment-filter-contract-decision` + +## Content Quality + +- [x] No implementation details in `spec.md` beyond necessary system contracts and known seams. +- [x] User value and operator/governance outcomes are clear. +- [x] Surface role decisions are explicit. +- [x] All mandatory sections are present. +- [x] No unresolved clarification markers remain. + +## Requirement Completeness + +- [x] Alerts have a final contract. +- [x] Alert Deliveries have a final contract. +- [x] Alert Rules have a final contract. +- [x] Alert Destinations have a final contract. +- [x] Audit Log has a final contract. +- [x] Audit event detail/selection behavior is covered. +- [x] Environment Dashboard CTA behavior is covered. +- [x] Legacy alias rejection is covered. +- [x] Cross-workspace Environment handling is covered. +- [x] Clear/reload/back behavior is covered for filterable surfaces. +- [x] Data scope and visible UI state are both covered. +- [x] Acceptance criteria are measurable. +- [x] Test requirements are explicit. +- [x] Browser verification is explicit. + +## Constitution / Repo Fit + +- [x] Completed specs 313 through 320 were treated as context, not modified. +- [x] Hard cutover/no compatibility posture is explicit. +- [x] No new persisted entity, enum/status family, taxonomy, or framework is introduced. +- [x] No migration is expected. +- [x] Shared pattern reuse is required before new abstraction. +- [x] Filament v5 / Livewire v4 compliance is stated. +- [x] Provider boundary is stated. +- [x] OperationRun impact is stated. + +## Readiness + +- [x] `decision.md` exists and classifies every in-scope surface. +- [x] `plan.md` identifies runtime areas, test lanes, and deployment impact. +- [x] `tasks.md` is ordered and implementation-ready. +- [x] No application implementation was performed during preparation. + +## Notes + +This checklist validates preparation artifacts only. Runtime acceptance remains pending until implementation, tests, and browser verification are completed. diff --git a/specs/321-alerts-audit-log-environment-filter-contract-decision/decision.md b/specs/321-alerts-audit-log-environment-filter-contract-decision/decision.md new file mode 100644 index 00000000..8ee6dd1a --- /dev/null +++ b/specs/321-alerts-audit-log-environment-filter-contract-decision/decision.md @@ -0,0 +1,58 @@ +# Spec 321 Decision: Alerts / Audit Log Environment Filter Contract + +**Status**: Draft decision artifact +**Date**: 2026-05-17 +**Branch**: `321-alerts-audit-log-environment-filter-contract-decision` + +## Decision Summary + +Alerts and Audit Log remain Workspace-owned surfaces. + +The canonical Environment filter key is: + +```text +environment_id +``` + +No legacy aliases are accepted. No remembered Environment fallback is accepted. No Filament tenant fallback is accepted. + +## Surface Decisions + +| Surface | Current behavior | Data model supports environment attribution? | Chosen contract | Reason | Implementation impact | Test impact | +| --- | --- | --- | --- | --- | --- | --- | +| Alerts overview / Alert Center | Registered workspace hub, but Spec 318 found `/admin/alerts?environment_id=...` redirects/drops query or lacks full filter contract. | Partially, through environment-attributable alert delivery signal. Alert rules/destinations are workspace configuration. | `environment_filterable_workspace_hub` | Alerts are an operational signal hub. Operators need "show me alerts for this Environment" behavior, and the delivery signal has reliable `managed_environment_id`. | Resolve canonical `environment_id`, show shared chip, scope environment-attributable KPI/signal data, keep shell Workspace-only, clear with shared resetter. | Clean URL, filtered URL, visible chip, clear, reload, legacy alias rejection, cross-workspace guard. | +| Alert Deliveries | Registered workspace hub/table resource. Has current table filter for `managed_environment_id`, but no canonical visible workspace-hub chip contract. | Yes. `alert_deliveries.managed_environment_id` exists, is nullable, workspace-constrained, and indexed. | `environment_filterable_workspace_hub` | Deliveries are the table-backed alert event/signal surface and can be filtered without inference. | Apply canonical `environment_id` to query/table state using shared resolver, render chip, clear stale Filament/session table filters. | Filtered rows prove data scope; clear and reload safety; legacy aliases ignored. | +| Alert Rules | Registered under Alerts. Workspace configuration surface. Uses workspace-level rule configuration, including targeting settings. | No page-level filter attribution. Rule targeting config is not the same as page ownership/filter state. | `configuration_workspace_surface` | Rules configure workspace alert behavior and should not appear Environment-owned or Environment-filtered. | Do not render chip. Do not accept `environment_id` as filter state. Keep clean links from navigation/CTAs. | Assert `environment_id` does not create chip/filter and Environment CTAs do not emit filters to this surface. | +| Alert Destinations | Registered under Alerts. Workspace configuration surface. | No. Destinations are workspace notification targets. | `configuration_workspace_surface` | Notification destinations are workspace-level configuration, not Environment-scoped operational signal. | Do not render chip. Do not accept `environment_id` as filter state. Keep clean links from navigation/CTAs. | Assert `environment_id` does not create chip/filter and Environment CTAs do not emit filters to this surface. | +| Audit Log | Registered workspace hub. Spec 318 found `/admin/audit-log?environment_id=...` preserves query but shows no visible chip. | Yes. `audit_logs.managed_environment_id` exists, is nullable, indexed, and tied to workspace attribution. | `environment_filterable_workspace_hub` | Audit Log is a workspace governance surface, but many records are reliably Environment-attributable. Filtering by direct attribution is useful and safe. | Resolve canonical `environment_id`, filter by `audit_logs.managed_environment_id`, render chip, keep shell Workspace-only, clear stale query/table/session state. | Clean URL, filtered URL, visible chip, filtered rows, selected event consistency, clear, reload, legacy alias rejection, cross-workspace guard. | +| Audit event detail / selected event state | Same Audit Log page, usually selected through query/state. | Yes when the selected event row has `managed_environment_id`. | `environment_filterable_workspace_hub` | Event detail is metadata inside the Audit Log surface, not a separate Environment-owned page. | If a filter is active, selected event state must remain inside the filtered query scope or be neutralized. Show Environment metadata as event data, not shell ownership. | Assert selected event outside active Environment filter is not shown as the selected detail. | +| Environment Dashboard CTAs to Alerts / Alert Deliveries / Audit Log | Must be inspected during implementation. | Yes for the filterable destinations above. | `environment_filterable_workspace_hub` destinations use canonical `environment_id`. | Environment-origin navigation may preserve Environment focus only when the destination supports the full filter contract. | Add or update links to use `environment_id` only. No legacy params. | Assert CTAs use canonical `environment_id` and no legacy aliases. | +| Environment Dashboard CTAs to Alert Rules / Alert Destinations | Must be inspected during implementation. | No page-level filter attribution. | `configuration_workspace_surface` destinations use clean workspace links or are omitted. | Configuration surfaces should not imply Environment-specific state. | Remove Environment filter params from these destinations. | Assert CTAs emit no Environment filter params. | + +## Final Contract Values + +Allowed values used in this decision: + +```text +environment_filterable_workspace_hub +configuration_workspace_surface +``` + +No surface in scope remains ambiguous. + +## Runtime Constraints + +- Shell remains Workspace-only for all surfaces in this decision. +- Filterable surfaces accept only `environment_id`. +- Legacy aliases are invalid as Environment filter input. +- Cross-workspace or unauthorized Environment IDs must be rejected safely. +- Clear behavior must remove URL, table, deferred table, session, and page Environment filter state. +- No migrations are expected. + +## Reopen Conditions + +Update this decision before implementation continues if: + +- Runtime discovery shows `audit_logs.managed_environment_id` or `alert_deliveries.managed_environment_id` is not reliable. +- A migration or new persisted attribution field becomes necessary. +- A new Alert or Audit sub-surface is discovered that cannot be classified by this artifact. diff --git a/specs/321-alerts-audit-log-environment-filter-contract-decision/plan.md b/specs/321-alerts-audit-log-environment-filter-contract-decision/plan.md new file mode 100644 index 00000000..2ab11642 --- /dev/null +++ b/specs/321-alerts-audit-log-environment-filter-contract-decision/plan.md @@ -0,0 +1,240 @@ +# Implementation Plan: Alerts / Audit Log Environment Filter Contract Decision + +**Branch**: `321-alerts-audit-log-environment-filter-contract-decision` +**Date**: 2026-05-17 +**Spec**: `specs/321-alerts-audit-log-environment-filter-contract-decision/spec.md` +**Decision Artifact**: `specs/321-alerts-audit-log-environment-filter-contract-decision/decision.md` +**Status**: Draft + +## Summary + +Make Alerts and Audit Log unambiguous Workspace-owned surfaces. Alerts overview, Alert Deliveries, and Audit Log become canonical `environment_id` filterable workspace hubs with visible chip and clear behavior. Alert Rules and Alert Destinations remain workspace-level configuration surfaces and must not accept Environment filter state. + +No runtime implementation is performed by this preparation. Runtime work must follow this plan and the tasks file. + +## Technical Context + +**Language / Version**: PHP 8.4.15 +**Primary Framework**: Laravel 12.52.0 +**Admin UI**: Filament 5.2.1 +**Reactive Layer**: Livewire 4.1.4 +**Database**: PostgreSQL +**Testing**: Pest 4.3.1, PHPUnit 12 +**Local Runtime**: Laravel Sail first + +Relevant package posture: + +- Filament v5 requires Livewire v4.0+; this app uses Livewire 4.1.4. +- Laravel 12 provider registration remains in `apps/platform/bootstrap/providers.php`; this spec does not add a panel provider. +- No frontend asset registration or `filament:assets` deployment change is planned. + +## Constitutional Check + +### Pre-Implementation + +- **LEAN-001 Hard Cutover**: Pass. No legacy aliases, compatibility redirects, or dual contracts are allowed. +- **Tenant / Workspace Isolation**: Pass with implementation requirement. `environment_id` must be resolved through workspace and user access checks. +- **Shared Pattern First**: Pass. Reuse `WorkspaceHubEnvironmentFilter`, `WorkspaceHubFilterStateResetter`, clear trait, and shared chip partial. +- **Proportionality**: Pass. No new persisted entity, enum/status family, taxonomy, framework, migration, or dependency. +- **Test Governance**: Pass with required tests and browser verification listed below. +- **Spec Candidate Gate**: Pass. User manually promoted a direct follow-up to Spec 318 findings; completed specs 313-320 were inspected as context. + +### Post-Design + +- No constitutional violation is expected. +- If implementation discovers missing reliable Environment attribution, the spec and decision artifact must be updated before runtime work continues. +- If implementation requires a new persisted attribute or abstraction, the proportionality review must be reopened before code changes continue. + +## Project Structure + +### Documentation / Spec Artifacts + +```text +specs/321-alerts-audit-log-environment-filter-contract-decision/spec.md +specs/321-alerts-audit-log-environment-filter-contract-decision/plan.md +specs/321-alerts-audit-log-environment-filter-contract-decision/tasks.md +specs/321-alerts-audit-log-environment-filter-contract-decision/decision.md +specs/321-alerts-audit-log-environment-filter-contract-decision/checklists/requirements.md +specs/321-alerts-audit-log-environment-filter-contract-decision/artifacts/screenshots/ +``` + +### Runtime Areas For Later Implementation + +```text +apps/platform/app/Support/Navigation/WorkspaceHubRegistry.php +apps/platform/app/Support/Navigation/AdminSurfaceScope.php +apps/platform/app/Support/Navigation/WorkspaceSidebarNavigation.php +apps/platform/app/Support/Navigation/WorkspaceHubEnvironmentFilter.php +apps/platform/app/Support/Navigation/WorkspaceHubFilterStateResetter.php +apps/platform/app/Filament/Concerns/ClearsWorkspaceHubEnvironmentFilterState.php +apps/platform/app/Filament/Pages/Monitoring/Alerts.php +apps/platform/app/Filament/Widgets/AlertsKpiHeader.php +apps/platform/app/Filament/Resources/AlertDeliveryResource.php +apps/platform/app/Filament/Resources/AlertRuleResource.php +apps/platform/app/Filament/Resources/AlertDestinationResource.php +apps/platform/app/Filament/Pages/Monitoring/AuditLog.php +apps/platform/resources/views/filament/pages/monitoring/alerts.blade.php +apps/platform/resources/views/filament/pages/monitoring/audit-log.blade.php +apps/platform/resources/views/filament/partials/workspace-hub-environment-filter-chip.blade.php +apps/platform/app/Support/ManagedEnvironmentLinks.php +apps/platform/app/Support/Operations/OperationRunLinks.php +apps/platform/tests/Feature/Navigation +apps/platform/tests/Feature/Filament +``` + +## Data Model Decision + +No migration is planned. + +Discovered attribution support: + +- `alert_deliveries.managed_environment_id`: reliable nullable Environment attribution with workspace constraint and index. +- `audit_logs.managed_environment_id`: reliable nullable Environment attribution with workspace constraint and indexes. +- `alert_rules`: workspace configuration; no page-level Environment filter contract. +- `alert_destinations`: workspace configuration; no page-level Environment filter contract. + +Filtering must use direct normalized attribution only. It must not infer Environment from text, labels, JSON payload contents, actor/session context, remembered state, provider tenant IDs, or Filament tenant context. + +## UI / Filament Plan + +### Alerts Overview + +- Keep Workspace-only shell. +- Resolve canonical `environment_id` if present and valid. +- Render the shared Environment filter chip in filtered state. +- Ensure KPI/header data that is Environment-attributable is scoped by the active filter. +- Ensure configuration counts are either not presented as Environment-filtered or are clearly workspace-level. +- Link to Alert Deliveries with `environment_id` when filtered. +- Link to Alert Rules and Alert Destinations without Environment filter params. + +### Alert Deliveries + +- Keep table-backed workspace hub behavior. +- Apply `environment_id` as canonical URL filter and synchronize table query state without accepting legacy query aliases. +- Use visible chip and shared clear behavior. +- Filter rows by `managed_environment_id`. + +### Alert Rules / Alert Destinations + +- Remain workspace configuration surfaces. +- Do not render Environment filter chip. +- Do not accept `environment_id` as filter state. +- Keep sidebar/global navigation clean. + +### Audit Log + +- Keep Workspace-only shell. +- Resolve canonical `environment_id` if present and valid. +- Render visible shared chip in filtered state. +- Filter rows by `audit_logs.managed_environment_id`. +- Ensure selected event/detail state cannot show an event outside the active filter. +- Clear URL, chip, table/session filter state, and selected stale filter state. + +## Authorization / Security Plan + +- Use the existing workspace/user access model for Environment resolution. +- Cross-workspace or unauthorized Environment IDs must result in 404 / safe no-access behavior. +- UI visibility is not authorization. +- No page may use remembered Environment, Filament tenant context, or provider tenant aliases as access control input. + +## Navigation / CTA Plan + +- Sidebar and global entries remain clean workspace URLs. +- Environment-owned CTAs to Alerts, Alert Deliveries, and Audit Log may include canonical `environment_id` when preserving Environment focus. +- Environment-owned CTAs to Alert Rules and Alert Destinations must use clean workspace links or be omitted. +- No CTA may emit `tenant`, `tenant_id`, `managed_environment_id`, `environment`, `tenant_scope`, or `tableFilters` as Environment filter input. + +## Testing Plan + +Use Pest 4 feature/browser style matching existing tests. + +Target tests: + +- Decision artifact static guard. +- Alerts overview filtered/clean/chip/clear behavior. +- Alert Deliveries filtered rows/chip/clear behavior. +- Audit Log filtered rows/chip/clear behavior. +- Legacy alias guard for Alerts and Audit Log. +- Cross-workspace Environment guard for filterable surfaces. +- Sidebar/global clean URL regression. +- Environment CTA contract. +- Alert configuration surfaces reject Environment filter state. + +Regression lanes: + +```bash +cd apps/platform && ./vendor/bin/sail artisan test --filter=WorkspaceHubEnvironmentFilterContractTest +cd apps/platform && ./vendor/bin/sail artisan test --filter=WorkspaceHubClearFilterContractTest +cd apps/platform && ./vendor/bin/sail artisan test --filter=LegacyTenantPlatformContextCleanup +cd apps/platform && ./vendor/bin/sail artisan test --filter=WorkspaceOwnedAnalysisSurface +cd apps/platform && ./vendor/bin/sail artisan test --filter=BaselineCompare +``` + +Exact class/filter names may be adjusted to match the repository during implementation. + +## Browser Verification Plan + +Use the in-app browser or project browser smoke workflow after runtime code changes: + +- Alerts clean +- Alerts filtered +- Alerts after clear +- Alerts after reload +- Alert Deliveries filtered +- Audit Log clean +- Audit Log filtered +- Audit Log after clear +- Audit Log after reload +- Environment Dashboard CTA to Alerts/Audit Log + +Save screenshots under: + +```text +specs/321-alerts-audit-log-environment-filter-contract-decision/artifacts/screenshots/ +``` + +## Filament v5 Output Contract + +1. **Livewire v4.0+ compliance**: Required. The app uses Livewire 4.1.4; implementation must not introduce Livewire v3 references. +2. **Provider registration location**: No new panel provider is planned. If one is unexpectedly required, Laravel 12 registration belongs in `apps/platform/bootstrap/providers.php`, not `bootstrap/app.php`. +3. **Global search resources**: `AlertDeliveryResource`, `AlertRuleResource`, and `AlertDestinationResource` currently disable global search. Alerts and Audit Log are pages, not globally searchable resources. This spec must not make them globally searchable without Edit/View page review. +4. **Destructive actions**: This spec should not introduce destructive actions. Existing destructive alert rule/destination actions must preserve confirmation and authorization. Any new destructive action is forbidden unless it executes through `Action::make(...)->action(...)`, has `->requiresConfirmation()`, and enforces authorization. +5. **Asset strategy**: No global or on-demand assets are planned. No deployment `filament:assets` change is required. +6. **Testing plan**: Cover Filament pages/resources as Livewire components and table/action behavior using Filament/Pest patterns already present in the repo. + +## Implementation Phases + +1. Confirm decision artifact and static guards. +2. Add failing contract tests. +3. Implement shared filter resolution/chip/clear on Alerts overview, Alert Deliveries, and Audit Log. +4. Ensure Alert Rules and Alert Destinations reject Environment filter state. +5. Update CTA and navigation helpers. +6. Run targeted tests and regression lanes. +7. Perform focused browser verification and capture screenshots. +8. Run formatting and diff checks. + +## Complexity Tracking + +No complexity violation is expected. + +Potential implementation complexity: + +- Audit Log selected event state may require careful reconciliation with the active Environment filter. +- Alert overview KPIs combine workspace configuration counts and delivery signal counts. +- Existing persisted Filament table filters must be cleared without resurrecting legacy aliases. + +These risks should be handled within existing shared patterns. + +## Deployment / Operations Impact + +Expected: + +- No migrations. +- No seeders. +- No package changes. +- No environment variable changes. +- No queue, scheduler, storage, or volume changes. +- No Dokploy deployment changes. +- No asset build/deploy changes. + +Implementation should still mention staging validation because this changes admin navigation/filter behavior. diff --git a/specs/321-alerts-audit-log-environment-filter-contract-decision/spec.md b/specs/321-alerts-audit-log-environment-filter-contract-decision/spec.md new file mode 100644 index 00000000..aa38f0e4 --- /dev/null +++ b/specs/321-alerts-audit-log-environment-filter-contract-decision/spec.md @@ -0,0 +1,473 @@ +# Feature Specification: Alerts / Audit Log Environment Filter Contract Decision + +**Feature Branch**: `321-alerts-audit-log-environment-filter-contract-decision` +**Created**: 2026-05-17 +**Status**: Draft +**Input**: User supplied Spec 321 draft: "Alerts / Audit Log Environment Filter Contract Decision" +**Type**: Product contract decision / runtime hardening / filter consistency +**Runtime Posture**: Hard cutover. No backwards compatibility. No legacy alias support. + +## Dependencies + +- Spec 313: Full Workspace / Environment Context Browser Verification Audit +- Spec 314: Workspace Hub Navigation Context Contract +- Spec 315: Environment CTA Explicit Filter Contract +- Spec 316: Workspace Hub Clear Filter Contract +- Spec 317: Legacy Tenant / Environment Context Cleanup +- Spec 318: Admin Surface Scope & Shell Context Audit +- Spec 319: Environment-Owned Surface Routing & Shell Context Contract +- Spec 320: Workspace-Owned Analysis Surface Registration & Shell Cutover + +## Spec Candidate Check + +**Candidate Source**: Direct user-provided manual promotion, based on Spec 318 mismatch findings. + +**Completed-Spec Guardrail**: Specs 313 through 320 were inspected as completed context and remain unchanged by this preparation. Spec 321 addresses the unresolved Alerts / Audit Log contract gap explicitly called out by Spec 318. + +**Score**: 9/10. + +**Why now**: + +- Spec 318 identified Alerts and Audit Log as the remaining ambiguous workspace-hub filter surfaces. +- Specs 314 through 320 already established the shared workspace hub, environment filter, clear filter, legacy cleanup, and shell ownership contracts this work must reuse. +- The repo data model supports a hard decision now: + - `alert_deliveries.managed_environment_id` exists and is workspace constrained. + - `audit_logs.managed_environment_id` exists, is indexed, and is workspace constrained. + +**Alternatives deferred**: + +- Spec 322 durable browser no-drift regression coverage is explicitly out of scope. +- Broad redesign of alerts, audit logging, notification routing, or evidence flows is out of scope. +- New persisted entities, packages, migrations, or compatibility redirects are out of scope. + +## Summary + +Resolve the remaining Alerts / Audit Log environment-filter contract gap found by Spec 318. + +TenantPilot has two primary admin context contracts: + +- Workspace hubs use a Workspace-only shell. They may support an explicit, visible, clearable `environment_id` filter. +- Environment-owned pages require an Environment route or context. They show Workspace + Environment shell ownership and do not use workspace-hub-style `environment_id` access. + +Alerts and Audit Log are governance and observability surfaces. They stay Workspace-owned surfaces. This spec decides and prepares the implementation contract for optional canonical Environment filtering on those surfaces. + +## Product Decision + +The chosen contracts are: + +| Surface | Chosen contract | +| --- | --- | +| Alerts overview / Alert Center | `environment_filterable_workspace_hub` | +| Alert Deliveries | `environment_filterable_workspace_hub` | +| Alert Rules | `configuration_workspace_surface` | +| Alert Destinations | `configuration_workspace_surface` | +| Audit Log | `environment_filterable_workspace_hub` | +| Audit event detail state | Same `environment_filterable_workspace_hub` surface, not a separate Environment-owned page | + +Decision details are documented in: + +```text +specs/321-alerts-audit-log-environment-filter-contract-decision/decision.md +``` + +## Hard Cutover Policy + +There is no production data or production environment to preserve. The implementation must not introduce compatibility behavior. + +Only this Environment filter query key is canonical: + +```text +environment_id +``` + +These inputs must not create Environment filter state: + +```text +tenant +tenant_id +managed_environment_id +environment +tenant_scope +tableFilters as URL source +remembered Environment +Filament::getTenant() +getTenant() +``` + +No compatibility redirect, alias support, remembered fallback, hidden fallback, or dual contract is allowed. + +## Spec Scope Fields + +**Primary users**: Workspace operators, security/governance admins, support/admin users reviewing operational signal and auditability. + +**Primary surfaces**: + +- `GET /admin/alerts` +- `GET /admin/alerts/alert-deliveries` +- `GET /admin/alerts/alert-rules` +- `GET /admin/alerts/alert-destinations` +- `GET /admin/audit-log` + +**Related surfaces to inspect during implementation**: + +- Environment Dashboard CTAs and widgets +- Alert widgets/cards and alert KPI header +- Notification or alert link helpers +- Audit support links +- OperationRun and ManagedEnvironment link helpers +- Audit event selection/detail state + +**Persistence impact**: No migrations expected. Existing `managed_environment_id` columns provide the reliable attribution needed for the chosen filterable surfaces. + +**Runtime impact**: Query, navigation, chip, clear behavior, and test/browser coverage only. + +**Out of scope**: + +- Alert type redesign +- Audit schema redesign +- New alert rules or delivery engines +- Provider-specific tenant concepts +- Spec 322 durable browser no-drift infrastructure +- Broad rebaseline of existing browser artifacts + +## Current Repo Truth + +Discovery found these relevant seams: + +- `WorkspaceHubRegistry` already registers `audit_log`, `alerts`, `alert_deliveries`, `alert_rules`, and `alert_destinations` as workspace hub entries. +- `WorkspaceHubEnvironmentFilter` already resolves canonical `environment_id`, constrains by current workspace, checks current user Environment access, and rejects cross-workspace or unauthorized Environment IDs with 404 behavior. +- `WorkspaceHubFilterStateResetter` and `ClearsWorkspaceHubEnvironmentFilterState` already provide shared clear behavior for stale query/table/session filter state. +- `resources/views/filament/partials/workspace-hub-environment-filter-chip.blade.php` already provides a shared visible filter chip. +- `AlertDeliveryResource` is table-backed and has reliable `managed_environment_id` attribution. +- `AlertRuleResource` and `AlertDestinationResource` are workspace configuration resources and should not become environment-filtered. +- `AuditLog` has a table filter for `managed_environment_id`, but Spec 318 found canonical `environment_id` direct URLs currently lack a visible chip and full contract behavior. +- `audit_logs.managed_environment_id` exists, is indexed, and is a reliable attribution column. + +## User Scenarios & Testing + +### User Story 1: Workspace operator filters Alerts by Environment + +As a workspace operator, I can open Alerts cleanly for all environments or open Alerts with `?environment_id={id}` to focus on one Managed Environment without changing shell ownership. + +**Independent Test**: Open `/admin/alerts` and `/admin/alerts?environment_id={validEnvironmentId}`. Verify clean state is workspace-wide, filtered state shows the shared chip, the shell remains Workspace-only, clear returns to clean URL, and no legacy query alias creates filter state. + +**Acceptance Scenarios**: + +1. Given a Workspace has multiple Managed Environments and alert delivery data, when the operator opens the clean Alerts URL, then the page shows Workspace-only shell and all-environment copy. +2. Given a valid Environment ID in the current Workspace, when the operator opens Alerts with `environment_id`, then the page shows `Environment filter: {environment name}` and Environment-scoped signal where the data model supports it. +3. Given the operator clears the filter, when the page reloads, then the URL is clean and the Environment chip does not return. + +### User Story 2: Workspace operator filters Alert Deliveries by Environment + +As a workspace operator, I can use Alert Deliveries as the table-backed alert signal surface and apply the same canonical `environment_id` filter contract. + +**Independent Test**: Open `/admin/alerts/alert-deliveries?environment_id={validEnvironmentId}`. Verify visible chip, filtered rows, clear behavior, and no Environment shell ownership. + +**Acceptance Scenarios**: + +1. Given deliveries exist for two environments, when the filtered URL is opened for one Environment, then only deliveries for that Environment are shown. +2. Given alert rules or destinations are opened with `environment_id`, then those configuration surfaces do not create an Environment chip or Environment filter state. + +### User Story 3: Governance admin filters Audit Log by Environment + +As a governance admin, I can open Audit Log cleanly for workspace-wide events or explicitly filter it by Environment when audit entries carry reliable Environment attribution. + +**Independent Test**: Open `/admin/audit-log` and `/admin/audit-log?environment_id={validEnvironmentId}`. Verify clean state is workspace-wide, filtered state shows the chip, rows are filtered by `audit_logs.managed_environment_id`, selected event detail remains consistent with the filter, and clear is reload-safe. + +**Acceptance Scenarios**: + +1. Given audit logs exist for two environments, when the filtered URL is opened, then only matching Environment-attributed audit rows appear. +2. Given an audit event detail is selected while an Environment filter is active, when the event does not belong to that Environment, then it is not shown as the selected detail. +3. Given an audit row has no Environment attribution, when an Environment filter is active, then the row is excluded from the filtered results. + +### User Story 4: Environment Dashboard CTAs follow the contract + +As an operator starting from an Environment-owned page, I can use CTAs that either pass canonical `environment_id` to filterable workspace hubs or use clean workspace links for configuration surfaces. + +**Independent Test**: Inspect and browser-test Environment Dashboard or related Environment CTAs. Verify Alerts, Alert Deliveries, and Audit Log CTAs use `environment_id` when they claim Environment focus, while Alert Rules and Alert Destinations do not receive Environment filter params. + +## Edge Cases + +- `environment_id` belongs to another Workspace: reject with 404 / safe no-access and do not switch Workspace. +- `environment_id` belongs to current Workspace but current user lacks access: reject with 404 / safe no-access. +- `environment_id` is malformed or missing: no filter state should be created. +- Legacy aliases appear with or without `environment_id`: only canonical `environment_id` may control filter state. +- Stale `tableFilters`, `tableDeferredFilters`, persisted Filament/session state, or Livewire state exists: clear and clean entry must neutralize stale Environment-like state. +- Browser back/forward after filter and clear must not create mismatched URL/chip/data state. +- Alert Rules and Alert Destinations must stay workspace configuration surfaces even though rule configuration may contain tenant/environment targeting semantics. +- Audit events with null `managed_environment_id` remain visible in workspace-wide Audit Log but are excluded from Environment-filtered Audit Log. + +## Requirements + +### Functional Requirements + +- **FR-001**: The implementation MUST preserve Workspace-only shell ownership for Alerts, Alert Deliveries, Alert Rules, Alert Destinations, and Audit Log. +- **FR-002**: Alerts overview MUST support clean workspace-wide URL `/admin/alerts` and filtered URL `/admin/alerts?environment_id={id}`. +- **FR-003**: Alert Deliveries MUST support clean workspace-wide URL `/admin/alerts/alert-deliveries` and filtered URL `/admin/alerts/alert-deliveries?environment_id={id}`. +- **FR-004**: Audit Log MUST support clean workspace-wide URL `/admin/audit-log` and filtered URL `/admin/audit-log?environment_id={id}`. +- **FR-005**: Alert Rules and Alert Destinations MUST remain workspace configuration surfaces and MUST NOT create Environment filter state from `environment_id`. +- **FR-006**: Filterable surfaces MUST resolve `environment_id` through the shared `WorkspaceHubEnvironmentFilter` or an equivalent shared adapter that preserves workspace and user access checks. +- **FR-007**: Filterable surfaces MUST show the shared visible Environment filter chip when a valid `environment_id` filter is active. +- **FR-008**: The chip clear action MUST reuse the Spec 316 shared clear/reset behavior and remove URL, Livewire, Filament table, deferred table, and persisted session Environment-like state. +- **FR-009**: Sidebar and global workspace hub entries MUST generate clean URLs without Environment query params. +- **FR-010**: Environment-owned CTAs to filterable surfaces MUST use canonical `environment_id` if they intend to preserve Environment focus. +- **FR-011**: Environment-owned CTAs to Alert Rules or Alert Destinations MUST use clean workspace links or be omitted if an Environment-focused link would be misleading. +- **FR-012**: Legacy aliases `tenant`, `tenant_id`, `managed_environment_id`, `environment`, `tenant_scope`, and `tableFilters` as URL source MUST NOT create Environment filter state. +- **FR-013**: Cross-workspace or unauthorized Environment IDs MUST be rejected with safe no-access behavior and MUST NOT leak data or switch Workspace. +- **FR-014**: Alert Deliveries filtering MUST use reliable `managed_environment_id` attribution only. +- **FR-015**: Audit Log filtering MUST use reliable `audit_logs.managed_environment_id` attribution only. +- **FR-016**: The implementation MUST NOT infer Environment attribution from labels, descriptions, actor/session context, remembered Environment, provider tenant external IDs, or arbitrary JSON text. +- **FR-017**: Audit event detail/selection state MUST stay consistent with any active `environment_id` filter. +- **FR-018**: Alerts overview KPI or summary data MUST either apply the active Environment filter where it uses environment-attributable data or clearly avoid implying Environment-specific counts for non-attributable configuration data. +- **FR-019**: Workspace-wide copy MUST say all environments, workspace-wide, all alerts, or all events as appropriate when no filter is active. +- **FR-020**: Filtered copy MUST use `Environment filter: {environment name}` through the shared chip pattern. +- **FR-021**: No migration, seeder, package, environment variable, queue, scheduler, storage, or deployment asset change is expected or allowed unless the spec is updated before implementation. +- **FR-022**: No backwards compatibility layer, legacy query alias support, compatibility redirect, or dual contract is allowed. + +### Non-Functional Requirements + +- **NFR-001**: Workspace and Environment isolation MUST remain enforceable in queries and authorization. +- **NFR-002**: Data filtering MUST be reload-safe, shareable by URL, and safe across browser back/forward where covered by browser verification. +- **NFR-003**: The implementation MUST reuse existing shared navigation/filter/reset/chip seams before introducing any new abstraction. +- **NFR-004**: Filament v5 and Livewire v4 patterns MUST be used. No Filament v3/v4 APIs or Livewire v3 references are allowed. +- **NFR-005**: Tests MUST cover both visible UI state and actual data scope for filterable surfaces. + +## UI / Surface Guardrail Impact + +### Decision-First Surface Role + +- Alerts overview: Workspace-owned operational signal hub with optional explicit Environment filter. +- Alert Deliveries: Workspace-owned table-backed operational signal hub with optional explicit Environment filter. +- Alert Rules: Workspace-level configuration surface. +- Alert Destinations: Workspace-level configuration surface. +- Audit Log: Workspace-owned auditability surface with optional explicit Environment filter. + +### Audience-Aware Disclosure + +Filtered state must be visible and unambiguous. Operators must never have to infer from URL alone whether the page is Environment-filtered. + +### UI/UX Surface Classification + +This is an operator/admin UX change to existing Filament pages and resources. It is not a marketing, website, or public-facing surface. + +### Operator Surface Contract + +- Use existing Filament table/page structure. +- Use the shared workspace hub Environment filter chip where possible. +- Do not introduce custom visual systems, new card-heavy layouts, or marketing copy. +- Do not publish Filament internal views. + +## Cross-Cutting / Shared Pattern Reuse + +Implementation must inspect and reuse these existing seams before adding code: + +- `WorkspaceHubRegistry` +- `AdminSurfaceScope` +- `WorkspaceSidebarNavigation` +- `WorkspaceHubEnvironmentFilter` +- `WorkspaceHubFilterStateResetter` +- `ClearsWorkspaceHubEnvironmentFilterState` +- `workspace-hub-environment-filter-chip` partial +- Existing page state contract helpers +- Existing audit and alert resource query scopes + +Any new helper must be justified by reducing duplication across Alerts and Audit Log without weakening existing contracts from Specs 314 through 320. + +## OperationRun UX Impact + +No new operation start, operation run detail, or operation lifecycle behavior is introduced. Existing OperationRun audit or navigation links must only be updated if they currently emit non-canonical Environment parameters or imply a filtered Alerts/Audit destination without using `environment_id`. + +## Provider Boundary + +This spec is provider-neutral. It must not introduce Microsoft Graph tenant concepts, provider tenant aliases, or provider-specific Environment inference. The only Environment filter key is internal canonical `environment_id`. + +## Proportionality Review + +This spec introduces no new persisted entity, enum/status family, taxonomy, or framework. It updates the product contract and prepares implementation for existing surfaces using existing columns and shared navigation/filter abstractions. Therefore the constitution proportionality threshold for new persisted abstractions is not triggered. + +If implementation discovers that a new persisted attribute or abstraction is required, work must stop and this spec/plan/tasks must be updated before runtime changes continue. + +## Testing Requirements + +Required tests: + +- `it('documents_alerts_and_audit_log_filter_contract_decisions')` +- `it('alerts_support_environment_id_filter_with_visible_chip_and_clear')` +- `it('alert_deliveries_support_environment_id_filter_with_visible_chip_and_clear')` +- `it('audit_log_supports_environment_id_filter_with_visible_chip_and_clear')` +- `it('alerts_and_audit_log_do_not_accept_legacy_environment_query_aliases')` +- `it('alerts_and_audit_log_reject_cross_workspace_environment_filters')` +- `it('alerts_and_audit_log_sidebar_entry_is_workspace_wide')` +- `it('environment_ctas_to_alerts_and_audit_log_use_environment_id')` +- `it('alert_configuration_surfaces_do_not_emit_environment_filters')` + +Regression lanes: + +- Workspace hub registry and clean navigation tests from Spec 314. +- Environment CTA explicit filter tests from Spec 315. +- Clear filter contract tests from Spec 316. +- Legacy tenant/environment cleanup tests from Spec 317. +- Baseline Compare Environment-owned tests from Spec 319. +- Workspace-owned analysis shell tests from Spec 320. + +## Browser Verification Required + +Perform focused browser verification after runtime implementation: + +1. Open Alerts clean URL and verify Workspace-only shell, workspace-wide copy, and no chip. +2. Open Alerts with `?environment_id={id}` and verify chip, Workspace-only shell, and aligned filtered signal. +3. Clear Alerts filter, reload, and verify clean workspace-wide state. +4. Open Alert Deliveries with `?environment_id={id}` and verify chip, filtered rows, clear, and reload safety. +5. Open Audit Log clean URL and verify Workspace-only shell, workspace-wide copy, and no chip. +6. Open Audit Log with `?environment_id={id}` and verify chip, filtered rows, selected event consistency, clear, and reload safety. +7. Verify Environment Dashboard CTAs use `environment_id` only for filterable destinations and clean links for configuration destinations. +8. Verify browser back/forward after filter and clear does not create URL/chip/data mismatch. + +Screenshots, when captured, should be saved under: + +```text +specs/321-alerts-audit-log-environment-filter-contract-decision/artifacts/screenshots/ +``` + +Suggested names: + +```text +alerts--clean.png +alerts--filtered.png +alerts--after-clear.png +alerts--after-reload.png +alert-deliveries--filtered.png +audit-log--clean.png +audit-log--filtered.png +audit-log--after-clear.png +audit-log--after-reload.png +environment-cta--alerts.png +environment-cta--audit-log.png +``` + +## Acceptance Criteria + +### Decision + +- [ ] `decision.md` exists. +- [ ] Alerts have final contract `environment_filterable_workspace_hub`. +- [ ] Alert Deliveries have final contract `environment_filterable_workspace_hub`. +- [ ] Alert Rules have final contract `configuration_workspace_surface`. +- [ ] Alert Destinations have final contract `configuration_workspace_surface`. +- [ ] Audit Log has final contract `environment_filterable_workspace_hub`. +- [ ] No Alerts/Audit surface remains ambiguous. + +### URL / Query + +- [ ] Clean URLs open workspace-wide. +- [ ] Sidebar/global URLs contain no Environment params. +- [ ] Only `environment_id` is accepted for filterable surfaces. +- [ ] Legacy query aliases are not accepted. +- [ ] Cross-workspace Environment IDs are rejected. + +### Shell / UI + +- [ ] Alerts shell is Workspace-only. +- [ ] Alert Deliveries shell is Workspace-only. +- [ ] Alert Rules and Alert Destinations shell remains Workspace-only configuration. +- [ ] Audit Log shell is Workspace-only. +- [ ] Filtered state uses visible Environment chip. +- [ ] Workspace-wide state does not show Environment chip. +- [ ] No active Environment shell ownership appears. + +### Data Scope + +- [ ] Alert Deliveries are filtered by reliable `managed_environment_id`. +- [ ] Alerts overview Environment-attributable signal is filtered or non-attributable configuration counts are clearly not implied to be filtered. +- [ ] Audit Log is filtered by reliable `audit_logs.managed_environment_id`. +- [ ] No remembered Environment fallback applies. +- [ ] No Filament tenant fallback applies. +- [ ] No legacy table filter resurrects Environment scope. + +### Clear / Reload + +- [ ] Clear removes `environment_id`. +- [ ] Clear removes visible chip. +- [ ] Clear neutralizes stale table/session state. +- [ ] Reload after clear stays workspace-wide. +- [ ] Browser back/forward does not create mismatch where covered. + +### CTAs + +- [ ] Environment CTAs to Alerts, Alert Deliveries, or Audit Log use `environment_id` when preserving Environment focus. +- [ ] Environment CTAs to Alert Rules or Alert Destinations do not emit Environment filters. +- [ ] No CTA emits legacy params. + +### Regression + +- [ ] Spec 314 clean workspace hub entry remains green. +- [ ] Spec 315 `environment_id` contract remains green. +- [ ] Spec 316 clear filter remains green. +- [ ] Spec 317 legacy cleanup remains green. +- [ ] Spec 319 Baseline Compare remains Environment-owned. +- [ ] Spec 320 workspace-owned analysis remains Workspace-only. + +## Success Criteria + +- **SC-001**: A valid filtered Alerts or Audit Log URL visibly shows exactly one active Environment chip and Workspace-only shell. +- **SC-002**: A clean Alerts or Audit Log URL never inherits remembered Environment state. +- **SC-003**: Legacy query aliases do not change data, chip, shell, or clear state. +- **SC-004**: Cross-workspace Environment IDs never leak data. +- **SC-005**: Clear returns the page to a shareable, reload-safe clean URL. + +## Assumptions + +- The app remains pre-production; hard cutover is acceptable. +- Existing `managed_environment_id` columns are the reliable attribution source for chosen filterable surfaces. +- No migration is needed. +- Alert Rules and Alert Destinations remain workspace-level configuration, even when individual rule configuration can target tenants/environments. + +## Risks + +- Existing persisted Filament table filter state may conflict with canonical URL filter state if not explicitly reset. +- Alerts overview combines configuration and delivery data, so implementation must avoid implying that workspace-level configuration counts are Environment-filtered unless they truly are. +- Audit event detail selection could show a stale event outside the active Environment filter if selection state is not reconciled. + +## Follow-Up + +Spec 322 should add durable browser no-drift regression coverage for all context contracts after Spec 321 runtime implementation is complete. + +## Required Final Report For Implementation + +When Spec 321 runtime implementation completes, report: + +```text +Spec 321 completed. + +Chosen contracts: +- Alerts: environment_filterable_workspace_hub +- Alert Deliveries: environment_filterable_workspace_hub +- Alert Rules: configuration_workspace_surface +- Alert Destinations: configuration_workspace_surface +- Audit Log: environment_filterable_workspace_hub + +Changed behavior: +... + +Decision artifact: +specs/321-alerts-audit-log-environment-filter-contract-decision/decision.md + +Files changed: +... + +Tests: +- command: +- result: + +Browser verification: +... + +Remaining follow-up: +- 322: + +No migrations were created. +No seeders were changed. +No packages, env vars, queues, scheduler, storage, or deployment asset changes were made. +No backwards compatibility layer was introduced. +No legacy query alias support was added. +``` diff --git a/specs/321-alerts-audit-log-environment-filter-contract-decision/tasks.md b/specs/321-alerts-audit-log-environment-filter-contract-decision/tasks.md new file mode 100644 index 00000000..e747aba3 --- /dev/null +++ b/specs/321-alerts-audit-log-environment-filter-contract-decision/tasks.md @@ -0,0 +1,123 @@ +# Tasks: Alerts / Audit Log Environment Filter Contract Decision + +**Input**: Spec artifacts from `specs/321-alerts-audit-log-environment-filter-contract-decision/` +**Prerequisites**: `spec.md`, `plan.md`, `decision.md` +**Runtime posture**: Hard cutover. No legacy alias support. + +## Phase 1: Discovery Confirmation + +- [x] T001 Re-read `specs/321-alerts-audit-log-environment-filter-contract-decision/spec.md`, `plan.md`, and `decision.md` before runtime implementation starts. +- [x] T002 Re-read Spec 318 artifacts: `audit-report.md`, `surface-inventory.md`, `page-matrix.md`, `mismatch-findings.md`, and `recommended-fixes.md`. +- [x] T003 Confirm related completed specs 314 through 320 are context only and do not need edits for Spec 321. +- [x] T004 Confirm `alert_deliveries.managed_environment_id` and `audit_logs.managed_environment_id` are still present and indexed before implementing filter queries. +- [x] T005 Inspect current route names for Alerts, Alert Deliveries, Alert Rules, Alert Destinations, and Audit Log. + +## Phase 2: Tests First + +- [x] T006 Add a static/spec guard test named `it('documents_alerts_and_audit_log_filter_contract_decisions')`. +- [x] T007 Add Alerts overview contract test `it('alerts_support_environment_id_filter_with_visible_chip_and_clear')`. +- [x] T008 Add Alert Deliveries contract test `it('alert_deliveries_support_environment_id_filter_with_visible_chip_and_clear')`. +- [x] T009 Add Audit Log contract test `it('audit_log_supports_environment_id_filter_with_visible_chip_and_clear')`. +- [x] T010 Add legacy alias guard test `it('alerts_and_audit_log_do_not_accept_legacy_environment_query_aliases')`. +- [x] T011 Add cross-workspace guard test `it('alerts_and_audit_log_reject_cross_workspace_environment_filters')`. +- [x] T012 Add sidebar/global navigation regression test `it('alerts_and_audit_log_sidebar_entry_is_workspace_wide')`. +- [x] T013 Add Environment CTA contract test `it('environment_ctas_to_alerts_and_audit_log_use_environment_id')`. +- [x] T014 Add configuration-surface guard test `it('alert_configuration_surfaces_do_not_emit_environment_filters')`. +- [x] T015 Ensure tests prove data scope, not only URL or visible chip state. + +## Phase 3: Shared Contract Wiring + +- [x] T016 Inspect `apps/platform/app/Support/Navigation/WorkspaceHubRegistry.php` and confirm Alerts, Alert Deliveries, and Audit Log remain registered as workspace hubs. +- [x] T017 Confirm `WorkspaceHubRegistry::cleanUrl()` continues to strip Environment query params from sidebar/global entries. +- [x] T018 Inspect `apps/platform/app/Support/Navigation/WorkspaceHubEnvironmentFilter.php` and reuse it for canonical `environment_id` resolution. +- [x] T019 Reuse `apps/platform/app/Support/Navigation/WorkspaceHubFilterStateResetter.php` for clear behavior. +- [x] T020 Reuse `apps/platform/app/Filament/Concerns/ClearsWorkspaceHubEnvironmentFilterState.php` where page/resource architecture supports it. +- [x] T021 Reuse `apps/platform/resources/views/filament/partials/workspace-hub-environment-filter-chip.blade.php` for visible filter state. +- [x] T022 Do not add a page-local Environment resolver unless existing architecture requires a minimal adapter. + +## Phase 4: Alerts Overview + +- [x] T023 Update `apps/platform/app/Filament/Pages/Monitoring/Alerts.php` to resolve canonical `environment_id` for the current Workspace. +- [x] T024 Keep Alerts overview shell Workspace-only in all filtered and unfiltered states. +- [x] T025 Render the shared Environment filter chip in `apps/platform/resources/views/filament/pages/monitoring/alerts.blade.php` when the filter is active. +- [x] T026 Ensure the clear action on Alerts overview uses shared reset behavior and returns to clean `/admin/alerts`. +- [x] T027 Update `apps/platform/app/Filament/Widgets/AlertsKpiHeader.php` so Environment-attributable delivery signal counts respect the active filter. +- [x] T028 Ensure workspace-level configuration counts on Alerts overview are not mislabeled as Environment-filtered counts. +- [x] T029 Ensure Alerts overview links to Alert Deliveries preserve canonical `environment_id` when filtered. +- [x] T030 Ensure Alerts overview links to Alert Rules and Alert Destinations do not include Environment filter params. + +## Phase 5: Alert Deliveries + +- [x] T031 Update `apps/platform/app/Filament/Resources/AlertDeliveryResource.php` query/table behavior to apply canonical `environment_id` through shared resolution. +- [x] T032 Filter Alert Deliveries rows by reliable `managed_environment_id`. +- [x] T033 Keep Alert Deliveries shell Workspace-only in all filtered and unfiltered states. +- [x] T034 Render the shared Environment filter chip on the Alert Deliveries list page when filtered. +- [x] T035 Ensure clear removes `environment_id`, `tableFilters`, `tableDeferredFilters`, persisted Filament/session filter state, and visible chip. +- [x] T036 Ensure legacy URL aliases do not prefill the table filter. +- [x] T037 Ensure clean Alert Deliveries entry is workspace-wide and reload-safe. + +## Phase 6: Alert Rules and Alert Destinations + +- [x] T038 Confirm `apps/platform/app/Filament/Resources/AlertRuleResource.php` remains a workspace configuration surface. +- [x] T039 Confirm `apps/platform/app/Filament/Resources/AlertDestinationResource.php` remains a workspace configuration surface. +- [x] T040 Ensure Alert Rules do not render an Environment filter chip from `environment_id`. +- [x] T041 Ensure Alert Destinations do not render an Environment filter chip from `environment_id`. +- [x] T042 Ensure Environment Dashboard or Alert overview CTAs to Alert Rules use clean workspace links. +- [x] T043 Ensure Environment Dashboard or Alert overview CTAs to Alert Destinations use clean workspace links. + +## Phase 7: Audit Log + +- [x] T044 Update `apps/platform/app/Filament/Pages/Monitoring/AuditLog.php` to resolve canonical `environment_id` for the current Workspace. +- [x] T045 Filter Audit Log rows by reliable `audit_logs.managed_environment_id` when the filter is active. +- [x] T046 Keep Audit Log shell Workspace-only in all filtered and unfiltered states. +- [x] T047 Render the shared Environment filter chip in `apps/platform/resources/views/filament/pages/monitoring/audit-log.blade.php` when filtered. +- [x] T048 Ensure Audit Log clear removes `environment_id`, table filters, deferred filters, persisted session state, and visible chip. +- [x] T049 Ensure Audit Log clean entry does not inherit remembered Environment state. +- [x] T050 Ensure selected audit event/detail state cannot show an event outside the active Environment filter. +- [x] T051 Ensure audit events with null `managed_environment_id` remain visible workspace-wide and are excluded when filtered. + +## Phase 8: Navigation, Links, and CTAs + +- [x] T052 Inspect `apps/platform/app/Support/Navigation/WorkspaceSidebarNavigation.php` and confirm sidebar entries for Alerts and Audit Log remain clean. +- [x] T053 Inspect Environment Dashboard CTAs and widgets for Alerts or Audit Log links. +- [x] T054 Update Environment-origin CTAs to Alerts, Alert Deliveries, and Audit Log to use canonical `environment_id` only when preserving Environment focus. +- [x] T055 Remove `environment_id` from Environment-origin CTAs to Alert Rules and Alert Destinations. +- [x] T056 Inspect `apps/platform/app/Support/ManagedEnvironmentLinks.php` for any Alerts/Audit helpers or related links that need canonicalization. +- [x] T057 Inspect `apps/platform/app/Support/Operations/OperationRunLinks.php` for Alerts/Audit links that need canonicalization. +- [x] T058 Inspect notification, alert, and audit link helpers for legacy `tenant`, `tenant_id`, `managed_environment_id`, `environment`, `tenant_scope`, or `tableFilters` query output. + +## Phase 9: Regression and Safety + +- [x] T059 Run targeted Alerts/Audit contract tests. +- [x] T060 Run Spec 315 environment filter contract regression tests. +- [x] T061 Run Spec 316 clear filter contract regression tests. +- [x] T062 Run Spec 317 legacy tenant cleanup regression tests. +- [x] T063 Run Spec 314 workspace hub navigation regression tests. +- [x] T064 Run Spec 319 Baseline Compare Environment-owned regression tests. +- [x] T065 Run Spec 320 workspace-owned analysis shell regression tests. +- [x] T066 Run formatting for touched PHP files. +- [x] T067 Run `git diff --check`. + +## Phase 10: Browser Verification + +> 2026-05-17 note: attempted integrated browser verification, but the Playwright MCP browser profile was locked by another running browser process (`mcp-chrome-6176c52`). Existing Pest browser smoke `tests/Browser/Spec193MonitoringSurfaceHierarchySmokeTest.php` passed and covers `/admin/alerts`, but the requested screenshot set remains pending. + +- [ ] T068 Start the local Sail/browser-ready environment. +- [ ] T069 Open Alerts clean URL and capture `artifacts/screenshots/alerts--clean.png`. +- [ ] T070 Open Alerts filtered URL and capture `artifacts/screenshots/alerts--filtered.png`. +- [ ] T071 Clear Alerts filter, reload, and capture `artifacts/screenshots/alerts--after-clear.png` and `alerts--after-reload.png`. +- [ ] T072 Open Alert Deliveries filtered URL and capture `artifacts/screenshots/alert-deliveries--filtered.png`. +- [ ] T073 Open Audit Log clean URL and capture `artifacts/screenshots/audit-log--clean.png`. +- [ ] T074 Open Audit Log filtered URL and capture `artifacts/screenshots/audit-log--filtered.png`. +- [ ] T075 Clear Audit Log filter, reload, and capture `artifacts/screenshots/audit-log--after-clear.png` and `audit-log--after-reload.png`. +- [ ] T076 Verify Environment Dashboard CTAs to Alerts/Audit Log and capture useful CTA screenshots. +- [ ] T077 Verify browser back/forward after filter and clear does not create URL/chip/data mismatch. + +## Phase 11: Final Report + +- [ ] T078 Report chosen contracts for Alerts, Alert Deliveries, Alert Rules, Alert Destinations, and Audit Log. +- [ ] T079 Report changed behavior and files changed. +- [ ] T080 Report test commands and results, including any unrelated residual failures. +- [ ] T081 Report browser verification and screenshot paths. +- [ ] T082 Report that no migrations, seeders, packages, env vars, queues, scheduler, storage, or deployment asset changes were made unless the spec was explicitly updated. +- [ ] T083 Report that no backwards compatibility layer or legacy query alias support was introduced.