diff --git a/app/Console/Commands/PruneBaselineEvidencePolicyVersionsCommand.php b/app/Console/Commands/PruneBaselineEvidencePolicyVersionsCommand.php new file mode 100644 index 0000000..0a3a58b --- /dev/null +++ b/app/Console/Commands/PruneBaselineEvidencePolicyVersionsCommand.php @@ -0,0 +1,48 @@ +option('days') ?: config('tenantpilot.baselines.full_content_capture.retention_days', 90)); + + if ($days < 1) { + $this->error('Retention days must be at least 1.'); + + return self::FAILURE; + } + + $cutoff = now()->subDays($days); + + $deleted = PolicyVersion::query() + ->whereNull('deleted_at') + ->whereIn('capture_purpose', [ + PolicyVersionCapturePurpose::BaselineCapture->value, + PolicyVersionCapturePurpose::BaselineCompare->value, + ]) + ->where('captured_at', '<', $cutoff) + ->delete(); + + $this->info("Pruned {$deleted} baseline evidence policy version(s) older than {$days} days."); + + return self::SUCCESS; + } +} diff --git a/app/Filament/Pages/BaselineCompareLanding.php b/app/Filament/Pages/BaselineCompareLanding.php index c2360e8..1c80b02 100644 --- a/app/Filament/Pages/BaselineCompareLanding.php +++ b/app/Filament/Pages/BaselineCompareLanding.php @@ -44,12 +44,18 @@ class BaselineCompareLanding extends Page public ?string $message = null; + public ?string $reasonCode = null; + + public ?string $reasonMessage = null; + public ?string $profileName = null; public ?int $profileId = null; public ?int $snapshotId = null; + public ?int $duplicateNamePoliciesCount = null; + public ?int $operationRunId = null; public ?int $findingsCount = null; @@ -110,12 +116,15 @@ public function refreshStats(): void $this->profileName = $stats->profileName; $this->profileId = $stats->profileId; $this->snapshotId = $stats->snapshotId; + $this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount; $this->operationRunId = $stats->operationRunId; $this->findingsCount = $stats->findingsCount; $this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null; $this->lastComparedAt = $stats->lastComparedHuman; $this->lastComparedIso = $stats->lastComparedIso; $this->failureReason = $stats->failureReason; + $this->reasonCode = $stats->reasonCode; + $this->reasonMessage = $stats->reasonMessage; $this->coverageStatus = $stats->coverageStatus; $this->uncoveredTypesCount = $stats->uncoveredTypesCount; @@ -126,6 +135,101 @@ public function refreshStats(): void $this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null; } + /** + * Computed view data exposed to the Blade template. + * + * Moves presentational logic out of Blade `@php` blocks so the + * template only receives ready-to-render values. + * + * @return array + */ + protected function getViewData(): array + { + $hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true); + $evidenceGapsCountValue = (int) ($this->evidenceGapsCount ?? 0); + $hasEvidenceGaps = $evidenceGapsCountValue > 0; + $hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps; + + $evidenceGapsSummary = null; + $evidenceGapsTooltip = null; + + if ($hasEvidenceGaps && is_array($this->evidenceGapsTopReasons) && $this->evidenceGapsTopReasons !== []) { + $parts = []; + + foreach (array_slice($this->evidenceGapsTopReasons, 0, 5, true) as $reason => $count) { + if (! is_string($reason) || $reason === '' || ! is_numeric($count)) { + continue; + } + + $parts[] = $reason.' ('.((int) $count).')'; + } + + if ($parts !== []) { + $evidenceGapsSummary = implode(', ', $parts); + $evidenceGapsTooltip = __('baseline-compare.evidence_gaps_tooltip', ['summary' => $evidenceGapsSummary]); + } + } + + // Derive the colour class for the findings-count stat card. + // Only show danger-red when high-severity findings exist; + // use warning-orange for low/medium-only, and success-green for zero. + $findingsColorClass = $this->resolveFindingsColorClass($hasWarnings); + + // "Why no findings" explanation when count is zero. + $whyNoFindingsMessage = filled($this->reasonMessage) ? (string) $this->reasonMessage : null; + $whyNoFindingsFallback = ! $hasWarnings + ? __('baseline-compare.no_findings_all_clear') + : ($hasCoverageWarnings + ? __('baseline-compare.no_findings_coverage_warnings') + : ($hasEvidenceGaps + ? __('baseline-compare.no_findings_evidence_gaps') + : __('baseline-compare.no_findings_default'))); + $whyNoFindingsColor = $hasWarnings + ? 'text-warning-600 dark:text-warning-400' + : 'text-success-600 dark:text-success-400'; + + if ($this->reasonCode === 'no_subjects_in_scope') { + $whyNoFindingsColor = 'text-gray-600 dark:text-gray-400'; + } + + return [ + 'hasCoverageWarnings' => $hasCoverageWarnings, + 'evidenceGapsCountValue' => $evidenceGapsCountValue, + 'hasEvidenceGaps' => $hasEvidenceGaps, + 'hasWarnings' => $hasWarnings, + 'evidenceGapsSummary' => $evidenceGapsSummary, + 'evidenceGapsTooltip' => $evidenceGapsTooltip, + 'findingsColorClass' => $findingsColorClass, + 'whyNoFindingsMessage' => $whyNoFindingsMessage, + 'whyNoFindingsFallback' => $whyNoFindingsFallback, + 'whyNoFindingsColor' => $whyNoFindingsColor, + ]; + } + + /** + * Resolve the Tailwind colour class for the Total Findings stat. + * + * - Red (danger) only when high-severity findings exist + * - Orange (warning) for medium/low-only findings or when warnings present + * - Green (success) when fully clear + */ + private function resolveFindingsColorClass(bool $hasWarnings): string + { + $count = (int) ($this->findingsCount ?? 0); + + if ($count === 0) { + return $hasWarnings + ? 'text-warning-600 dark:text-warning-400' + : 'text-success-600 dark:text-success-400'; + } + + $hasHigh = ($this->severityCounts['high'] ?? 0) > 0; + + return $hasHigh + ? 'text-danger-600 dark:text-danger-400' + : 'text-warning-600 dark:text-warning-400'; + } + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) diff --git a/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php b/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php index ce7c3aa..a083156 100644 --- a/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +++ b/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php @@ -9,10 +9,16 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Auth\CapabilityResolver; +use App\Services\Auth\WorkspaceCapabilityResolver; +use App\Services\Baselines\BaselineEvidenceCaptureResumeService; +use App\Support\Auth\Capabilities; use App\Support\OperateHub\OperateHubShell; use App\Support\OperationRunLinks; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; use Filament\Actions\Action; use Filament\Actions\ActionGroup; +use Filament\Notifications\Notification; use Filament\Pages\Page; use Filament\Schemas\Components\EmbeddedSchema; use Filament\Schemas\Schema; @@ -105,6 +111,8 @@ protected function getHeaderActions(): array ->color('gray'); } + $actions[] = $this->resumeCaptureAction(); + return $actions; } @@ -139,4 +147,120 @@ public function content(Schema $schema): Schema EmbeddedSchema::make('infolist'), ]); } + + private function resumeCaptureAction(): Action + { + return Action::make('resumeCapture') + ->label('Resume capture') + ->icon('heroicon-o-forward') + ->requiresConfirmation() + ->modalHeading('Resume capture') + ->modalDescription('This will start a follow-up operation to capture remaining baseline evidence for this scope.') + ->visible(fn (): bool => $this->canResumeCapture()) + ->action(function (): void { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + if (! isset($this->run)) { + Notification::make() + ->title('Run not loaded') + ->danger() + ->send(); + + return; + } + + $service = app(BaselineEvidenceCaptureResumeService::class); + $result = $service->resume($this->run, $user); + + if (! ($result['ok'] ?? false)) { + $reason = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown'; + + Notification::make() + ->title('Cannot resume capture') + ->body('Reason: '.str_replace('.', ' ', $reason)) + ->danger() + ->send(); + + return; + } + + $run = $result['run'] ?? null; + + if (! $run instanceof OperationRun) { + Notification::make() + ->title('Cannot resume capture') + ->body('Reason: missing operation run') + ->danger() + ->send(); + + return; + } + + $viewAction = Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::tenantlessView($run)); + + if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) { + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::alreadyQueuedToast((string) $run->type) + ->actions([$viewAction]) + ->send(); + + return; + } + + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::queuedToast((string) $run->type) + ->actions([$viewAction]) + ->send(); + }); + } + + private function canResumeCapture(): bool + { + if (! isset($this->run)) { + return false; + } + + if ((string) $this->run->status !== 'completed') { + return false; + } + + if (! in_array((string) $this->run->type, ['baseline_capture', 'baseline_compare'], true)) { + return false; + } + + $context = is_array($this->run->context) ? $this->run->context : []; + $tokenKey = (string) $this->run->type === 'baseline_capture' + ? 'baseline_capture.resume_token' + : 'baseline_compare.resume_token'; + $token = data_get($context, $tokenKey); + + if (! is_string($token) || trim($token) === '') { + return false; + } + + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $workspace = $this->run->workspace; + + if (! $workspace instanceof \App\Models\Workspace) { + return false; + } + + $resolver = app(WorkspaceCapabilityResolver::class); + + return $resolver->isMember($user, $workspace) + && $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE); + } } diff --git a/app/Filament/Resources/BaselineSnapshotResource.php b/app/Filament/Resources/BaselineSnapshotResource.php new file mode 100644 index 0000000..56f2e43 --- /dev/null +++ b/app/Filament/Resources/BaselineSnapshotResource.php @@ -0,0 +1,275 @@ +getId() !== 'admin') { + return false; + } + + return parent::shouldRegisterNavigation(); + } + + public static function canViewAny(): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $workspace = self::resolveWorkspace(); + + if (! $workspace instanceof Workspace) { + return false; + } + + $resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class); + + return $resolver->isMember($user, $workspace) + && $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW); + } + + public static function canCreate(): bool + { + return false; + } + + public static function canEdit(Model $record): bool + { + return false; + } + + public static function canDelete(Model $record): bool + { + return false; + } + + public static function canView(Model $record): bool + { + return self::canViewAny(); + } + + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) + ->exempt(ActionSurfaceSlot::ListHeader, 'Snapshots are created by capture runs; no list-header actions.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Snapshots are immutable; no row actions besides view.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Snapshots are immutable; no bulk actions.') + ->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; snapshots appear after baseline captures.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.'); + } + + public static function getEloquentQuery(): Builder + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + return parent::getEloquentQuery() + ->with('baselineProfile') + ->when( + $workspaceId !== null, + fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId), + ) + ->when( + $workspaceId === null, + fn (Builder $query): Builder => $query->whereRaw('1 = 0'), + ); + } + + public static function form(Schema $schema): Schema + { + return $schema; + } + + public static function table(Table $table): Table + { + return $table + ->defaultSort('captured_at', 'desc') + ->columns([ + TextColumn::make('id') + ->label('Snapshot') + ->formatStateUsing(static fn (?int $state): string => $state ? '#'.$state : '—') + ->sortable(), + TextColumn::make('baselineProfile.name') + ->label('Baseline') + ->wrap() + ->placeholder('—'), + TextColumn::make('captured_at') + ->label('Captured') + ->since() + ->sortable(), + TextColumn::make('fidelity_summary') + ->label('Fidelity') + ->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record)) + ->wrap(), + TextColumn::make('snapshot_state') + ->label('State') + ->badge() + ->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record)) + ->color(static fn (BaselineSnapshot $record): string => self::hasGaps($record) ? 'warning' : 'success'), + ]) + ->actions([ + ViewAction::make()->label('View'), + ]) + ->bulkActions([]); + } + + public static function infolist(Schema $schema): Schema + { + return $schema + ->schema([ + Section::make('Snapshot') + ->schema([ + TextEntry::make('id') + ->label('Snapshot') + ->formatStateUsing(static fn (?int $state): string => $state ? '#'.$state : '—'), + TextEntry::make('baselineProfile.name') + ->label('Baseline'), + TextEntry::make('captured_at') + ->label('Captured') + ->dateTime(), + TextEntry::make('snapshot_state') + ->label('State') + ->badge() + ->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record)) + ->color(static fn (BaselineSnapshot $record): string => self::hasGaps($record) ? 'warning' : 'success'), + TextEntry::make('fidelity_summary') + ->label('Fidelity') + ->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record)), + TextEntry::make('evidence_gaps') + ->label('Evidence gaps') + ->getStateUsing(static fn (BaselineSnapshot $record): int => self::gapsCount($record)), + TextEntry::make('snapshot_identity_hash') + ->label('Identity hash') + ->copyable() + ->columnSpanFull(), + ]) + ->columns(2) + ->columnSpanFull(), + Section::make('Summary') + ->schema([ + ViewEntry::make('summary_jsonb') + ->label('') + ->view('filament.infolists.entries.snapshot-json') + ->state(static fn (BaselineSnapshot $record): array => is_array($record->summary_jsonb) ? $record->summary_jsonb : []) + ->columnSpanFull(), + ]) + ->columnSpanFull(), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListBaselineSnapshots::route('/'), + 'view' => Pages\ViewBaselineSnapshot::route('/{record}'), + ]; + } + + private static function resolveWorkspace(): ?Workspace + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if ($workspaceId === null) { + return null; + } + + return Workspace::query()->whereKey($workspaceId)->first(); + } + + private static function summary(BaselineSnapshot $snapshot): array + { + return is_array($snapshot->summary_jsonb) ? $snapshot->summary_jsonb : []; + } + + private static function fidelityCounts(BaselineSnapshot $snapshot): array + { + $summary = self::summary($snapshot); + $counts = $summary['fidelity_counts'] ?? null; + $counts = is_array($counts) ? $counts : []; + + $content = $counts['content'] ?? 0; + $meta = $counts['meta'] ?? 0; + + return [ + 'content' => is_numeric($content) ? (int) $content : 0, + 'meta' => is_numeric($meta) ? (int) $meta : 0, + ]; + } + + private static function fidelitySummary(BaselineSnapshot $snapshot): string + { + $counts = self::fidelityCounts($snapshot); + + return sprintf('Content %d, Meta %d', (int) ($counts['content'] ?? 0), (int) ($counts['meta'] ?? 0)); + } + + private static function gapsCount(BaselineSnapshot $snapshot): int + { + $summary = self::summary($snapshot); + $gaps = $summary['gaps'] ?? null; + $gaps = is_array($gaps) ? $gaps : []; + + $count = $gaps['count'] ?? 0; + + return is_numeric($count) ? (int) $count : 0; + } + + private static function hasGaps(BaselineSnapshot $snapshot): bool + { + return self::gapsCount($snapshot) > 0; + } + + private static function stateLabel(BaselineSnapshot $snapshot): string + { + return self::hasGaps($snapshot) ? 'Captured with gaps' : 'Complete'; + } +} diff --git a/app/Filament/Resources/BaselineSnapshotResource/Pages/ListBaselineSnapshots.php b/app/Filament/Resources/BaselineSnapshotResource/Pages/ListBaselineSnapshots.php new file mode 100644 index 0000000..7efacb2 --- /dev/null +++ b/app/Filament/Resources/BaselineSnapshotResource/Pages/ListBaselineSnapshots.php @@ -0,0 +1,18 @@ + 'warning', default => 'gray', }), + TextEntry::make('baseline_compare_why_no_findings') + ->label('Why no findings') + ->getStateUsing(function (OperationRun $record): ?string { + $context = is_array($record->context) ? $record->context : []; + $code = data_get($context, 'baseline_compare.reason_code'); + $code = is_string($code) ? trim($code) : null; + $code = $code !== '' ? $code : null; + + if ($code === null) { + return null; + } + + $enum = BaselineCompareReasonCode::tryFrom($code); + $message = $enum?->message(); + + return ($message !== null ? $message.' (' : '').$code.($message !== null ? ')' : ''); + }) + ->visible(function (OperationRun $record): bool { + $context = is_array($record->context) ? $record->context : []; + $code = data_get($context, 'baseline_compare.reason_code'); + + return is_string($code) && trim($code) !== ''; + }) + ->columnSpanFull(), TextEntry::make('baseline_compare_uncovered_types') ->label('Uncovered types') ->getStateUsing(function (OperationRun $record): ?string { diff --git a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php index 4a09550..dc2b865 100644 --- a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php +++ b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php @@ -22,14 +22,13 @@ class ListPolicies extends ListRecords protected function getHeaderActions(): array { return [ - $this->makeSyncAction() - ->visible(fn (): bool => $this->getFilteredTableQuery()->exists()), + $this->makeSyncAction(), ]; } protected function getTableEmptyStateActions(): array { - return [$this->makeSyncAction('syncEmpty')]; + return [$this->makeSyncAction()]; } private function makeSyncAction(string $name = 'sync'): Actions\Action diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index 29091c9..7440bc0 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -20,6 +20,7 @@ use App\Support\Auth\Capabilities; use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeRenderer; +use App\Support\Baselines\PolicyVersionCapturePurpose; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\Rbac\UiEnforcement; @@ -825,10 +826,29 @@ public static function table(Table $table): Table public static function getEloquentQuery(): Builder { - $tenantId = Tenant::currentOrFail()->getKey(); + $tenant = Tenant::currentOrFail(); + $tenantId = $tenant->getKey(); + $user = auth()->user(); + + $resolver = app(CapabilityResolver::class); + $canSeeBaselinePurposeEvidence = $user instanceof User + && ( + $resolver->can($user, $tenant, Capabilities::TENANT_SYNC) + || $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW) + ); return parent::getEloquentQuery() ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) + ->when(! $canSeeBaselinePurposeEvidence, function (Builder $query): Builder { + return $query->where(function (Builder $query): void { + $query + ->whereNull('capture_purpose') + ->orWhereNotIn('capture_purpose', [ + PolicyVersionCapturePurpose::BaselineCapture->value, + PolicyVersionCapturePurpose::BaselineCompare->value, + ]); + }); + }) ->with('policy'); } diff --git a/app/Jobs/CaptureBaselineSnapshotJob.php b/app/Jobs/CaptureBaselineSnapshotJob.php index cb20f60..f8148f1 100644 --- a/app/Jobs/CaptureBaselineSnapshotJob.php +++ b/app/Jobs/CaptureBaselineSnapshotJob.php @@ -284,17 +284,29 @@ private function collectInventorySubjects( /** @var array $inventoryByKey */ $inventoryByKey = []; - $subjectsTotal = 0; /** @var array $gaps */ $gaps = []; + /** + * Ensure we only include unambiguous subjects when matching by subject_key (derived from display name). + * + * When multiple inventory items share the same "policy_type|subject_key" we cannot reliably map them + * across tenants, so we treat them as an evidence gap and exclude them from the snapshot. + * + * @var array $ambiguousKeys + */ + $ambiguousKeys = []; + + /** + * @var array $subjectKeyToInventoryKey + */ + $subjectKeyToInventoryKey = []; + $query->orderBy('policy_type') ->orderBy('external_id') - ->chunk(500, function ($inventoryItems) use (&$inventoryByKey, &$subjectsTotal, &$gaps): void { + ->chunk(500, function ($inventoryItems) use (&$inventoryByKey, &$gaps, &$ambiguousKeys, &$subjectKeyToInventoryKey): void { foreach ($inventoryItems as $inventoryItem) { - $subjectsTotal++; - $metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : []; $displayName = is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null; $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); @@ -305,18 +317,37 @@ private function collectInventorySubjects( continue; } + $policyType = (string) $inventoryItem->policy_type; + $logicalKey = $policyType.'|'.$subjectKey; + + if (array_key_exists($logicalKey, $ambiguousKeys)) { + continue; + } + + if (array_key_exists($logicalKey, $subjectKeyToInventoryKey)) { + $ambiguousKeys[$logicalKey] = true; + + $previousKey = $subjectKeyToInventoryKey[$logicalKey]; + unset($subjectKeyToInventoryKey[$logicalKey], $inventoryByKey[$previousKey]); + + $gaps['ambiguous_match'] = ($gaps['ambiguous_match'] ?? 0) + 1; + + continue; + } + $workspaceSafeId = BaselineSubjectKey::workspaceSafeSubjectExternalId( - policyType: (string) $inventoryItem->policy_type, + policyType: $policyType, subjectKey: $subjectKey, ); - $key = (string) $inventoryItem->policy_type.'|'.(string) $inventoryItem->external_id; + $key = $policyType.'|'.(string) $inventoryItem->external_id; + $subjectKeyToInventoryKey[$logicalKey] = $key; $inventoryByKey[$key] = [ 'tenant_subject_external_id' => (string) $inventoryItem->external_id, 'workspace_subject_external_id' => $workspaceSafeId, 'subject_key' => $subjectKey, - 'policy_type' => (string) $inventoryItem->policy_type, + 'policy_type' => $policyType, 'display_name' => $displayName, 'category' => is_string($inventoryItem->category) ? $inventoryItem->category : null, 'platform' => is_string($inventoryItem->platform) ? $inventoryItem->platform : null, @@ -335,7 +366,7 @@ private function collectInventorySubjects( )); return [ - 'subjects_total' => $subjectsTotal, + 'subjects_total' => count($subjects), 'subjects' => $subjects, 'inventory_by_key' => $inventoryByKey, 'gaps' => $gaps, diff --git a/app/Jobs/CompareBaselineToTenantJob.php b/app/Jobs/CompareBaselineToTenantJob.php index 5fc096c..e903bf1 100644 --- a/app/Jobs/CompareBaselineToTenantJob.php +++ b/app/Jobs/CompareBaselineToTenantJob.php @@ -25,6 +25,7 @@ use App\Services\OperationRunService; use App\Services\Settings\SettingsResolver; use App\Support\Baselines\BaselineCaptureMode; +use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\Baselines\BaselineFullContentRolloutGate; use App\Support\Baselines\BaselineScope; use App\Support\Baselines\BaselineSubjectKey; @@ -120,7 +121,43 @@ public function handle( : BaselineCaptureMode::Opportunistic; if ($captureMode === BaselineCaptureMode::FullContent) { - $rolloutGate->assertEnabled(); + try { + $rolloutGate->assertEnabled(); + } catch (RuntimeException) { + $this->auditStarted( + auditLogger: $auditLogger, + tenant: $tenant, + profile: $profile, + initiator: $initiator, + captureMode: $captureMode, + subjectsTotal: 0, + effectiveScope: $effectiveScope, + ); + + $effectiveTypeCount = count($effectiveTypes); + $gapCount = max(1, $effectiveTypeCount); + + $this->completeWithCoverageWarning( + operationRunService: $operationRunService, + auditLogger: $auditLogger, + tenant: $tenant, + profile: $profile, + initiator: $initiator, + inventorySyncRun: null, + coverageProof: false, + effectiveTypes: $effectiveTypes, + coveredTypes: [], + uncoveredTypes: $effectiveTypes, + errorsRecorded: $gapCount, + captureMode: $captureMode, + reasonCode: BaselineCompareReasonCode::RolloutDisabled, + evidenceGapsByReason: [ + BaselineCompareReasonCode::RolloutDisabled->value => $gapCount, + ], + ); + + return; + } } if ($effectiveTypes === []) { @@ -147,6 +184,8 @@ public function handle( uncoveredTypes: [], errorsRecorded: 1, captureMode: $captureMode, + reasonCode: BaselineCompareReasonCode::NoSubjectsInScope, + evidenceGapsByReason: [], ); return; @@ -414,6 +453,18 @@ public function handle( ? EvidenceProvenance::FidelityMeta : EvidenceProvenance::FidelityContent; + $reasonCode = null; + + if ($subjectsTotal === 0) { + $reasonCode = BaselineCompareReasonCode::NoSubjectsInScope; + } elseif (count($driftResults) === 0) { + $reasonCode = match (true) { + $uncoveredTypes !== [] => BaselineCompareReasonCode::CoverageUnproven, + $resumeToken !== null || $gapsCount > 0 => BaselineCompareReasonCode::EvidenceCaptureIncomplete, + default => BaselineCompareReasonCode::NoDriftDetected, + }; + } + $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; $updatedContext['baseline_compare'] = array_merge( is_array($updatedContext['baseline_compare'] ?? null) ? $updatedContext['baseline_compare'] : [], @@ -437,6 +488,7 @@ public function handle( ...$baselineCoverage, ], 'fidelity' => $overallFidelity, + 'reason_code' => $reasonCode?->value, ], ); $updatedContext['findings'] = array_merge( @@ -568,6 +620,8 @@ private function completeWithCoverageWarning( array $uncoveredTypes, int $errorsRecorded, BaselineCaptureMode $captureMode, + BaselineCompareReasonCode $reasonCode = BaselineCompareReasonCode::CoverageUnproven, + ?array $evidenceGapsByReason = null, ): void { $summaryCounts = [ 'total' => 0, @@ -599,8 +653,8 @@ private function completeWithCoverageWarning( 'throttled' => 0, ]; - $evidenceGapsByReason = [ - 'coverage_unproven' => max(1, $errorsRecorded), + $evidenceGapsByReason ??= [ + BaselineCompareReasonCode::CoverageUnproven->value => max(1, $errorsRecorded), ]; $updatedContext['baseline_compare'] = array_merge( @@ -615,6 +669,7 @@ private function completeWithCoverageWarning( ...$evidenceGapsByReason, ], 'resume_token' => null, + 'reason_code' => $reasonCode->value, 'coverage' => [ 'effective_types' => array_values($effectiveTypes), 'covered_types' => array_values($coveredTypes), diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 2682577..a769ec5 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -13,6 +13,7 @@ use App\Filament\Resources\AlertDestinationResource; use App\Filament\Resources\AlertRuleResource; use App\Filament\Resources\BaselineProfileResource; +use App\Filament\Resources\BaselineSnapshotResource; use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\PolicyResource; use App\Filament\Resources\ProviderConnectionResource; @@ -179,6 +180,7 @@ public function panel(Panel $panel): Panel AlertDeliveryResource::class, WorkspaceResource::class, BaselineProfileResource::class, + BaselineSnapshotResource::class, ]) ->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters') ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') diff --git a/app/Services/Baselines/BaselineContentCapturePhase.php b/app/Services/Baselines/BaselineContentCapturePhase.php index 9837377..871ddfe 100644 --- a/app/Services/Baselines/BaselineContentCapturePhase.php +++ b/app/Services/Baselines/BaselineContentCapturePhase.php @@ -9,6 +9,7 @@ use App\Services\Intune\PolicyCaptureOrchestrator; use App\Support\Baselines\BaselineEvidenceResumeToken; use App\Support\Baselines\PolicyVersionCapturePurpose; +use Throwable; final class BaselineContentCapturePhase { @@ -37,7 +38,11 @@ public function capture( ?int $baselineProfileId = null, ?string $createdBy = null, ): array { + $subjects = array_values($subjects); + $maxItemsPerRun = max(0, (int) ($budgets['max_items_per_run'] ?? 0)); + $maxConcurrency = max(1, (int) ($budgets['max_concurrency'] ?? 1)); + $maxRetries = max(0, (int) ($budgets['max_retries'] ?? 0)); $offset = 0; @@ -46,6 +51,10 @@ public function capture( $offset = is_numeric($state['offset'] ?? null) ? max(0, (int) $state['offset']) : 0; } + if ($offset >= count($subjects)) { + $offset = 0; + } + $remaining = array_slice($subjects, $offset); $batch = $maxItemsPerRun > 0 ? array_slice($remaining, 0, $maxItemsPerRun) : []; @@ -60,52 +69,104 @@ public function capture( /** @var array $gaps */ $gaps = []; - foreach ($batch as $subject) { - $policyType = trim((string) ($subject['policy_type'] ?? '')); - $externalId = trim((string) ($subject['subject_external_id'] ?? '')); + /** + * @var array $seen + */ + $seen = []; - if ($policyType === '' || $externalId === '') { - $gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1; - $stats['failed']++; + foreach (array_chunk($batch, $maxConcurrency) as $chunk) { + foreach ($chunk as $subject) { + $policyType = trim((string) ($subject['policy_type'] ?? '')); + $externalId = trim((string) ($subject['subject_external_id'] ?? '')); - continue; + if ($policyType === '' || $externalId === '') { + $gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1; + $stats['failed']++; + + continue; + } + + $subjectKey = $policyType.'|'.$externalId; + + if (isset($seen[$subjectKey])) { + $gaps['duplicate_subject'] = ($gaps['duplicate_subject'] ?? 0) + 1; + $stats['skipped']++; + + continue; + } + + $seen[$subjectKey] = true; + + $policy = Policy::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('policy_type', $policyType) + ->where('external_id', $externalId) + ->first(); + + if (! $policy instanceof Policy) { + $gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1; + $stats['failed']++; + + continue; + } + + $attempt = 0; + $result = null; + + while (true) { + try { + $result = $this->captureOrchestrator->capture( + policy: $policy, + tenant: $tenant, + includeAssignments: true, + includeScopeTags: true, + createdBy: $createdBy, + metadata: [ + 'capture_source' => 'baseline_evidence', + ], + capturePurpose: $purpose, + operationRunId: $operationRunId, + baselineProfileId: $baselineProfileId, + ); + } catch (Throwable $throwable) { + $result = [ + 'failure' => [ + 'reason' => $throwable->getMessage(), + 'status' => is_numeric($throwable->getCode()) ? (int) $throwable->getCode() : null, + ], + ]; + } + + if (! (is_array($result) && array_key_exists('failure', $result))) { + $stats['succeeded']++; + + break; + } + + $failure = is_array($result['failure'] ?? null) ? $result['failure'] : []; + $status = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null; + + $isThrottled = in_array($status, [429, 503], true); + + if ($isThrottled && $attempt < $maxRetries) { + $delayMs = $this->retryDelayMs($attempt); + usleep($delayMs * 1000); + $attempt++; + + continue; + } + + if ($isThrottled) { + $gaps['throttled'] = ($gaps['throttled'] ?? 0) + 1; + $stats['throttled']++; + } else { + $gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1; + $stats['failed']++; + } + + break; + } } - - $policy = Policy::query() - ->where('tenant_id', (int) $tenant->getKey()) - ->where('policy_type', $policyType) - ->where('external_id', $externalId) - ->first(); - - if (! $policy instanceof Policy) { - $gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1; - $stats['failed']++; - - continue; - } - - $result = $this->captureOrchestrator->capture( - policy: $policy, - tenant: $tenant, - includeAssignments: true, - includeScopeTags: true, - createdBy: $createdBy, - metadata: [ - 'capture_source' => 'baseline_evidence', - ], - capturePurpose: $purpose, - operationRunId: $operationRunId, - baselineProfileId: $baselineProfileId, - ); - - if (is_array($result) && array_key_exists('failure', $result)) { - $gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1; - $stats['failed']++; - - continue; - } - - $stats['succeeded']++; } $processed = $offset + count($batch); @@ -114,7 +175,13 @@ public function capture( if ($processed < count($subjects)) { $resumeTokenOut = BaselineEvidenceResumeToken::encode([ 'offset' => $processed, + 'total' => count($subjects), ]); + + $remainingCount = max(0, count($subjects) - $processed); + if ($remainingCount > 0) { + $gaps['budget_exhausted'] = ($gaps['budget_exhausted'] ?? 0) + $remainingCount; + } } ksort($gaps); @@ -125,5 +192,17 @@ public function capture( 'resume_token' => $resumeTokenOut, ]; } -} + private function retryDelayMs(int $attempt): int + { + $attempt = max(0, $attempt); + + $baseDelayMs = 500; + $maxDelayMs = 30_000; + + $delayMs = (int) min($maxDelayMs, $baseDelayMs * (2 ** $attempt)); + $jitterMs = random_int(0, 250); + + return $delayMs + $jitterMs; + } +} diff --git a/app/Services/Baselines/BaselineEvidenceCaptureResumeService.php b/app/Services/Baselines/BaselineEvidenceCaptureResumeService.php new file mode 100644 index 0000000..1bf9550 --- /dev/null +++ b/app/Services/Baselines/BaselineEvidenceCaptureResumeService.php @@ -0,0 +1,148 @@ +type); + + if (! in_array($runType, [OperationRunType::BaselineCapture->value, OperationRunType::BaselineCompare->value], true)) { + return ['ok' => false, 'reason_code' => 'baseline.resume.unsupported_run_type']; + } + + if ($priorRun->status !== OperationRunStatus::Completed->value) { + return ['ok' => false, 'reason_code' => 'baseline.resume.run_not_completed']; + } + + $tenantId = (int) ($priorRun->tenant_id ?? 0); + + if ($tenantId <= 0) { + return ['ok' => false, 'reason_code' => 'baseline.resume.missing_tenant']; + } + + $tenant = Tenant::query()->whereKey($tenantId)->first(); + + if (! $tenant instanceof Tenant) { + return ['ok' => false, 'reason_code' => 'baseline.resume.tenant_not_found']; + } + + $workspaceId = (int) ($tenant->workspace_id ?? 0); + + if ($workspaceId <= 0) { + return ['ok' => false, 'reason_code' => 'baseline.resume.missing_workspace']; + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return ['ok' => false, 'reason_code' => 'baseline.resume.workspace_not_found']; + } + + if (! $this->workspaceCapabilities->isMember($initiator, $workspace)) { + return ['ok' => false, 'reason_code' => 'baseline.resume.not_workspace_member']; + } + + if (! $this->workspaceCapabilities->can($initiator, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) { + return ['ok' => false, 'reason_code' => 'baseline.resume.forbidden']; + } + + $this->rolloutGate->assertEnabled(); + + $context = is_array($priorRun->context) ? $priorRun->context : []; + $profileId = (int) ($context['baseline_profile_id'] ?? 0); + + if ($profileId <= 0) { + return ['ok' => false, 'reason_code' => 'baseline.resume.missing_profile']; + } + + $resumeSection = $runType === OperationRunType::BaselineCapture->value ? 'baseline_capture' : 'baseline_compare'; + $resumeToken = data_get($context, "{$resumeSection}.resume_token"); + + if (! is_string($resumeToken) || trim($resumeToken) === '') { + return ['ok' => false, 'reason_code' => 'baseline.resume.missing_resume_token']; + } + + $newContext = []; + + foreach (['target_scope', 'baseline_profile_id', 'baseline_snapshot_id', 'source_tenant_id', 'effective_scope', 'capture_mode'] as $key) { + if (array_key_exists($key, $context)) { + $newContext[$key] = $context[$key]; + } + } + + $newContext['resume_from_operation_run_id'] = (int) $priorRun->getKey(); + + $newContext[$resumeSection] = [ + 'resume_token' => $resumeToken, + 'resume_from_operation_run_id' => (int) $priorRun->getKey(), + ]; + + $run = $this->runs->ensureRunWithIdentity( + tenant: $tenant, + type: $runType, + identityInputs: [ + 'baseline_profile_id' => $profileId, + ], + context: $newContext, + initiator: $initiator, + ); + + if ($run->wasRecentlyCreated) { + match ($runType) { + OperationRunType::BaselineCapture->value => CaptureBaselineSnapshotJob::dispatch($run), + OperationRunType::BaselineCompare->value => CompareBaselineToTenantJob::dispatch($run), + default => null, + }; + } + + $this->auditLogger->log( + tenant: $tenant, + action: 'baseline.evidence.resume.started', + context: [ + 'metadata' => [ + 'prior_operation_run_id' => (int) $priorRun->getKey(), + 'operation_run_id' => (int) $run->getKey(), + 'baseline_profile_id' => $profileId, + 'run_type' => $runType, + ], + ], + actorId: (int) $initiator->getKey(), + actorEmail: (string) $initiator->email, + actorName: (string) $initiator->name, + resourceType: 'operation_run', + resourceId: (string) $priorRun->getKey(), + ); + + return ['ok' => true, 'run' => $run]; + } +} diff --git a/app/Support/Baselines/BaselineCompareReasonCode.php b/app/Support/Baselines/BaselineCompareReasonCode.php index 1855ac0..fbbbef5 100644 --- a/app/Support/Baselines/BaselineCompareReasonCode.php +++ b/app/Support/Baselines/BaselineCompareReasonCode.php @@ -16,11 +16,10 @@ public function message(): string { return match ($this) { self::NoSubjectsInScope => 'No subjects were in scope for this comparison.', - self::CoverageUnproven => 'Coverage proof was not available, so missing-policy outcomes were suppressed.', + self::CoverageUnproven => 'Coverage proof was missing or incomplete, so some findings were suppressed for safety.', self::EvidenceCaptureIncomplete => 'Evidence capture was incomplete, so some drift evaluation may have been suppressed.', self::RolloutDisabled => 'Full-content baseline compare is currently disabled by rollout configuration.', self::NoDriftDetected => 'No drift was detected for in-scope subjects.', }; } } - diff --git a/app/Support/Baselines/BaselineCompareStats.php b/app/Support/Baselines/BaselineCompareStats.php index 3cbf473..dc9d1c9 100644 --- a/app/Support/Baselines/BaselineCompareStats.php +++ b/app/Support/Baselines/BaselineCompareStats.php @@ -7,8 +7,10 @@ use App\Models\BaselineProfile; use App\Models\BaselineTenantAssignment; use App\Models\Finding; +use App\Models\InventoryItem; use App\Models\OperationRun; use App\Models\Tenant; +use Illuminate\Support\Facades\Cache; final class BaselineCompareStats { @@ -23,12 +25,15 @@ private function __construct( public readonly ?string $profileName, public readonly ?int $profileId, public readonly ?int $snapshotId, + public readonly ?int $duplicateNamePoliciesCount, public readonly ?int $operationRunId, public readonly ?int $findingsCount, public readonly array $severityCounts, public readonly ?string $lastComparedHuman, public readonly ?string $lastComparedIso, public readonly ?string $failureReason, + public readonly ?string $reasonCode = null, + public readonly ?string $reasonMessage = null, public readonly ?string $coverageStatus = null, public readonly ?int $uncoveredTypesCount = null, public readonly array $uncoveredTypes = [], @@ -67,12 +72,23 @@ public static function forTenant(?Tenant $tenant): self $profileId = (int) $profile->getKey(); $snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null; + $profileScope = BaselineScope::fromJsonb( + is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null, + ); + $overrideScope = $assignment->override_scope_jsonb !== null + ? BaselineScope::fromJsonb(is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null) + : null; + $effectiveScope = BaselineScope::effective($profileScope, $overrideScope); + + $duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope); + if ($snapshotId === null) { return self::empty( 'no_snapshot', 'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.', profileName: $profileName, profileId: $profileId, + duplicateNamePoliciesCount: $duplicateNamePoliciesCount, ); } @@ -84,6 +100,7 @@ public static function forTenant(?Tenant $tenant): self [$coverageStatus, $uncoveredTypes, $fidelity] = self::coverageInfoForRun($latestRun); [$evidenceGapsCount, $evidenceGapsTopReasons] = self::evidenceGapSummaryForRun($latestRun); + [$reasonCode, $reasonMessage] = self::reasonInfoForRun($latestRun); // Active run (queued/running) if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) { @@ -93,12 +110,15 @@ public static function forTenant(?Tenant $tenant): self profileName: $profileName, profileId: $profileId, snapshotId: $snapshotId, + duplicateNamePoliciesCount: $duplicateNamePoliciesCount, operationRunId: (int) $latestRun->getKey(), findingsCount: null, severityCounts: [], lastComparedHuman: null, lastComparedIso: null, failureReason: null, + reasonCode: $reasonCode, + reasonMessage: $reasonMessage, coverageStatus: $coverageStatus, uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypes: $uncoveredTypes, @@ -121,12 +141,15 @@ public static function forTenant(?Tenant $tenant): self profileName: $profileName, profileId: $profileId, snapshotId: $snapshotId, + duplicateNamePoliciesCount: $duplicateNamePoliciesCount, operationRunId: (int) $latestRun->getKey(), findingsCount: null, severityCounts: [], lastComparedHuman: $latestRun->finished_at?->diffForHumans(), lastComparedIso: $latestRun->finished_at?->toIso8601String(), failureReason: (string) $failureReason, + reasonCode: $reasonCode, + reasonMessage: $reasonMessage, coverageStatus: $coverageStatus, uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypes: $uncoveredTypes, @@ -171,12 +194,15 @@ public static function forTenant(?Tenant $tenant): self profileName: $profileName, profileId: $profileId, snapshotId: $snapshotId, + duplicateNamePoliciesCount: $duplicateNamePoliciesCount, operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null, findingsCount: $totalFindings, severityCounts: $severityCounts, lastComparedHuman: $lastComparedHuman, lastComparedIso: $lastComparedIso, failureReason: null, + reasonCode: $reasonCode, + reasonMessage: $reasonMessage, coverageStatus: $coverageStatus, uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypes: $uncoveredTypes, @@ -195,12 +221,15 @@ public static function forTenant(?Tenant $tenant): self profileName: $profileName, profileId: $profileId, snapshotId: $snapshotId, + duplicateNamePoliciesCount: $duplicateNamePoliciesCount, operationRunId: (int) $latestRun->getKey(), findingsCount: 0, severityCounts: $severityCounts, lastComparedHuman: $lastComparedHuman, lastComparedIso: $lastComparedIso, failureReason: null, + reasonCode: $reasonCode, + reasonMessage: $reasonMessage, coverageStatus: $coverageStatus, uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypes: $uncoveredTypes, @@ -216,12 +245,15 @@ public static function forTenant(?Tenant $tenant): self profileName: $profileName, profileId: $profileId, snapshotId: $snapshotId, + duplicateNamePoliciesCount: $duplicateNamePoliciesCount, operationRunId: null, findingsCount: null, severityCounts: $severityCounts, lastComparedHuman: $lastComparedHuman, lastComparedIso: $lastComparedIso, failureReason: null, + reasonCode: $reasonCode, + reasonMessage: $reasonMessage, coverageStatus: $coverageStatus, uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypes: $uncoveredTypes, @@ -278,6 +310,7 @@ public static function forWidget(?Tenant $tenant): self profileName: (string) $profile->name, profileId: (int) $profile->getKey(), snapshotId: $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null, + duplicateNamePoliciesCount: null, operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null, findingsCount: $totalFindings, severityCounts: [ @@ -291,6 +324,64 @@ public static function forWidget(?Tenant $tenant): self ); } + private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope $effectiveScope): int + { + $policyTypes = $effectiveScope->allTypes(); + + if ($policyTypes === []) { + return 0; + } + + $compute = static function () use ($tenant, $policyTypes): int { + /** + * @var array $countsByKey + */ + $countsByKey = []; + + InventoryItem::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->whereIn('policy_type', $policyTypes) + ->whereNotNull('display_name') + ->select(['id', 'policy_type', 'display_name']) + ->orderBy('id') + ->chunkById(1_000, function ($inventoryItems) use (&$countsByKey): void { + foreach ($inventoryItems as $inventoryItem) { + $displayName = is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null; + $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); + + if ($subjectKey === null) { + continue; + } + + $logicalKey = (string) $inventoryItem->policy_type.'|'.$subjectKey; + $countsByKey[$logicalKey] = ($countsByKey[$logicalKey] ?? 0) + 1; + } + }); + + $duplicatePolicies = 0; + + foreach ($countsByKey as $count) { + if ($count > 1) { + $duplicatePolicies += $count; + } + } + + return $duplicatePolicies; + }; + + if (app()->environment('testing')) { + return $compute(); + } + + $cacheKey = sprintf( + 'baseline_compare:tenant:%d:duplicate_names:%s', + (int) $tenant->getKey(), + hash('sha256', implode('|', $policyTypes)), + ); + + return (int) Cache::remember($cacheKey, now()->addSeconds(60), $compute); + } + /** * @return array{0: ?string, 1: list, 2: ?string} */ @@ -335,6 +426,31 @@ private static function coverageInfoForRun(?OperationRun $run): array return [$coverageStatus, $uncoveredTypes, $fidelity]; } + /** + * @return array{0: ?string, 1: ?string} + */ + private static function reasonInfoForRun(?OperationRun $run): array + { + if (! $run instanceof OperationRun) { + return [null, null]; + } + + $context = is_array($run->context) ? $run->context : []; + $baselineCompare = $context['baseline_compare'] ?? null; + + if (! is_array($baselineCompare)) { + return [null, null]; + } + + $reasonCode = $baselineCompare['reason_code'] ?? null; + $reasonCode = is_string($reasonCode) ? trim($reasonCode) : null; + $reasonCode = $reasonCode !== '' ? $reasonCode : null; + + $enum = $reasonCode !== null ? BaselineCompareReasonCode::tryFrom($reasonCode) : null; + + return [$reasonCode, $enum?->message()]; + } + /** * @return array{0: ?int, 1: array} */ @@ -393,6 +509,7 @@ private static function empty( ?string $message, ?string $profileName = null, ?int $profileId = null, + ?int $duplicateNamePoliciesCount = null, ): self { return new self( state: $state, @@ -400,6 +517,7 @@ private static function empty( profileName: $profileName, profileId: $profileId, snapshotId: null, + duplicateNamePoliciesCount: $duplicateNamePoliciesCount, operationRunId: null, findingsCount: null, severityCounts: [], diff --git a/lang/en/baseline-compare.php b/lang/en/baseline-compare.php new file mode 100644 index 0000000..4c16cd9 --- /dev/null +++ b/lang/en/baseline-compare.php @@ -0,0 +1,81 @@ + 'Warning', + 'duplicate_warning_body_plural' => ':count policies in this tenant share the same display name. :app cannot match them to the baseline. Please rename the duplicates in the Microsoft Intune portal.', + 'duplicate_warning_body_singular' => ':count policy in this tenant shares the same display name. :app cannot match it to the baseline. Please rename the duplicate in the Microsoft Intune portal.', + + // Stats card labels + 'stat_assigned_baseline' => 'Assigned Baseline', + 'stat_total_findings' => 'Total Findings', + 'stat_last_compared' => 'Last Compared', + 'stat_last_compared_never' => 'Never', + 'stat_error' => 'Error', + + // Badges + 'badge_snapshot' => 'Snapshot #:id', + 'badge_coverage_ok' => 'Coverage: OK', + 'badge_coverage_warnings' => 'Coverage: Warnings', + 'badge_fidelity' => 'Fidelity: :level', + 'badge_evidence_gaps' => 'Evidence gaps: :count', + 'evidence_gaps_tooltip' => 'Top gaps: :summary', + + // Comparing state + 'comparing_indicator' => 'Comparing…', + + // Why-no-findings explanations + 'no_findings_all_clear' => 'All clear', + 'no_findings_coverage_warnings' => 'Coverage warnings', + 'no_findings_evidence_gaps' => 'Evidence gaps', + 'no_findings_default' => 'No findings', + + // Coverage warning banner + 'coverage_warning_title' => 'Comparison completed with warnings', + 'coverage_unproven_body' => 'Coverage proof was missing or unreadable for the last comparison run, so findings were suppressed for safety.', + 'coverage_incomplete_body' => 'Findings were skipped for :count policy :types due to incomplete coverage.', + 'coverage_uncovered_label' => 'Uncovered: :list', + + // Failed banner + 'failed_title' => 'Comparison Failed', + 'failed_body_default' => 'The last baseline comparison failed. Review the run details or retry.', + + // Critical drift banner + 'critical_drift_title' => 'Critical Drift Detected', + 'critical_drift_body' => 'The current tenant state deviates from baseline :profile. :count high-severity :findings require immediate attention.', + + // Empty states + 'empty_no_tenant' => 'No Tenant Selected', + 'empty_no_assignment' => 'No Baseline Assigned', + 'empty_no_snapshot' => 'No Snapshot Available', + + // Findings section + 'findings_description' => 'The tenant configuration drifted from the baseline profile.', + + // No drift + 'no_drift_title' => 'No Drift Detected', + 'no_drift_body' => 'The tenant configuration matches the baseline profile. Everything looks good.', + + // Coverage warnings (no findings) + 'coverage_warnings_title' => 'Coverage Warnings', + 'coverage_warnings_body' => 'The last comparison completed with warnings and produced no drift findings. Run Inventory Sync again to establish full coverage before interpreting results.', + + // Idle + 'idle_title' => 'Ready to Compare', + + // Buttons + 'button_view_run' => 'View run', + 'button_view_failed_run' => 'View failed run', + 'button_view_findings' => 'View all findings', + 'button_review_last_run' => 'Review last run', + +]; diff --git a/resources/views/filament/pages/baseline-compare-landing.blade.php b/resources/views/filament/pages/baseline-compare-landing.blade.php index 27458ee..86525d7 100644 --- a/resources/views/filament/pages/baseline-compare-landing.blade.php +++ b/resources/views/filament/pages/baseline-compare-landing.blade.php @@ -5,44 +5,40 @@ @endif @php - $hasCoverageWarnings = in_array(($coverageStatus ?? null), ['warning', 'unproven'], true); - $evidenceGapsCountValue = (int) ($evidenceGapsCount ?? 0); - $hasEvidenceGaps = $evidenceGapsCountValue > 0; - $hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps; - - $evidenceGapsSummary = null; - $evidenceGapsTooltip = null; - - if ($hasEvidenceGaps && is_array($evidenceGapsTopReasons ?? null) && $evidenceGapsTopReasons !== []) { - $parts = []; - - foreach (array_slice($evidenceGapsTopReasons, 0, 5, true) as $reason => $count) { - if (! is_string($reason) || $reason === '' || ! is_numeric($count)) { - continue; - } - - $parts[] = $reason.' ('.((int) $count).')'; - } - - if ($parts !== []) { - $evidenceGapsSummary = implode(', ', $parts); - $evidenceGapsTooltip = 'Top gaps: '.$evidenceGapsSummary; - } - } + $duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0); @endphp + @if ($duplicateNamePoliciesCountValue > 0) + + @endif + {{-- Row 1: Stats Overview --}} @if (in_array($state, ['ready', 'idle', 'comparing', 'failed']))
{{-- Stat: Assigned Baseline --}}
-
Assigned Baseline
+
{{ __('baseline-compare.stat_assigned_baseline') }}
{{ $profileName ?? '—' }}
@if ($snapshotId) - Snapshot #{{ $snapshotId }} + {{ __('baseline-compare.badge_snapshot', ['id' => $snapshotId]) }} @endif @@ -52,26 +48,26 @@ size="sm" class="w-fit" > - Coverage: {{ $coverageStatus === 'ok' ? 'OK' : 'Warnings' }} + {{ $coverageStatus === 'ok' ? __('baseline-compare.badge_coverage_ok') : __('baseline-compare.badge_coverage_warnings') }} @endif @if (filled($fidelity)) - Fidelity: {{ Str::title($fidelity) }} + {{ __('baseline-compare.badge_fidelity', ['level' => Str::title($fidelity)]) }} @endif @if ($hasEvidenceGaps) - Evidence gaps: {{ $evidenceGapsCountValue }} + {{ __('baseline-compare.badge_evidence_gaps', ['count' => $evidenceGapsCountValue]) }} @endif
@if ($hasEvidenceGaps && filled($evidenceGapsSummary))
- Top gaps: {{ $evidenceGapsSummary }} + {{ __('baseline-compare.evidence_gaps_tooltip', ['summary' => $evidenceGapsSummary]) }}
@endif
@@ -80,25 +76,21 @@ class="w-fit" {{-- Stat: Total Findings --}}
-
Total Findings
+
{{ __('baseline-compare.stat_total_findings') }}
@if ($state === 'failed') -
Error
+
{{ __('baseline-compare.stat_error') }}
@else -
+
{{ $findingsCount ?? 0 }}
@endif @if ($state === 'comparing')
- Comparing… + {{ __('baseline-compare.comparing_indicator') }}
- @elseif (($findingsCount ?? 0) === 0 && $state === 'ready' && ! $hasWarnings) - All clear - @elseif ($state === 'ready' && $hasCoverageWarnings) - Coverage warnings - @elseif ($state === 'ready' && $hasEvidenceGaps) - Evidence gaps + @elseif (($findingsCount ?? 0) === 0 && $state === 'ready') + {{ $whyNoFindingsMessage ?? $whyNoFindingsFallback }} @endif
@@ -106,13 +98,13 @@ class="w-fit" {{-- Stat: Last Compared --}}
-
Last Compared
+
{{ __('baseline-compare.stat_last_compared') }}
- {{ $lastComparedAt ?? 'Never' }} + {{ $lastComparedAt ?? __('baseline-compare.stat_last_compared_never') }}
@if ($this->getRunUrl()) - View run + {{ __('baseline-compare.button_view_run') }} @endif
@@ -122,23 +114,28 @@ class="w-fit" {{-- Coverage warnings banner --}} @if ($state === 'ready' && $hasCoverageWarnings) -
+