diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index f0c4f35d..d7b55ebe 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -165,6 +165,8 @@ ## Active Technologies - PostgreSQL with one narrow schema addition (`is_enabled`) followed by final removal of legacy `status` and `health_status` columns and their indexes (188-provider-connection-state-cleanup) - PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `PortfolioArrivalContext`, `TenantBackupHealthResolver`, `RestoreSafetyResolver`, `BadgeCatalog`, `UiEnforcement`, and `AuditRecorder` patterns (189-portfolio-triage-review-state) - PostgreSQL via Laravel Eloquent with one new table `tenant_triage_reviews` and no new external caches or background stores (189-portfolio-triage-review-state) +- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns (190-baseline-compare-matrix) +- PostgreSQL via existing `baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments`, `operation_runs`, and `findings` tables; no new persistence planned (190-baseline-compare-matrix) - PHP 8.4.15 (feat/005-bulk-operations) @@ -199,8 +201,8 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 190-baseline-compare-matrix: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns - 189-portfolio-triage-review-state: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `PortfolioArrivalContext`, `TenantBackupHealthResolver`, `RestoreSafetyResolver`, `BadgeCatalog`, `UiEnforcement`, and `AuditRecorder` patterns - 188-provider-connection-state-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ProviderConnection` model, `ProviderConnectionResolver`, `ProviderConnectionStateProjector`, `ProviderConnectionMutationService`, `ProviderConnectionHealthCheckJob`, `StartVerification`, `ProviderConnectionResource`, `TenantResource`, system directory pages, `BadgeCatalog`, `BadgeRenderer`, and shared provider-state Blade entries -- 187-portfolio-triage-arrival-context: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `CanonicalAdminTenantFilterState`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, and continuity-aware backup or restore list pages diff --git a/apps/platform/app/Filament/Pages/BaselineCompareLanding.php b/apps/platform/app/Filament/Pages/BaselineCompareLanding.php index f51d4944..acd2e153 100644 --- a/apps/platform/app/Filament/Pages/BaselineCompareLanding.php +++ b/apps/platform/app/Filament/Pages/BaselineCompareLanding.php @@ -15,6 +15,7 @@ use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCompareEvidenceGapDetails; use App\Support\Baselines\BaselineCompareStats; +use App\Support\Navigation\CanonicalNavigationContext; use App\Support\Baselines\TenantGovernanceAggregate; use App\Support\Baselines\TenantGovernanceAggregateResolver; use App\Support\OperationRunLinks; @@ -109,6 +110,13 @@ class BaselineCompareLanding extends Page /** @var array|null */ public ?array $summaryAssessment = null; + /** @var array|null */ + public ?array $navigationContextPayload = null; + + public ?int $matrixBaselineProfileId = null; + + public ?string $matrixSubjectKey = null; + public static function canAccess(): bool { $user = auth()->user(); @@ -130,6 +138,12 @@ public static function canAccess(): bool public function mount(): void { + $this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null; + $baselineProfileId = request()->query('baseline_profile_id'); + $subjectKey = request()->query('subject_key'); + + $this->matrixBaselineProfileId = is_numeric($baselineProfileId) ? (int) $baselineProfileId : null; + $this->matrixSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null; $this->refreshStats(); } @@ -244,6 +258,9 @@ protected function getViewData(): array } return [ + 'navigationContext' => $this->navigationContext()?->toQuery()['nav'] ?? null, + 'matrixBaselineProfileId' => $this->matrixBaselineProfileId, + 'matrixSubjectKey' => $this->matrixSubjectKey, 'hasCoverageWarnings' => $hasCoverageWarnings, 'evidenceGapsCountValue' => $evidenceGapsCountValue, 'hasEvidenceGaps' => $hasEvidenceGaps, @@ -302,9 +319,19 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration */ protected function getHeaderActions(): array { - return [ - $this->compareNowAction(), - ]; + $actions = []; + $navigationContext = $this->navigationContext(); + + if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) { + $actions[] = Action::make('backToOrigin') + ->label($navigationContext->backLinkLabel) + ->color('gray') + ->url($navigationContext->backLinkUrl); + } + + $actions[] = $this->compareNowAction(); + + return $actions; } private function compareNowAction(): Action @@ -389,7 +416,7 @@ private function compareNowAction(): Action ->actions($run instanceof OperationRun ? [ Action::make('view_run') ->label('Open operation') - ->url(OperationRunLinks::view($run, $tenant)), + ->url(OperationRunLinks::view($run, $tenant, $this->navigationContext())), ] : []) ->send(); }); @@ -436,4 +463,15 @@ private function governanceAggregate(Tenant $tenant, BaselineCompareStats $stats return $aggregate; } + + private function navigationContext(): ?CanonicalNavigationContext + { + if (! is_array($this->navigationContextPayload)) { + return CanonicalNavigationContext::fromRequest(request()); + } + + $request = request()->duplicate(query: ['nav' => $this->navigationContextPayload]); + + return CanonicalNavigationContext::fromRequest($request); + } } diff --git a/apps/platform/app/Filament/Pages/BaselineCompareMatrix.php b/apps/platform/app/Filament/Pages/BaselineCompareMatrix.php new file mode 100644 index 00000000..dcdd0670 --- /dev/null +++ b/apps/platform/app/Filament/Pages/BaselineCompareMatrix.php @@ -0,0 +1,414 @@ + + */ + public array $selectedPolicyTypes = []; + + /** + * @var list + */ + public array $selectedStates = []; + + /** + * @var list + */ + public array $selectedSeverities = []; + + public string $tenantSort = 'tenant_name'; + + public string $subjectSort = 'deviation_breadth'; + + public ?string $focusedSubjectKey = null; + + /** + * @var array + */ + public array $matrix = []; + + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep bounded navigation plus confirmation-gated compare fan-out for visible assigned tenants.') + ->exempt(ActionSurfaceSlot::InspectAffordance, 'The matrix intentionally forbids row click; only explicit tenant, subject, cell, and run drilldowns are rendered.') + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The matrix does not use a row-level secondary-actions menu.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The matrix has no bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Blocked, empty, no-visible-tenant, and no-filter-match states render as explicit matrix empty states.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'The matrix is a page-level scan surface rather than a record detail header.'); + } + + public function mount(int|string $record): void + { + $this->record = $this->resolveRecord($record); + $this->hydrateFiltersFromRequest(); + $this->refreshMatrix(); + } + + protected function authorizeAccess(): void + { + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + abort(404); + } + + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + if (! $resolver->isMember($user, $workspace)) { + abort(404); + } + + if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) { + abort(403); + } + } + + public function getTitle(): string + { + /** @var BaselineProfile $profile */ + $profile = $this->getRecord(); + + return 'Compare matrix: '.$profile->name; + } + + /** + * @return array + */ + protected function getHeaderActions(): array + { + $profile = $this->getRecord(); + + $compareAssignedTenantsAction = Action::make('compareAssignedTenants') + ->label('Compare assigned tenants') + ->icon('heroicon-o-play') + ->requiresConfirmation() + ->modalHeading('Compare assigned tenants') + ->modalDescription('Simulation only. This starts the normal tenant-owned baseline compare path for the visible assigned set. No workspace umbrella run is created.') + ->disabled(fn (): bool => $this->compareAssignedTenantsDisabledReason() !== null) + ->tooltip(fn (): ?string => $this->compareAssignedTenantsDisabledReason()) + ->action(fn (): mixed => $this->compareAssignedTenants()); + + $compareAssignedTenantsAction = WorkspaceUiEnforcement::forAction( + $compareAssignedTenantsAction, + fn (): ?Workspace => $this->workspace(), + ) + ->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE) + ->preserveDisabled() + ->tooltip('You need workspace baseline manage access to compare the visible assigned set.') + ->apply(); + + return [ + Action::make('backToBaselineProfile') + ->label('Back to baseline profile') + ->color('gray') + ->url(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin')), + $compareAssignedTenantsAction, + ]; + } + + public function refreshMatrix(): void + { + $user = auth()->user(); + + abort_unless($user instanceof User, 403); + + /** @var BaselineProfile $profile */ + $profile = $this->getRecord(); + + $this->matrix = app(BaselineCompareMatrixBuilder::class)->build($profile, $user, [ + 'policyTypes' => $this->selectedPolicyTypes, + 'states' => $this->selectedStates, + 'severities' => $this->selectedSeverities, + 'tenantSort' => $this->tenantSort, + 'subjectSort' => $this->subjectSort, + 'focusedSubjectKey' => $this->focusedSubjectKey, + ]); + } + + public function tenantCompareUrl(int $tenantId, ?string $subjectKey = null): ?string + { + $tenant = $this->tenant($tenantId); + + if (! $tenant instanceof Tenant) { + return null; + } + + return BaselineCompareLanding::getUrl( + parameters: $this->navigationContext($tenant, $subjectKey)->toQuery(), + panel: 'tenant', + tenant: $tenant, + ); + } + + public function findingUrl(int $tenantId, int $findingId, ?string $subjectKey = null): ?string + { + $tenant = $this->tenant($tenantId); + + if (! $tenant instanceof Tenant) { + return null; + } + + return FindingResource::getUrl( + 'view', + [ + 'record' => $findingId, + ...$this->navigationContext($tenant, $subjectKey)->toQuery(), + ], + tenant: $tenant, + ); + } + + public function runUrl(int $runId, ?int $tenantId = null, ?string $subjectKey = null): string + { + return OperationRunLinks::tenantlessView( + $runId, + $this->navigationContext( + $tenantId !== null ? $this->tenant($tenantId) : null, + $subjectKey, + ), + ); + } + + public function clearSubjectFocusUrl(): string + { + return static::getUrl($this->routeParameters([ + 'subject_key' => null, + ]), panel: 'admin'); + } + + public function filterUrl(array $overrides = []): string + { + return static::getUrl($this->routeParameters($overrides), panel: 'admin'); + } + + public function updatedSelectedPolicyTypes(): void + { + $this->refreshMatrix(); + } + + public function updatedSelectedStates(): void + { + $this->refreshMatrix(); + } + + public function updatedSelectedSeverities(): void + { + $this->refreshMatrix(); + } + + public function updatedTenantSort(): void + { + $this->refreshMatrix(); + } + + public function updatedSubjectSort(): void + { + $this->refreshMatrix(); + } + + public function updatedFocusedSubjectKey(): void + { + $this->refreshMatrix(); + } + + /** + * @return array + */ + protected function getViewData(): array + { + return array_merge($this->matrix, [ + 'profile' => $this->getRecord(), + 'currentFilters' => [ + 'policy_type' => $this->selectedPolicyTypes, + 'state' => $this->selectedStates, + 'severity' => $this->selectedSeverities, + 'tenant_sort' => $this->tenantSort, + 'subject_sort' => $this->subjectSort, + 'subject_key' => $this->focusedSubjectKey, + ], + ]); + } + + private function hydrateFiltersFromRequest(): void + { + $this->selectedPolicyTypes = $this->normalizeQueryList(request()->query('policy_type', [])); + $this->selectedStates = $this->normalizeQueryList(request()->query('state', [])); + $this->selectedSeverities = $this->normalizeQueryList(request()->query('severity', [])); + $this->tenantSort = is_string(request()->query('tenant_sort')) ? (string) request()->query('tenant_sort') : 'tenant_name'; + $this->subjectSort = is_string(request()->query('subject_sort')) ? (string) request()->query('subject_sort') : 'deviation_breadth'; + $subjectKey = request()->query('subject_key'); + $this->focusedSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null; + } + + /** + * @return list + */ + private function normalizeQueryList(mixed $value): array + { + $values = is_array($value) ? $value : [$value]; + + return array_values(array_unique(array_filter(array_map(static function (mixed $item): ?string { + if (! is_string($item)) { + return null; + } + + $normalized = trim($item); + + return $normalized !== '' ? $normalized : null; + }, $values)))); + } + + private function compareAssignedTenantsDisabledReason(): ?string + { + $reference = is_array($this->matrix['reference'] ?? null) ? $this->matrix['reference'] : []; + + if (($reference['referenceState'] ?? null) !== 'ready') { + return 'Capture a complete baseline snapshot before comparing assigned tenants.'; + } + + if ((int) ($reference['visibleTenantCount'] ?? 0) === 0) { + return 'No visible assigned tenants are available for compare.'; + } + + return null; + } + + private function compareAssignedTenants(): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + /** @var BaselineProfile $profile */ + $profile = $this->getRecord(); + + $result = app(BaselineCompareService::class)->startCompareForVisibleAssignments($profile, $user); + $summary = sprintf( + '%d queued, %d already queued, %d blocked across %d visible assigned tenant%s.', + (int) $result['queuedCount'], + (int) $result['alreadyQueuedCount'], + (int) $result['blockedCount'], + (int) $result['visibleAssignedTenantCount'], + (int) $result['visibleAssignedTenantCount'] === 1 ? '' : 's', + ); + + if ((int) $result['queuedCount'] > 0 || (int) $result['alreadyQueuedCount'] > 0) { + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + $toast = (int) $result['queuedCount'] > 0 + ? OperationUxPresenter::queuedToast('baseline_compare') + : OperationUxPresenter::alreadyQueuedToast('baseline_compare'); + + $toast + ->body($summary.' Open Operations for progress and next steps.') + ->actions([ + Action::make('open_operations') + ->label('Open operations') + ->url(OperationRunLinks::index( + context: $this->navigationContext(), + allTenants: true, + )), + ]) + ->send(); + } else { + Notification::make() + ->title('No baseline compares were started') + ->body($summary) + ->warning() + ->send(); + } + + $this->refreshMatrix(); + } + + /** + * @param array $overrides + * @return array + */ + private function routeParameters(array $overrides = []): array + { + return array_filter([ + 'record' => $this->getRecord(), + 'policy_type' => $this->selectedPolicyTypes, + 'state' => $this->selectedStates, + 'severity' => $this->selectedSeverities, + 'tenant_sort' => $this->tenantSort, + 'subject_sort' => $this->subjectSort, + 'subject_key' => $this->focusedSubjectKey, + ...$overrides, + ], static fn (mixed $value): bool => $value !== null && $value !== [] && $value !== ''); + } + + private function navigationContext(?Tenant $tenant = null, ?string $subjectKey = null): CanonicalNavigationContext + { + /** @var BaselineProfile $profile */ + $profile = $this->getRecord(); + + $subjectKey ??= $this->focusedSubjectKey; + + return CanonicalNavigationContext::forBaselineCompareMatrix( + profile: $profile, + filters: $this->routeParameters(), + tenant: $tenant, + subjectKey: $subjectKey, + ); + } + + private function tenant(int $tenantId): ?Tenant + { + return Tenant::query() + ->whereKey($tenantId) + ->where('workspace_id', (int) $this->getRecord()->workspace_id) + ->first(); + } + + private function workspace(): ?Workspace + { + return Workspace::query()->whereKey((int) $this->getRecord()->workspace_id)->first(); + } +} diff --git a/apps/platform/app/Filament/Resources/BaselineProfileResource.php b/apps/platform/app/Filament/Resources/BaselineProfileResource.php index 321c1c49..427b61f7 100644 --- a/apps/platform/app/Filament/Resources/BaselineProfileResource.php +++ b/apps/platform/app/Filament/Resources/BaselineProfileResource.php @@ -4,6 +4,7 @@ namespace App\Filament\Resources; +use App\Filament\Pages\BaselineCompareMatrix; use App\Filament\Resources\BaselineProfileResource\Pages; use App\Models\BaselineProfile; use App\Models\BaselineSnapshot; @@ -135,7 +136,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Edit leads and archive trails inside "More".') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.') - ->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture + edit actions.'); + ->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture, compare-now, open-matrix, compare-assigned-tenants, and edit actions.'); } public static function getEloquentQuery(): Builder @@ -447,10 +448,16 @@ public static function getPages(): array 'index' => Pages\ListBaselineProfiles::route('/'), 'create' => Pages\CreateBaselineProfile::route('/create'), 'view' => Pages\ViewBaselineProfile::route('/{record}'), + 'compare-matrix' => BaselineCompareMatrix::route('/{record}/compare-matrix'), 'edit' => Pages\EditBaselineProfile::route('/{record}/edit'), ]; } + public static function compareMatrixUrl(BaselineProfile|int $profile): string + { + return static::getUrl('compare-matrix', ['record' => $profile], panel: 'admin'); + } + /** * @return array */ diff --git a/apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php b/apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php index 305abb71..6128bf26 100644 --- a/apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php +++ b/apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php @@ -44,6 +44,8 @@ protected function getHeaderActions(): array ->color('gray'), $this->captureAction(), $this->compareNowAction(), + $this->openCompareMatrixAction(), + $this->compareAssignedTenantsAction(), EditAction::make() ->visible(fn (): bool => $this->hasManageCapability()), ]; @@ -307,6 +309,80 @@ private function compareNowAction(): Action }); } + private function openCompareMatrixAction(): Action + { + return Action::make('openCompareMatrix') + ->label('Open compare matrix') + ->icon('heroicon-o-squares-2x2') + ->color('gray') + ->url(fn (): string => BaselineProfileResource::compareMatrixUrl($this->getRecord())); + } + + private function compareAssignedTenantsAction(): Action + { + $action = Action::make('compareAssignedTenants') + ->label('Compare assigned tenants') + ->icon('heroicon-o-play') + ->requiresConfirmation() + ->modalHeading('Compare assigned tenants') + ->modalDescription('Simulation only. This starts the normal tenant-owned baseline compare path for the visible assigned set. No workspace umbrella run is created.') + ->disabled(fn (): bool => $this->compareAssignedTenantsDisabledReason() !== null) + ->tooltip(fn (): ?string => $this->compareAssignedTenantsDisabledReason()) + ->action(function (): void { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + /** @var BaselineProfile $profile */ + $profile = $this->getRecord(); + $result = app(BaselineCompareService::class)->startCompareForVisibleAssignments($profile, $user); + $summary = sprintf( + '%d queued, %d already queued, %d blocked across %d visible assigned tenant%s.', + (int) $result['queuedCount'], + (int) $result['alreadyQueuedCount'], + (int) $result['blockedCount'], + (int) $result['visibleAssignedTenantCount'], + (int) $result['visibleAssignedTenantCount'] === 1 ? '' : 's', + ); + + if ((int) $result['queuedCount'] > 0 || (int) $result['alreadyQueuedCount'] > 0) { + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + $toast = (int) $result['queuedCount'] > 0 + ? OperationUxPresenter::queuedToast('baseline_compare') + : OperationUxPresenter::alreadyQueuedToast('baseline_compare'); + + $toast + ->body($summary.' Open Operations for progress and next steps.') + ->actions([ + Action::make('open_operations') + ->label('Open operations') + ->url(OperationRunLinks::index(allTenants: true)), + ]) + ->send(); + + return; + } + + Notification::make() + ->title('No baseline compares were started') + ->body($summary) + ->warning() + ->send(); + }); + + return WorkspaceUiEnforcement::forAction( + $action, + fn (): ?Workspace => Workspace::query()->whereKey((int) $this->getRecord()->workspace_id)->first(), + ) + ->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE) + ->preserveDisabled() + ->tooltip('You need workspace baseline manage access to compare the visible assigned set.') + ->apply(); + } + /** * @return array */ @@ -407,4 +483,48 @@ private function profileHasConsumableSnapshot(): bool return $profile->resolveCurrentConsumableSnapshot() !== null; } + + private function compareAssignedTenantsDisabledReason(): ?string + { + /** @var BaselineProfile $profile */ + $profile = $this->getRecord(); + + if (! $this->profileHasConsumableSnapshot()) { + return 'Capture a complete baseline snapshot before comparing assigned tenants.'; + } + + if ($this->visibleAssignedTenantCount($profile) === 0) { + return 'No visible assigned tenants are available for compare.'; + } + + return null; + } + + private function visibleAssignedTenantCount(BaselineProfile $profile): int + { + $user = auth()->user(); + + if (! $user instanceof User) { + return 0; + } + + $tenantIds = BaselineTenantAssignment::query() + ->where('workspace_id', (int) $profile->workspace_id) + ->where('baseline_profile_id', (int) $profile->getKey()) + ->pluck('tenant_id') + ->all(); + + if ($tenantIds === []) { + return 0; + } + + $resolver = app(CapabilityResolver::class); + + return Tenant::query() + ->where('workspace_id', (int) $profile->workspace_id) + ->whereIn('id', $tenantIds) + ->get(['id']) + ->filter(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) + ->count(); + } } diff --git a/apps/platform/app/Filament/Resources/FindingResource.php b/apps/platform/app/Filament/Resources/FindingResource.php index 1f9ce840..a41653a3 100644 --- a/apps/platform/app/Filament/Resources/FindingResource.php +++ b/apps/platform/app/Filament/Resources/FindingResource.php @@ -1257,6 +1257,16 @@ private static function primaryRelatedEntry(Finding $record, bool $fresh = false private static function findingRunNavigationContext(Finding $record): CanonicalNavigationContext { + $incomingContext = CanonicalNavigationContext::fromRequest(request()); + + if ( + $incomingContext instanceof CanonicalNavigationContext + && str_starts_with($incomingContext->sourceSurface, 'baseline_compare_matrix') + && $incomingContext->backLinkUrl !== null + ) { + return $incomingContext; + } + $tenant = $record->tenant; return new CanonicalNavigationContext( diff --git a/apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php b/apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php index 2dabbde0..b89e3f30 100644 --- a/apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php +++ b/apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php @@ -5,6 +5,7 @@ use App\Filament\Resources\FindingExceptionResource; use App\Filament\Resources\FindingResource; use App\Models\Finding; +use App\Support\Navigation\CanonicalNavigationContext; use App\Support\Navigation\CrossResourceNavigationMatrix; use App\Support\Navigation\RelatedNavigationResolver; use Filament\Actions; @@ -23,7 +24,17 @@ protected function resolveRecord(int|string $key): Model protected function getHeaderActions(): array { - return [ + $actions = []; + $navigationContext = $this->navigationContext(); + + if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) { + $actions[] = Actions\Action::make('back_to_origin') + ->label($navigationContext->backLinkLabel) + ->color('gray') + ->url($navigationContext->backLinkUrl); + } + + return array_merge($actions, [ Actions\Action::make('primary_related') ->label(fn (): string => app(RelatedNavigationResolver::class) ->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->actionLabel ?? 'Open related record') @@ -53,11 +64,16 @@ protected function getHeaderActions(): array ->label('Actions') ->icon('heroicon-o-ellipsis-vertical') ->color('gray'), - ]; + ]); } public function getSubheading(): string|Htmlable|null { return FindingResource::findingSubheading($this->getRecord()); } + + private function navigationContext(): ?CanonicalNavigationContext + { + return CanonicalNavigationContext::fromRequest(request()); + } } diff --git a/apps/platform/app/Models/BaselineTenantAssignment.php b/apps/platform/app/Models/BaselineTenantAssignment.php index 14a42bc7..0b341896 100644 --- a/apps/platform/app/Models/BaselineTenantAssignment.php +++ b/apps/platform/app/Models/BaselineTenantAssignment.php @@ -4,6 +4,7 @@ use App\Support\Concerns\DerivesWorkspaceIdFromTenant; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -37,4 +38,30 @@ public function assignedByUser(): BelongsTo { return $this->belongsTo(User::class, 'assigned_by_user_id'); } + + public function scopeForBaselineProfile(Builder $query, BaselineProfile|int $profile): Builder + { + $profileId = $profile instanceof BaselineProfile + ? (int) $profile->getKey() + : (int) $profile; + + return $query->where('baseline_profile_id', $profileId); + } + + public function scopeInWorkspace(Builder $query, int $workspaceId): Builder + { + return $query->where('workspace_id', $workspaceId); + } + + public static function assignedTenantIdsForProfile(BaselineProfile|int $profile, ?int $workspaceId = null): array + { + return static::query() + ->when($workspaceId !== null, fn (Builder $query): Builder => $query->inWorkspace($workspaceId)) + ->forBaselineProfile($profile) + ->pluck('tenant_id') + ->map(static fn (mixed $tenantId): int => (int) $tenantId) + ->filter(static fn (int $tenantId): bool => $tenantId > 0) + ->values() + ->all(); + } } diff --git a/apps/platform/app/Models/Finding.php b/apps/platform/app/Models/Finding.php index bd7daf59..3df02031 100644 --- a/apps/platform/app/Models/Finding.php +++ b/apps/platform/app/Models/Finding.php @@ -274,6 +274,18 @@ public function scopeOpenDrift(Builder $query): Builder ->openWorkflow(); } + public function scopeBaselineCompareForProfile(Builder $query, BaselineProfile|int $profile): Builder + { + $profileId = $profile instanceof BaselineProfile + ? (int) $profile->getKey() + : (int) $profile; + + return $query + ->drift() + ->where('source', 'baseline.compare') + ->where('scope_key', 'baseline_profile:'.$profileId); + } + public function scopeOverdueOpen(Builder $query): Builder { return $query diff --git a/apps/platform/app/Models/OperationRun.php b/apps/platform/app/Models/OperationRun.php index c76afd94..423e0934 100644 --- a/apps/platform/app/Models/OperationRun.php +++ b/apps/platform/app/Models/OperationRun.php @@ -6,6 +6,7 @@ use App\Support\OperationCatalog; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\OperationRunType; use App\Support\Operations\OperationLifecyclePolicy; use App\Support\Operations\OperationRunFreshnessState; use Illuminate\Database\Eloquent\Builder; @@ -89,6 +90,17 @@ public function scopeTerminalFailure(Builder $query): Builder ->where('outcome', OperationRunOutcome::Failed->value); } + public function scopeBaselineCompareForProfile(Builder $query, BaselineProfile|int $profile): Builder + { + $profileId = $profile instanceof BaselineProfile + ? (int) $profile->getKey() + : (int) $profile; + + return $query + ->where('type', OperationRunType::BaselineCompare->value) + ->where('context->baseline_profile_id', $profileId); + } + public function scopeLikelyStale(Builder $query, ?OperationLifecyclePolicy $policy = null): Builder { $policy ??= app(OperationLifecyclePolicy::class); @@ -284,6 +296,34 @@ public function isGovernanceArtifactOperation(): bool return OperationCatalog::isGovernanceArtifactOperation((string) $this->type); } + /** + * @param array $tenantIds + * @return \Illuminate\Support\Collection + */ + public static function latestBaselineCompareRunsForProfile( + BaselineProfile|int $profile, + array $tenantIds, + ?int $workspaceId = null, + bool $completedOnly = false, + ): \Illuminate\Support\Collection { + if ($tenantIds === []) { + return collect(); + } + + $runs = static::query() + ->when($workspaceId !== null, fn (Builder $query): Builder => $query->where('workspace_id', $workspaceId)) + ->whereIn('tenant_id', $tenantIds) + ->baselineCompareForProfile($profile) + ->when($completedOnly, fn (Builder $query): Builder => $query->where('status', OperationRunStatus::Completed->value)) + ->orderByDesc('completed_at') + ->orderByDesc('id') + ->get(); + + return $runs + ->unique(static fn (self $run): int => (int) $run->tenant_id) + ->values(); + } + public static function latestCompletedCoverageBearingInventorySyncForTenant(int $tenantId): ?self { if ($tenantId <= 0) { diff --git a/apps/platform/app/Services/Baselines/BaselineCompareService.php b/apps/platform/app/Services/Baselines/BaselineCompareService.php index b9bd59ad..d5073348 100644 --- a/apps/platform/app/Services/Baselines/BaselineCompareService.php +++ b/apps/platform/app/Services/Baselines/BaselineCompareService.php @@ -11,6 +11,7 @@ use App\Models\OperationRun; use App\Models\Tenant; use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\OperationRunService; use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineFullContentRolloutGate; @@ -28,6 +29,7 @@ public function __construct( private readonly BaselineFullContentRolloutGate $rolloutGate, private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver, private readonly BaselineSupportCapabilityGuard $capabilityGuard, + private readonly CapabilityResolver $capabilityResolver, ) {} /** @@ -47,12 +49,34 @@ public function startCompare( return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT); } - $profile = BaselineProfile::query()->find($assignment->baseline_profile_id); + $profile = $assignment->baselineProfile; if (! $profile instanceof BaselineProfile) { return $this->failedStart(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE); } + return $this->startCompareForProfile($profile, $tenant, $initiator, $baselineSnapshotId); + } + + /** + * @return array{ok: bool, run?: OperationRun, reason_code?: string, reason_translation?: array} + */ + public function startCompareForProfile( + BaselineProfile $profile, + Tenant $tenant, + User $initiator, + ?int $baselineSnapshotId = null, + ): array { + $assignment = BaselineTenantAssignment::query() + ->where('workspace_id', (int) $profile->workspace_id) + ->where('tenant_id', (int) $tenant->getKey()) + ->where('baseline_profile_id', (int) $profile->getKey()) + ->first(); + + if (! $assignment instanceof BaselineTenantAssignment) { + return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT); + } + $precondition = $this->validatePreconditions($profile); if ($precondition !== null) { @@ -124,6 +148,103 @@ public function startCompare( return ['ok' => true, 'run' => $run]; } + /** + * @return array{ + * baselineProfileId: int, + * visibleAssignedTenantCount: int, + * queuedCount: int, + * alreadyQueuedCount: int, + * blockedCount: int, + * targets: list + * } + */ + public function startCompareForVisibleAssignments(BaselineProfile $profile, User $initiator): array + { + $assignments = BaselineTenantAssignment::query() + ->where('workspace_id', (int) $profile->workspace_id) + ->where('baseline_profile_id', (int) $profile->getKey()) + ->with('tenant') + ->get(); + + $queuedCount = 0; + $alreadyQueuedCount = 0; + $blockedCount = 0; + $targets = []; + + foreach ($assignments as $assignment) { + $tenant = $assignment->tenant; + + if (! $tenant instanceof Tenant) { + continue; + } + + if (! $this->capabilityResolver->isMember($initiator, $tenant)) { + continue; + } + + if (! $this->capabilityResolver->can($initiator, $tenant, \App\Support\Auth\Capabilities::TENANT_VIEW)) { + continue; + } + + if (! $this->capabilityResolver->can($initiator, $tenant, \App\Support\Auth\Capabilities::TENANT_SYNC)) { + $blockedCount++; + $targets[] = [ + 'tenantId' => (int) $tenant->getKey(), + 'runId' => null, + 'launchState' => 'blocked', + 'reasonCode' => 'tenant_sync_required', + ]; + + continue; + } + + $result = $this->startCompareForProfile($profile, $tenant, $initiator); + $run = $result['run'] ?? null; + $reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : null; + + if (! ($result['ok'] ?? false) || ! $run instanceof OperationRun) { + $blockedCount++; + $targets[] = [ + 'tenantId' => (int) $tenant->getKey(), + 'runId' => null, + 'launchState' => 'blocked', + 'reasonCode' => $reasonCode, + ]; + + continue; + } + + if ($run->wasRecentlyCreated) { + $queuedCount++; + $targets[] = [ + 'tenantId' => (int) $tenant->getKey(), + 'runId' => (int) $run->getKey(), + 'launchState' => 'queued', + 'reasonCode' => null, + ]; + + continue; + } + + $alreadyQueuedCount++; + $targets[] = [ + 'tenantId' => (int) $tenant->getKey(), + 'runId' => (int) $run->getKey(), + 'launchState' => 'already_queued', + 'reasonCode' => null, + ]; + } + + return [ + 'baselineProfileId' => (int) $profile->getKey(), + 'visibleAssignedTenantCount' => count($targets), + 'queuedCount' => $queuedCount, + 'alreadyQueuedCount' => $alreadyQueuedCount, + 'blockedCount' => $blockedCount, + 'targets' => $targets, + ]; + } + private function validatePreconditions(BaselineProfile $profile): ?string { if ($profile->status !== BaselineProfileStatus::Active) { diff --git a/apps/platform/app/Support/Badges/BadgeCatalog.php b/apps/platform/app/Support/Badges/BadgeCatalog.php index bbe1a4d0..33e6dc92 100644 --- a/apps/platform/app/Support/Badges/BadgeCatalog.php +++ b/apps/platform/app/Support/Badges/BadgeCatalog.php @@ -66,6 +66,9 @@ final class BadgeCatalog BadgeDomain::SystemHealth->value => Domains\SystemHealthBadge::class, BadgeDomain::ReferenceResolutionState->value => Domains\ReferenceResolutionStateBadge::class, BadgeDomain::DiffRowStatus->value => Domains\DiffRowStatusBadge::class, + BadgeDomain::BaselineCompareMatrixState->value => Domains\BaselineCompareMatrixStateBadge::class, + BadgeDomain::BaselineCompareMatrixFreshness->value => Domains\BaselineCompareMatrixFreshnessBadge::class, + BadgeDomain::BaselineCompareMatrixTrust->value => Domains\BaselineCompareMatrixTrustBadge::class, ]; /** diff --git a/apps/platform/app/Support/Badges/BadgeDomain.php b/apps/platform/app/Support/Badges/BadgeDomain.php index 523d1bcb..efc973b9 100644 --- a/apps/platform/app/Support/Badges/BadgeDomain.php +++ b/apps/platform/app/Support/Badges/BadgeDomain.php @@ -57,4 +57,7 @@ enum BadgeDomain: string case SystemHealth = 'system_health'; case ReferenceResolutionState = 'reference_resolution_state'; case DiffRowStatus = 'diff_row_status'; + case BaselineCompareMatrixState = 'baseline_compare_matrix_state'; + case BaselineCompareMatrixFreshness = 'baseline_compare_matrix_freshness'; + case BaselineCompareMatrixTrust = 'baseline_compare_matrix_trust'; } diff --git a/apps/platform/app/Support/Badges/Domains/BaselineCompareMatrixFreshnessBadge.php b/apps/platform/app/Support/Badges/Domains/BaselineCompareMatrixFreshnessBadge.php new file mode 100644 index 00000000..a65b4546 --- /dev/null +++ b/apps/platform/app/Support/Badges/Domains/BaselineCompareMatrixFreshnessBadge.php @@ -0,0 +1,23 @@ + new BadgeSpec('Current result', 'success', 'heroicon-m-check-badge'), + 'stale' => new BadgeSpec('Refresh recommended', 'warning', 'heroicon-m-arrow-path'), + 'never_compared' => new BadgeSpec('Not compared yet', 'gray', 'heroicon-m-minus-circle'), + 'unknown' => new BadgeSpec('Freshness unknown', 'info', 'heroicon-m-question-mark-circle'), + default => BadgeSpec::unknown(), + }; + } +} diff --git a/apps/platform/app/Support/Badges/Domains/BaselineCompareMatrixStateBadge.php b/apps/platform/app/Support/Badges/Domains/BaselineCompareMatrixStateBadge.php new file mode 100644 index 00000000..81ffe9f7 --- /dev/null +++ b/apps/platform/app/Support/Badges/Domains/BaselineCompareMatrixStateBadge.php @@ -0,0 +1,25 @@ + new BadgeSpec('Reference aligned', 'success', 'heroicon-m-check-circle'), + 'differ' => new BadgeSpec('Drift detected', 'danger', 'heroicon-m-exclamation-triangle'), + 'missing' => new BadgeSpec('Missing from tenant', 'warning', 'heroicon-m-minus-circle'), + 'ambiguous' => new BadgeSpec('Identity ambiguous', 'info', 'heroicon-m-question-mark-circle'), + 'not_compared' => new BadgeSpec('Not compared', 'gray', 'heroicon-m-clock'), + 'stale_result' => new BadgeSpec('Result stale', 'warning', 'heroicon-m-arrow-path'), + default => BadgeSpec::unknown(), + }; + } +} diff --git a/apps/platform/app/Support/Badges/Domains/BaselineCompareMatrixTrustBadge.php b/apps/platform/app/Support/Badges/Domains/BaselineCompareMatrixTrustBadge.php new file mode 100644 index 00000000..9c9ed55a --- /dev/null +++ b/apps/platform/app/Support/Badges/Domains/BaselineCompareMatrixTrustBadge.php @@ -0,0 +1,18 @@ +> + */ + public static function subjectReasonsFromOperationRun(?OperationRun $run): array + { + $details = self::fromOperationRun($run); + $buckets = is_array($details['buckets'] ?? null) ? $details['buckets'] : []; + $reasonMap = []; + + foreach ($buckets as $bucket) { + if (! is_array($bucket)) { + continue; + } + + $reasonCode = self::stringOrNull($bucket['reason_code'] ?? null); + + if ($reasonCode === null) { + continue; + } + + $rows = is_array($bucket['rows'] ?? null) ? $bucket['rows'] : []; + + foreach ($rows as $row) { + if (! is_array($row)) { + continue; + } + + $policyType = self::stringOrNull($row['policy_type'] ?? null); + $subjectKey = self::stringOrNull($row['subject_key'] ?? null); + + if ($policyType === null || $subjectKey === null) { + continue; + } + + $compositeKey = self::subjectCompositeKey($policyType, $subjectKey); + $reasonMap[$compositeKey] ??= []; + $reasonMap[$compositeKey][] = $reasonCode; + } + } + + return array_map( + static fn (array $reasons): array => array_values(array_unique(array_filter($reasons, 'is_string'))), + $reasonMap, + ); + } + + public static function subjectCompositeKey(string $policyType, string $subjectKey): string + { + return trim(mb_strtolower($policyType)).'|'.trim(mb_strtolower($subjectKey)); + } + /** * @param list> $buckets * @return list> diff --git a/apps/platform/app/Support/Baselines/BaselineCompareExplanationRegistry.php b/apps/platform/app/Support/Baselines/BaselineCompareExplanationRegistry.php index 165976f4..8bc3ef80 100644 --- a/apps/platform/app/Support/Baselines/BaselineCompareExplanationRegistry.php +++ b/apps/platform/app/Support/Baselines/BaselineCompareExplanationRegistry.php @@ -4,6 +4,7 @@ namespace App\Support\Baselines; +use App\Models\OperationRun; use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\Ui\OperatorExplanation\CountDescriptor; use App\Support\Ui\OperatorExplanation\ExplanationFamily; @@ -18,6 +19,36 @@ public function __construct( private readonly ReasonPresenter $reasonPresenter, ) {} + public function trustLevelForRun(?OperationRun $run): string + { + if (! $run instanceof OperationRun) { + return TrustworthinessLevel::Unusable->value; + } + + $context = is_array($run->context) ? $run->context : []; + $baselineCompare = is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : []; + $coverage = is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : []; + $evidenceGaps = is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : []; + $reasonCode = is_string($baselineCompare['reason_code'] ?? null) ? trim((string) $baselineCompare['reason_code']) : null; + $proof = is_bool($coverage['proof'] ?? null) ? (bool) $coverage['proof'] : null; + $uncoveredTypes = is_array($coverage['uncovered_types'] ?? null) ? $coverage['uncovered_types'] : []; + $evidenceGapCount = is_numeric($evidenceGaps['count'] ?? null) ? (int) $evidenceGaps['count'] : 0; + + if ($run->status !== 'completed' || $run->outcome === 'failed') { + return TrustworthinessLevel::Unusable->value; + } + + if ($proof === false || $reasonCode !== null) { + return TrustworthinessLevel::DiagnosticOnly->value; + } + + if ($uncoveredTypes !== [] || $evidenceGapCount > 0) { + return TrustworthinessLevel::LimitedConfidence->value; + } + + return TrustworthinessLevel::Trustworthy->value; + } + public function forStats(BaselineCompareStats $stats): OperatorExplanationPattern { $reason = $stats->reasonCode !== null diff --git a/apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php b/apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php new file mode 100644 index 00000000..8d0edc0b --- /dev/null +++ b/apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php @@ -0,0 +1,884 @@ + $filters + * @return array + */ + public function build(BaselineProfile $profile, User $user, array $filters = []): array + { + $normalizedFilters = $this->normalizeFilters($filters); + + $assignments = BaselineTenantAssignment::query() + ->inWorkspace((int) $profile->workspace_id) + ->forBaselineProfile($profile) + ->with('tenant') + ->get(); + + $visibleTenants = $this->visibleTenants($assignments, $user); + $referenceResolution = $this->snapshotTruthResolver->resolveCompareSnapshot($profile); + $referenceSnapshot = $this->resolvedSnapshot($referenceResolution); + $referenceReasonCode = is_string($referenceResolution['reason_code'] ?? null) + ? trim((string) $referenceResolution['reason_code']) + : null; + + $reference = [ + 'workspaceId' => (int) $profile->workspace_id, + 'baselineProfileId' => (int) $profile->getKey(), + 'baselineProfileName' => (string) $profile->name, + 'baselineStatus' => $profile->status instanceof BaselineProfileStatus + ? $profile->status->value + : (string) $profile->status, + 'referenceSnapshotId' => $referenceSnapshot?->getKey(), + 'referenceSnapshotCapturedAt' => $referenceSnapshot?->captured_at?->toIso8601String(), + 'referenceState' => $referenceSnapshot instanceof BaselineSnapshot ? 'ready' : 'no_snapshot', + 'referenceReasonCode' => $referenceReasonCode, + 'assignedTenantCount' => $assignments->count(), + 'visibleTenantCount' => $visibleTenants->count(), + ]; + + $snapshotItems = $referenceSnapshot instanceof BaselineSnapshot + ? BaselineSnapshotItem::query() + ->where('baseline_snapshot_id', (int) $referenceSnapshot->getKey()) + ->orderBy('policy_type') + ->orderBy('subject_key') + ->orderBy('id') + ->get() + : collect(); + + $policyTypeOptions = $snapshotItems + ->pluck('policy_type') + ->filter(static fn (mixed $type): bool => is_string($type) && trim($type) !== '') + ->unique() + ->sort() + ->mapWithKeys(static fn (string $type): array => [ + $type => InventoryPolicyTypeMeta::label($type) ?? $type, + ]) + ->all(); + + $bundle = [ + 'reference' => $reference, + 'filters' => [ + 'policyTypes' => $normalizedFilters['policyTypes'], + 'states' => $normalizedFilters['states'], + 'severities' => $normalizedFilters['severities'], + 'tenantSort' => $normalizedFilters['tenantSort'], + 'subjectSort' => $normalizedFilters['subjectSort'], + 'focusedSubjectKey' => $normalizedFilters['focusedSubjectKey'], + ], + 'policyTypeOptions' => $policyTypeOptions, + 'stateOptions' => BadgeCatalog::options(BadgeDomain::BaselineCompareMatrixState, [ + 'match', + 'differ', + 'missing', + 'ambiguous', + 'not_compared', + 'stale_result', + ]), + 'severityOptions' => BadgeCatalog::options(BadgeDomain::FindingSeverity, [ + Finding::SEVERITY_LOW, + Finding::SEVERITY_MEDIUM, + Finding::SEVERITY_HIGH, + Finding::SEVERITY_CRITICAL, + ]), + 'tenantSortOptions' => [ + 'tenant_name' => 'Tenant name', + 'deviation_count' => 'Deviation count', + 'freshness_urgency' => 'Freshness urgency', + ], + 'subjectSortOptions' => [ + 'deviation_breadth' => 'Deviation breadth', + 'policy_type' => 'Policy type', + 'display_name' => 'Display name', + ], + 'stateLegend' => $this->legendSpecs(BadgeDomain::BaselineCompareMatrixState, [ + 'match', + 'differ', + 'missing', + 'ambiguous', + 'not_compared', + 'stale_result', + ]), + 'freshnessLegend' => $this->legendSpecs(BadgeDomain::BaselineCompareMatrixFreshness, [ + 'fresh', + 'stale', + 'never_compared', + 'unknown', + ]), + 'trustLegend' => $this->legendSpecs(BadgeDomain::BaselineCompareMatrixTrust, [ + 'trustworthy', + 'limited_confidence', + 'diagnostic_only', + 'unusable', + ]), + 'tenantSummaries' => [], + 'subjectSummaries' => [], + 'rows' => [], + 'emptyState' => $this->emptyState( + reference: $reference, + snapshotItemsCount: $snapshotItems->count(), + visibleTenantsCount: $visibleTenants->count(), + ), + 'hasActiveRuns' => false, + ]; + + if (! $referenceSnapshot instanceof BaselineSnapshot) { + return $bundle; + } + + if ($visibleTenants->isEmpty()) { + return $bundle; + } + + if ($snapshotItems->isEmpty()) { + return $bundle; + } + + $tenantIds = $visibleTenants + ->map(static fn (Tenant $tenant): int => (int) $tenant->getKey()) + ->values() + ->all(); + + $latestRuns = OperationRun::latestBaselineCompareRunsForProfile( + profile: $profile, + tenantIds: $tenantIds, + workspaceId: (int) $profile->workspace_id, + )->keyBy(static fn (OperationRun $run): int => (int) $run->tenant_id); + + $completedRuns = OperationRun::latestBaselineCompareRunsForProfile( + profile: $profile, + tenantIds: $tenantIds, + workspaceId: (int) $profile->workspace_id, + completedOnly: true, + )->keyBy(static fn (OperationRun $run): int => (int) $run->tenant_id); + + $findingMap = $this->findingMap($profile, $tenantIds, $completedRuns); + $rows = []; + + foreach ($snapshotItems as $item) { + if (! $item instanceof BaselineSnapshotItem) { + continue; + } + + $subjectKey = is_string($item->subject_key) ? trim($item->subject_key) : ''; + + if ($subjectKey === '') { + continue; + } + + $subject = [ + 'subjectKey' => $subjectKey, + 'policyType' => (string) $item->policy_type, + 'displayName' => $this->subjectDisplayName($item), + 'baselineExternalId' => is_string($item->subject_external_id) ? $item->subject_external_id : null, + ]; + + $cells = []; + + foreach ($visibleTenants as $tenant) { + $tenantId = (int) $tenant->getKey(); + $latestRun = $latestRuns->get($tenantId); + $completedRun = $completedRuns->get($tenantId); + $cells[] = $this->cellFor( + item: $item, + tenant: $tenant, + referenceSnapshot: $referenceSnapshot, + latestRun: $latestRun instanceof OperationRun ? $latestRun : null, + completedRun: $completedRun instanceof OperationRun ? $completedRun : null, + finding: $findingMap[$this->cellKey($tenantId, $subjectKey)] ?? null, + ); + } + + if (! $this->rowMatchesFilters($subject, $cells, $normalizedFilters)) { + continue; + } + + $rows[] = [ + 'subject' => $this->subjectSummary($subject, $cells), + 'cells' => $cells, + ]; + } + + $rows = $this->sortRows($rows, $normalizedFilters['subjectSort']); + $tenantSummaries = $this->sortTenantSummaries( + tenantSummaries: $this->tenantSummaries($visibleTenants, $latestRuns, $completedRuns, $rows, $referenceSnapshot), + sort: $normalizedFilters['tenantSort'], + ); + + foreach ($rows as &$row) { + $row['cells'] = $this->sortCellsForTenants($row['cells'], $tenantSummaries); + } + unset($row); + + $bundle['tenantSummaries'] = $tenantSummaries; + $bundle['subjectSummaries'] = array_map( + static fn (array $row): array => $row['subject'], + $rows, + ); + $bundle['rows'] = $rows; + $bundle['emptyState'] = $this->emptyState( + reference: $reference, + snapshotItemsCount: $snapshotItems->count(), + visibleTenantsCount: $visibleTenants->count(), + renderedRowsCount: count($rows), + ); + $bundle['hasActiveRuns'] = collect($tenantSummaries) + ->contains(static fn (array $summary): bool => in_array((string) ($summary['compareRunStatus'] ?? ''), [ + OperationRunStatus::Queued->value, + OperationRunStatus::Running->value, + ], true)); + + return $bundle; + } + + /** + * @param array $filters + * @return array{ + * policyTypes: list, + * states: list, + * severities: list, + * tenantSort: string, + * subjectSort: string, + * focusedSubjectKey: ?string + * } + */ + private function normalizeFilters(array $filters): array + { + $policyTypes = $this->normalizeStringList($filters['policy_type'] ?? $filters['policyTypes'] ?? []); + $states = array_values(array_intersect( + $this->normalizeStringList($filters['state'] ?? $filters['states'] ?? []), + ['match', 'differ', 'missing', 'ambiguous', 'not_compared', 'stale_result'], + )); + $severities = array_values(array_intersect( + $this->normalizeStringList($filters['severity'] ?? $filters['severities'] ?? []), + [Finding::SEVERITY_LOW, Finding::SEVERITY_MEDIUM, Finding::SEVERITY_HIGH, Finding::SEVERITY_CRITICAL], + )); + $tenantSort = in_array((string) ($filters['tenant_sort'] ?? $filters['tenantSort'] ?? 'tenant_name'), [ + 'tenant_name', + 'deviation_count', + 'freshness_urgency', + ], true) + ? (string) ($filters['tenant_sort'] ?? $filters['tenantSort'] ?? 'tenant_name') + : 'tenant_name'; + $subjectSort = in_array((string) ($filters['subject_sort'] ?? $filters['subjectSort'] ?? 'deviation_breadth'), [ + 'deviation_breadth', + 'policy_type', + 'display_name', + ], true) + ? (string) ($filters['subject_sort'] ?? $filters['subjectSort'] ?? 'deviation_breadth') + : 'deviation_breadth'; + $focusedSubjectKey = $filters['subject_key'] ?? $filters['focusedSubjectKey'] ?? null; + $focusedSubjectKey = is_string($focusedSubjectKey) && trim($focusedSubjectKey) !== '' + ? trim($focusedSubjectKey) + : null; + + return [ + 'policyTypes' => $policyTypes, + 'states' => $states, + 'severities' => $severities, + 'tenantSort' => $tenantSort, + 'subjectSort' => $subjectSort, + 'focusedSubjectKey' => $focusedSubjectKey, + ]; + } + + /** + * @return list + */ + private function normalizeStringList(mixed $value): array + { + $values = is_array($value) ? $value : [$value]; + + return array_values(array_unique(array_filter(array_map(static function (mixed $item): ?string { + if (! is_string($item)) { + return null; + } + + $normalized = trim($item); + + return $normalized !== '' ? $normalized : null; + }, $values)))); + } + + /** + * @param Collection $assignments + * @return Collection + */ + private function visibleTenants(Collection $assignments, User $user): Collection + { + return $assignments + ->map(static fn (BaselineTenantAssignment $assignment): ?Tenant => $assignment->tenant) + ->filter(fn (?Tenant $tenant): bool => $tenant instanceof Tenant + && $this->capabilityResolver->isMember($user, $tenant) + && $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW)) + ->sortBy(static fn (Tenant $tenant): string => Str::lower((string) $tenant->name)) + ->values(); + } + + /** + * @param array $resolution + */ + private function resolvedSnapshot(array $resolution): ?BaselineSnapshot + { + $snapshot = $resolution['effective_snapshot'] ?? $resolution['snapshot'] ?? null; + + return $snapshot instanceof BaselineSnapshot ? $snapshot : null; + } + + /** + * @param array $tenantIds + * @param Collection $completedRuns + * @return array + */ + private function findingMap(BaselineProfile $profile, array $tenantIds, Collection $completedRuns): array + { + $findings = Finding::query() + ->baselineCompareForProfile($profile) + ->whereIn('tenant_id', $tenantIds) + ->orderByDesc('last_seen_at') + ->orderByDesc('id') + ->get(); + + $map = []; + + foreach ($findings as $finding) { + if (! $finding instanceof Finding) { + continue; + } + + $tenantId = (int) $finding->tenant_id; + $subjectKey = $this->subjectKeyForFinding($finding); + + if ($subjectKey === null) { + continue; + } + + $completedRun = $completedRuns->get($tenantId); + + if ( + $completedRun instanceof OperationRun + && (int) ($finding->current_operation_run_id ?? 0) !== (int) $completedRun->getKey() + ) { + continue; + } + + $cellKey = $this->cellKey($tenantId, $subjectKey); + + if (! array_key_exists($cellKey, $map)) { + $map[$cellKey] = $finding; + } + } + + return $map; + } + + private function subjectKeyForFinding(Finding $finding): ?string + { + $subjectKey = data_get($finding->evidence_jsonb, 'subject_key'); + + if (is_string($subjectKey) && trim($subjectKey) !== '') { + return trim($subjectKey); + } + + return null; + } + + private function cellKey(int $tenantId, string $subjectKey): string + { + return $tenantId.'|'.trim(mb_strtolower($subjectKey)); + } + + private function subjectDisplayName(BaselineSnapshotItem $item): ?string + { + $displayName = data_get($item->meta_jsonb, 'display_name'); + + if (is_string($displayName) && trim($displayName) !== '') { + return trim($displayName); + } + + return is_string($item->subject_key) && trim($item->subject_key) !== '' + ? Str::headline($item->subject_key) + : null; + } + + private function cellFor( + BaselineSnapshotItem $item, + Tenant $tenant, + BaselineSnapshot $referenceSnapshot, + ?OperationRun $latestRun, + ?OperationRun $completedRun, + ?Finding $finding, + ): array { + $subjectKey = (string) $item->subject_key; + $policyType = (string) $item->policy_type; + $completedAt = $completedRun?->finished_at; + $policyTypeCovered = $this->policyTypeCovered($completedRun, $policyType); + $subjectReasons = $completedRun instanceof OperationRun + ? (BaselineCompareEvidenceGapDetails::subjectReasonsFromOperationRun($completedRun)[BaselineCompareEvidenceGapDetails::subjectCompositeKey($policyType, $subjectKey)] ?? []) + : []; + $reasonCode = $subjectReasons[0] ?? $this->runReasonCode($completedRun); + $changeType = is_string(data_get($finding?->evidence_jsonb, 'change_type')) ? (string) data_get($finding?->evidence_jsonb, 'change_type') : null; + $staleResult = $this->isStaleResult($completedRun, $referenceSnapshot); + $tenantTrustLevel = $this->tenantTrustLevel($completedRun); + + $state = match (true) { + ! $completedRun instanceof OperationRun => 'not_compared', + (string) $completedRun->outcome === OperationRunOutcome::Failed->value => 'not_compared', + ! $policyTypeCovered => 'not_compared', + $staleResult => 'stale_result', + $subjectReasons !== [] => 'ambiguous', + $changeType === 'missing_policy' => 'missing', + $finding instanceof Finding => 'differ', + default => 'match', + }; + + $trustLevel = match ($state) { + 'not_compared' => 'unusable', + 'stale_result' => 'limited_confidence', + 'ambiguous' => 'diagnostic_only', + default => $tenantTrustLevel, + }; + + return [ + 'tenantId' => (int) $tenant->getKey(), + 'subjectKey' => $subjectKey, + 'state' => $state, + 'severity' => $finding instanceof Finding ? (string) $finding->severity : null, + 'trustLevel' => $trustLevel, + 'reasonCode' => $reasonCode, + 'compareRunId' => $completedRun?->getKey(), + 'findingId' => $finding?->getKey(), + 'findingWorkflowState' => $finding instanceof Finding ? (string) $finding->status : null, + 'lastComparedAt' => $completedAt?->toIso8601String(), + 'policyTypeCovered' => $policyTypeCovered, + 'latestRunId' => $latestRun?->getKey(), + 'latestRunStatus' => $latestRun?->status, + ]; + } + + private function policyTypeCovered(?OperationRun $run, string $policyType): bool + { + if (! $run instanceof OperationRun) { + return false; + } + + $coverage = data_get($run->context, 'baseline_compare.coverage'); + + if (! is_array($coverage)) { + return true; + } + + $coveredTypes = is_array($coverage['covered_types'] ?? null) + ? array_values(array_filter($coverage['covered_types'], 'is_string')) + : []; + $uncoveredTypes = is_array($coverage['uncovered_types'] ?? null) + ? array_values(array_filter($coverage['uncovered_types'], 'is_string')) + : []; + + if (in_array($policyType, $uncoveredTypes, true)) { + return false; + } + + if ($coveredTypes === []) { + return true; + } + + return in_array($policyType, $coveredTypes, true); + } + + private function runReasonCode(?OperationRun $run): ?string + { + $reasonCode = data_get($run?->context, 'baseline_compare.reason_code'); + + return is_string($reasonCode) && trim($reasonCode) !== '' + ? trim($reasonCode) + : null; + } + + private function isStaleResult(?OperationRun $run, BaselineSnapshot $referenceSnapshot): bool + { + if (! $run instanceof OperationRun || ! $run->finished_at) { + return false; + } + + $runSnapshotId = data_get($run->context, 'baseline_snapshot_id'); + + if (is_numeric($runSnapshotId) && (int) $runSnapshotId !== (int) $referenceSnapshot->getKey()) { + return true; + } + + if ($referenceSnapshot->captured_at && $run->finished_at->lt($referenceSnapshot->captured_at)) { + return true; + } + + return BaselineCompareSummaryAssessor::isStaleComparedAt($run->finished_at); + } + + private function tenantTrustLevel(?OperationRun $run): string + { + return BadgeCatalog::normalizeState( + $this->explanationRegistry->trustLevelForRun($run), + ) ?? 'unusable'; + } + + /** + * @param array $subject + * @param list> $cells + * @param array $filters + */ + private function rowMatchesFilters(array $subject, array $cells, array $filters): bool + { + if ($filters['policyTypes'] !== [] && ! in_array((string) $subject['policyType'], $filters['policyTypes'], true)) { + return false; + } + + if ($filters['focusedSubjectKey'] !== null && (string) $subject['subjectKey'] !== $filters['focusedSubjectKey']) { + return false; + } + + foreach ($cells as $cell) { + if ($filters['states'] !== [] && ! in_array((string) ($cell['state'] ?? ''), $filters['states'], true)) { + continue; + } + + if ($filters['severities'] !== [] && ! in_array((string) ($cell['severity'] ?? ''), $filters['severities'], true)) { + continue; + } + + return true; + } + + return $filters['states'] === [] && $filters['severities'] === []; + } + + /** + * @param list> $cells + * @return array + */ + private function subjectSummary(array $subject, array $cells): array + { + return [ + 'subjectKey' => $subject['subjectKey'], + 'policyType' => $subject['policyType'], + 'displayName' => $subject['displayName'], + 'baselineExternalId' => $subject['baselineExternalId'], + 'deviationBreadth' => $this->countStates($cells, ['differ', 'missing']), + 'missingBreadth' => $this->countStates($cells, ['missing']), + 'ambiguousBreadth' => $this->countStates($cells, ['ambiguous']), + 'notComparedBreadth' => $this->countStates($cells, ['not_compared']), + 'maxSeverity' => $this->maxSeverity($cells), + 'trustLevel' => $this->worstTrustLevel($cells), + ]; + } + + /** + * @param Collection $visibleTenants + * @param Collection $latestRuns + * @param Collection $completedRuns + * @param list> $rows + * @return list> + */ + private function tenantSummaries( + Collection $visibleTenants, + Collection $latestRuns, + Collection $completedRuns, + array $rows, + BaselineSnapshot $referenceSnapshot, + ): array { + $summaries = []; + + foreach ($visibleTenants as $tenant) { + $tenantId = (int) $tenant->getKey(); + $latestRun = $latestRuns->get($tenantId); + $completedRun = $completedRuns->get($tenantId); + $cells = array_map( + static fn (array $row): array => collect($row['cells'])->firstWhere('tenantId', $tenantId) ?? [], + $rows, + ); + + $summaries[] = [ + 'tenantId' => $tenantId, + 'tenantName' => (string) $tenant->name, + 'compareRunId' => $latestRun?->getKey(), + 'compareRunStatus' => $latestRun?->status, + 'compareRunOutcome' => $latestRun?->outcome, + 'freshnessState' => $this->freshnessState($completedRun, $referenceSnapshot), + 'lastComparedAt' => $completedRun?->finished_at?->toIso8601String(), + 'matchedCount' => $this->countStates($cells, ['match']), + 'differingCount' => $this->countStates($cells, ['differ']), + 'missingCount' => $this->countStates($cells, ['missing']), + 'ambiguousCount' => $this->countStates($cells, ['ambiguous']), + 'notComparedCount' => $this->countStates($cells, ['not_compared']), + 'maxSeverity' => $this->maxSeverity($cells), + 'trustLevel' => $this->worstTrustLevel($cells), + ]; + } + + return $summaries; + } + + private function freshnessState(?OperationRun $completedRun, BaselineSnapshot $referenceSnapshot): string + { + if (! $completedRun instanceof OperationRun) { + return 'never_compared'; + } + + if ((string) $completedRun->outcome === OperationRunOutcome::Failed->value) { + return 'unknown'; + } + + if ($this->isStaleResult($completedRun, $referenceSnapshot)) { + return 'stale'; + } + + return 'fresh'; + } + + /** + * @param list> $cells + * @param array $states + */ + private function countStates(array $cells, array $states): int + { + return count(array_filter( + $cells, + static fn (array $cell): bool => in_array((string) ($cell['state'] ?? ''), $states, true), + )); + } + + /** + * @param list> $cells + */ + private function maxSeverity(array $cells): ?string + { + $ranked = collect($cells) + ->map(static fn (array $cell): ?string => is_string($cell['severity'] ?? null) ? (string) $cell['severity'] : null) + ->filter() + ->sortByDesc(fn (string $severity): int => $this->severityRank($severity)) + ->values(); + + return $ranked->first(); + } + + private function severityRank(string $severity): int + { + return match ($severity) { + Finding::SEVERITY_CRITICAL => 4, + Finding::SEVERITY_HIGH => 3, + Finding::SEVERITY_MEDIUM => 2, + Finding::SEVERITY_LOW => 1, + default => 0, + }; + } + + /** + * @param list> $cells + */ + private function worstTrustLevel(array $cells): string + { + return collect($cells) + ->map(static fn (array $cell): string => (string) ($cell['trustLevel'] ?? 'unusable')) + ->sortByDesc(fn (string $trust): int => $this->trustRank($trust)) + ->first() ?? 'unusable'; + } + + private function trustRank(string $trustLevel): int + { + return match ($trustLevel) { + 'unusable' => 4, + 'diagnostic_only' => 3, + 'limited_confidence' => 2, + 'trustworthy' => 1, + default => 0, + }; + } + + /** + * @param list> $rows + * @return list> + */ + private function sortRows(array $rows, string $sort): array + { + usort($rows, function (array $left, array $right) use ($sort): int { + $leftSubject = $left['subject'] ?? []; + $rightSubject = $right['subject'] ?? []; + + return match ($sort) { + 'policy_type' => [(string) ($leftSubject['policyType'] ?? ''), Str::lower((string) ($leftSubject['displayName'] ?? ''))] + <=> [(string) ($rightSubject['policyType'] ?? ''), Str::lower((string) ($rightSubject['displayName'] ?? ''))], + 'display_name' => [Str::lower((string) ($leftSubject['displayName'] ?? '')), (string) ($leftSubject['subjectKey'] ?? '')] + <=> [Str::lower((string) ($rightSubject['displayName'] ?? '')), (string) ($rightSubject['subjectKey'] ?? '')], + default => [ + -1 * (int) ($leftSubject['deviationBreadth'] ?? 0), + -1 * (int) ($leftSubject['ambiguousBreadth'] ?? 0), + Str::lower((string) ($leftSubject['displayName'] ?? '')), + ] <=> [ + -1 * (int) ($rightSubject['deviationBreadth'] ?? 0), + -1 * (int) ($rightSubject['ambiguousBreadth'] ?? 0), + Str::lower((string) ($rightSubject['displayName'] ?? '')), + ], + }; + }); + + return array_values($rows); + } + + /** + * @param list> $tenantSummaries + * @return list> + */ + private function sortTenantSummaries(array $tenantSummaries, string $sort): array + { + usort($tenantSummaries, function (array $left, array $right) use ($sort): int { + return match ($sort) { + 'deviation_count' => [ + -1 * ((int) ($left['differingCount'] ?? 0) + (int) ($left['missingCount'] ?? 0) + (int) ($left['ambiguousCount'] ?? 0)), + Str::lower((string) ($left['tenantName'] ?? '')), + ] <=> [ + -1 * ((int) ($right['differingCount'] ?? 0) + (int) ($right['missingCount'] ?? 0) + (int) ($right['ambiguousCount'] ?? 0)), + Str::lower((string) ($right['tenantName'] ?? '')), + ], + 'freshness_urgency' => [ + -1 * $this->freshnessRank((string) ($left['freshnessState'] ?? 'unknown')), + Str::lower((string) ($left['tenantName'] ?? '')), + ] <=> [ + -1 * $this->freshnessRank((string) ($right['freshnessState'] ?? 'unknown')), + Str::lower((string) ($right['tenantName'] ?? '')), + ], + default => Str::lower((string) ($left['tenantName'] ?? '')) <=> Str::lower((string) ($right['tenantName'] ?? '')), + }; + }); + + return array_values($tenantSummaries); + } + + private function freshnessRank(string $freshnessState): int + { + return match ($freshnessState) { + 'stale' => 4, + 'unknown' => 3, + 'never_compared' => 2, + 'fresh' => 1, + default => 0, + }; + } + + /** + * @param list> $cells + * @param list> $tenantSummaries + * @return list> + */ + private function sortCellsForTenants(array $cells, array $tenantSummaries): array + { + $order = collect($tenantSummaries) + ->values() + ->mapWithKeys(static fn (array $summary, int $index): array => [ + (int) ($summary['tenantId'] ?? 0) => $index, + ]); + + usort($cells, static fn (array $left, array $right): int => ($order[(int) ($left['tenantId'] ?? 0)] ?? 9999) <=> ($order[(int) ($right['tenantId'] ?? 0)] ?? 9999)); + + return array_values($cells); + } + + /** + * @param array $reference + * @return array{title: string, body: string}|null + */ + private function emptyState( + array $reference, + int $snapshotItemsCount, + int $visibleTenantsCount, + int $renderedRowsCount = 0, + ): ?array { + if (($reference['referenceState'] ?? null) !== 'ready') { + return [ + 'title' => 'No usable reference snapshot', + 'body' => 'Capture a complete baseline snapshot before using the compare matrix.', + ]; + } + + if ((int) ($reference['assignedTenantCount'] ?? 0) === 0) { + return [ + 'title' => 'No assigned tenants', + 'body' => 'Assign tenants to this baseline profile to build the visible compare set.', + ]; + } + + if ($visibleTenantsCount === 0) { + return [ + 'title' => 'No visible assigned tenants', + 'body' => 'This baseline has assigned tenants, but none are visible in your current tenant scope.', + ]; + } + + if ($snapshotItemsCount === 0) { + return [ + 'title' => 'No baseline subjects', + 'body' => 'The active reference snapshot completed without any baseline subjects to compare.', + ]; + } + + if ($renderedRowsCount === 0) { + return [ + 'title' => 'No rows match the current filters', + 'body' => 'Adjust the policy type, state, or severity filters to broaden the matrix view.', + ]; + } + + return null; + } + + /** + * @param array $values + * @return list> + */ + private function legendSpecs(BadgeDomain $domain, array $values): array + { + return array_map( + static function (string $value) use ($domain): array { + $spec = BadgeCatalog::spec($domain, $value); + + return [ + 'value' => $value, + 'label' => $spec->label, + 'color' => $spec->color, + 'icon' => $spec->icon, + 'iconColor' => $spec->iconColor, + ]; + }, + $values, + ); + } +} diff --git a/apps/platform/app/Support/Baselines/BaselineCompareSummaryAssessor.php b/apps/platform/app/Support/Baselines/BaselineCompareSummaryAssessor.php index ea0deb74..4fa87508 100644 --- a/apps/platform/app/Support/Baselines/BaselineCompareSummaryAssessor.php +++ b/apps/platform/app/Support/Baselines/BaselineCompareSummaryAssessor.php @@ -12,6 +12,28 @@ final class BaselineCompareSummaryAssessor { private const int STALE_AFTER_DAYS = 7; + public static function staleAfterDays(): int + { + return self::STALE_AFTER_DAYS; + } + + public static function isStaleComparedAt(\DateTimeInterface|string|null $value): bool + { + if ($value === null) { + return false; + } + + try { + $comparedAt = $value instanceof \DateTimeInterface + ? CarbonImmutable::instance(\DateTimeImmutable::createFromInterface($value)) + : CarbonImmutable::parse($value); + } catch (\Throwable) { + return false; + } + + return $comparedAt->lt(now()->subDays(self::STALE_AFTER_DAYS)); + } + public function assess(BaselineCompareStats $stats): BaselineCompareSummaryAssessment { $explanation = $stats->operatorExplanation(); @@ -376,12 +398,6 @@ private function hasStaleResult(BaselineCompareStats $stats, string $evaluationR return false; } - try { - $lastComparedAt = CarbonImmutable::parse($stats->lastComparedIso); - } catch (\Throwable) { - return false; - } - - return $lastComparedAt->lt(now()->subDays(self::STALE_AFTER_DAYS)); + return self::isStaleComparedAt($stats->lastComparedIso); } } diff --git a/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php b/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php index cd883229..995cce68 100644 --- a/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php +++ b/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php @@ -4,6 +4,9 @@ namespace App\Support\Navigation; +use App\Filament\Pages\BaselineCompareMatrix; +use App\Models\BaselineProfile; +use App\Models\Tenant; use Illuminate\Http\Request; final readonly class CanonicalNavigationContext @@ -63,4 +66,31 @@ public function toQuery(): array return $query; } + + /** + * @param array $filters + */ + public static function forBaselineCompareMatrix( + BaselineProfile $profile, + array $filters = [], + ?Tenant $tenant = null, + ?string $subjectKey = null, + ): self { + $parameters = array_filter([ + 'record' => $profile, + ...$filters, + ], static fn (mixed $value): bool => $value !== null && $value !== [] && $value !== ''); + + return new self( + sourceSurface: 'baseline_compare_matrix', + canonicalRouteName: BaselineCompareMatrix::getRouteName(), + tenantId: $tenant?->getKey(), + backLinkLabel: 'Back to compare matrix', + backLinkUrl: BaselineCompareMatrix::getUrl($parameters, panel: 'admin'), + filterPayload: array_filter([ + 'baseline_profile_id' => (int) $profile->getKey(), + 'subject_key' => $subjectKey, + ], static fn (mixed $value): bool => $value !== null && $value !== ''), + ); + } } diff --git a/apps/platform/app/Support/Rbac/WorkspaceUiEnforcement.php b/apps/platform/app/Support/Rbac/WorkspaceUiEnforcement.php index 2696de2d..8c3415eb 100644 --- a/apps/platform/app/Support/Rbac/WorkspaceUiEnforcement.php +++ b/apps/platform/app/Support/Rbac/WorkspaceUiEnforcement.php @@ -12,6 +12,7 @@ use Closure; use Filament\Actions\Action; use Illuminate\Database\Eloquent\Model; +use ReflectionObject; use Throwable; /** @@ -39,6 +40,10 @@ final class WorkspaceUiEnforcement private Model|Closure|null $record = null; + private bool $preserveExistingVisibility = false; + + private bool $preserveExistingDisabled = false; + private function __construct(Action $action) { $this->action = $action; @@ -58,6 +63,14 @@ public static function forTableAction(Action $action, Model|Closure $record): se return $instance; } + public static function forAction(Action $action, Model|Closure|null $record = null): self + { + $instance = new self($action); + $instance->record = $record; + + return $instance; + } + public function requireMembership(bool $require = true): self { $this->requireMembership = $require; @@ -95,6 +108,20 @@ public function tooltip(string $message): self return $this; } + public function preserveVisibility(): self + { + $this->preserveExistingVisibility = true; + + return $this; + } + + public function preserveDisabled(): self + { + $this->preserveExistingDisabled = true; + + return $this; + } + public function apply(): Action { $this->applyVisibility(); @@ -111,10 +138,22 @@ private function applyVisibility(): void return; } - $this->action->visible(function (?Model $record = null): bool { + $existingVisibility = $this->preserveExistingVisibility + ? $this->getExistingVisibilityCondition() + : null; + + $this->action->visible(function (?Model $record = null) use ($existingVisibility): bool { $context = $this->resolveContextWithRecord($record); - return $context->isMember; + if (! $context->isMember) { + return false; + } + + if ($existingVisibility === null) { + return true; + } + + return $this->evaluateVisibilityCondition($existingVisibility, $record); }); } @@ -126,7 +165,15 @@ private function applyDisabledState(): void $tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission(); - $this->action->disabled(function (?Model $record = null): bool { + $existingDisabled = $this->preserveExistingDisabled + ? $this->getExistingDisabledCondition() + : null; + + $this->action->disabled(function (?Model $record = null) use ($existingDisabled): bool { + if ($existingDisabled !== null && $this->evaluateDisabledCondition($existingDisabled, $record)) { + return true; + } + $context = $this->resolveContextWithRecord($record); if (! $context->isMember) { @@ -173,6 +220,96 @@ private function applyServerSideGuard(): void }); } + private function getExistingVisibilityCondition(): bool|Closure|null + { + try { + $ref = new ReflectionObject($this->action); + + if (! $ref->hasProperty('isVisible')) { + return null; + } + + $property = $ref->getProperty('isVisible'); + $property->setAccessible(true); + + /** @var bool|Closure $value */ + $value = $property->getValue($this->action); + + return $value; + } catch (Throwable) { + return null; + } + } + + private function evaluateVisibilityCondition(bool|Closure $condition, ?Model $record): bool + { + if (is_bool($condition)) { + return $condition; + } + + try { + $reflection = new \ReflectionFunction($condition); + $parameters = $reflection->getParameters(); + + if ($parameters === []) { + return (bool) $condition(); + } + + if ($record === null) { + return false; + } + + return (bool) $condition($record); + } catch (Throwable) { + return false; + } + } + + private function getExistingDisabledCondition(): bool|Closure|null + { + try { + $ref = new ReflectionObject($this->action); + + if (! $ref->hasProperty('isDisabled')) { + return null; + } + + $property = $ref->getProperty('isDisabled'); + $property->setAccessible(true); + + /** @var bool|Closure $value */ + $value = $property->getValue($this->action); + + return $value; + } catch (Throwable) { + return null; + } + } + + private function evaluateDisabledCondition(bool|Closure $condition, ?Model $record): bool + { + if (is_bool($condition)) { + return $condition; + } + + try { + $reflection = new \ReflectionFunction($condition); + $parameters = $reflection->getParameters(); + + if ($parameters === []) { + return (bool) $condition(); + } + + if ($record === null) { + return true; + } + + return (bool) $condition($record); + } catch (Throwable) { + return true; + } + } + private function resolveContextWithRecord(?Model $record = null): WorkspaceAccessContext { $user = auth()->user(); diff --git a/apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceDiscovery.php b/apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceDiscovery.php index 9dc25596..a1e30e28 100644 --- a/apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceDiscovery.php +++ b/apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceDiscovery.php @@ -6,10 +6,12 @@ use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType; use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope; +use ReflectionClass; use Filament\Tables\Contracts\HasTable; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use SplFileInfo; +use Throwable; final class ActionSurfaceDiscovery { @@ -100,7 +102,10 @@ private function panelScopesFor(string $className, array $adminScopedClasses): a { $scopes = [ActionSurfacePanelScope::Tenant]; - if (in_array($className, $adminScopedClasses, true)) { + if ( + in_array($className, $adminScopedClasses, true) + || $this->inheritsAdminScopeFromResource($className, $adminScopedClasses) + ) { $scopes[] = ActionSurfacePanelScope::Admin; } @@ -228,6 +233,37 @@ private function isDeclaredSystemTablePage(string $className): bool && method_exists($className, 'actionSurfaceDeclaration'); } + /** + * Resource-owned Filament pages can live under app/Filament/Pages and be routed + * from the resource instead of being panel-registered directly. When that happens, + * inherit admin scope from the owning resource so discovery stays truthful. + * + * @param array $adminScopedClasses + */ + private function inheritsAdminScopeFromResource(string $className, array $adminScopedClasses): bool + { + if (! class_exists($className)) { + return false; + } + + try { + $reflection = new ReflectionClass($className); + + if (! $reflection->hasProperty('resource')) { + return false; + } + + $defaults = $reflection->getDefaultProperties(); + $resourceClass = $defaults['resource'] ?? null; + + return is_string($resourceClass) + && $resourceClass !== '' + && in_array($resourceClass, $adminScopedClasses, true); + } catch (Throwable) { + return false; + } + } + /** * @return array */ diff --git a/apps/platform/resources/views/filament/pages/baseline-compare-landing.blade.php b/apps/platform/resources/views/filament/pages/baseline-compare-landing.blade.php index 1799d5a8..362fcf70 100644 --- a/apps/platform/resources/views/filament/pages/baseline-compare-landing.blade.php +++ b/apps/platform/resources/views/filament/pages/baseline-compare-landing.blade.php @@ -9,6 +9,8 @@ $duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0); $explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null; $summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null; + $matrixNavigationContext = is_array($navigationContext ?? null) ? $navigationContext : null; + $arrivedFromCompareMatrix = str_starts_with((string) ($matrixNavigationContext['source_surface'] ?? ''), 'baseline_compare_matrix'); $explanationCounts = collect(is_array($explanation['countDescriptors'] ?? null) ? $explanation['countDescriptors'] : []); $evaluationSpec = is_string($explanation['evaluationResult'] ?? null) ? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationEvaluationResult, $explanation['evaluationResult']) @@ -26,6 +28,28 @@ }; @endphp + @if ($arrivedFromCompareMatrix) + +
+ + Arrived from compare matrix + + + @if ($matrixBaselineProfileId) + + Baseline profile #{{ (int) $matrixBaselineProfileId }} + + @endif + + @if (filled($matrixSubjectKey)) + + Subject {{ $matrixSubjectKey }} + + @endif +
+
+ @endif + @if ($duplicateNamePoliciesCountValue > 0)