diff --git a/.env.example b/.env.example index a37e8d7..4c2be0d 100644 --- a/.env.example +++ b/.env.example @@ -73,3 +73,10 @@ ENTRA_AUTHORITY_TENANT=organizations # System panel break-glass (Platform Operators) BREAK_GLASS_ENABLED=false BREAK_GLASS_TTL_MINUTES=60 + +# Baselines (Spec 118: full-content drift detection) +TENANTPILOT_BASELINE_FULL_CONTENT_CAPTURE_ENABLED=false +TENANTPILOT_BASELINE_EVIDENCE_MAX_ITEMS_PER_RUN=200 +TENANTPILOT_BASELINE_EVIDENCE_MAX_CONCURRENCY=5 +TENANTPILOT_BASELINE_EVIDENCE_MAX_RETRIES=3 +TENANTPILOT_BASELINE_EVIDENCE_RETENTION_DAYS=90 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 f88ec46..1c80b02 100644 --- a/app/Filament/Pages/BaselineCompareLanding.php +++ b/app/Filament/Pages/BaselineCompareLanding.php @@ -11,6 +11,7 @@ use App\Services\Auth\CapabilityResolver; use App\Services\Baselines\BaselineCompareService; use App\Support\Auth\Capabilities; +use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCompareStats; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; @@ -43,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; @@ -71,6 +78,11 @@ class BaselineCompareLanding extends Page public ?string $fidelity = null; + public ?int $evidenceGapsCount = null; + + /** @var array|null */ + public ?array $evidenceGapsTopReasons = null; + public static function canAccess(): bool { $user = auth()->user(); @@ -104,17 +116,118 @@ 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; $this->uncoveredTypes = $stats->uncoveredTypes !== [] ? $stats->uncoveredTypes : null; $this->fidelity = $stats->fidelity; + + $this->evidenceGapsCount = $stats->evidenceGapsCount; + $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 @@ -140,12 +253,28 @@ protected function getHeaderActions(): array private function compareNowAction(): Action { + $isFullContent = false; + + if (is_int($this->profileId) && $this->profileId > 0) { + $profile = \App\Models\BaselineProfile::query()->find($this->profileId); + $mode = $profile?->capture_mode instanceof BaselineCaptureMode + ? $profile->capture_mode + : (is_string($profile?->capture_mode) ? BaselineCaptureMode::tryFrom($profile->capture_mode) : null); + + $isFullContent = $mode === BaselineCaptureMode::FullContent; + } + + $label = $isFullContent ? 'Compare now (full content)' : 'Compare now'; + $modalDescription = $isFullContent + ? 'This will refresh content evidence on demand (redacted) before comparing the current tenant inventory against the assigned baseline snapshot.' + : 'This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.'; + $action = Action::make('compareNow') - ->label('Compare Now') + ->label($label) ->icon('heroicon-o-play') ->requiresConfirmation() - ->modalHeading('Start baseline comparison') - ->modalDescription('This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.') + ->modalHeading($label) + ->modalDescription($modalDescription) ->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready', 'failed'], true)) ->action(function (): void { $user = auth()->user(); 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/BaselineProfileResource.php b/app/Filament/Resources/BaselineProfileResource.php index faf4bc0..e0cb8aa 100644 --- a/app/Filament/Resources/BaselineProfileResource.php +++ b/app/Filament/Resources/BaselineProfileResource.php @@ -13,7 +13,9 @@ use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; +use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineProfileStatus; +use App\Support\Baselines\BaselineFullContentRolloutGate; use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Rbac\WorkspaceUiEnforcement; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; @@ -173,6 +175,23 @@ public static function form(Schema $schema): Schema BaselineProfileStatus::Active => 'Changing status to Archived is permanent.', default => 'Only active baselines are enforced during compliance checks.', }), + Select::make('capture_mode') + ->label('Capture mode') + ->required() + ->options(BaselineCaptureMode::selectOptions()) + ->default(BaselineCaptureMode::Opportunistic->value) + ->native(false) + ->disabled(fn (?BaselineProfile $record): bool => $record?->status === BaselineProfileStatus::Archived) + ->disableOptionWhen(function (string $value): bool { + if ($value !== BaselineCaptureMode::FullContent->value) { + return false; + } + + return ! app(BaselineFullContentRolloutGate::class)->enabled(); + }) + ->helperText(fn (): string => app(BaselineFullContentRolloutGate::class)->enabled() + ? 'Full content capture enables deep drift detection by capturing policy evidence on demand.' + : 'Full content capture is currently disabled by rollout configuration.'), TextInput::make('version_label') ->label('Version label') ->maxLength(50) @@ -213,6 +232,30 @@ public static function infolist(Schema $schema): Schema ->formatStateUsing(BadgeRenderer::label(BadgeDomain::BaselineProfileStatus)) ->color(BadgeRenderer::color(BadgeDomain::BaselineProfileStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::BaselineProfileStatus)), + TextEntry::make('capture_mode') + ->label('Capture mode') + ->badge() + ->formatStateUsing(function (mixed $state): string { + if ($state instanceof BaselineCaptureMode) { + return $state->label(); + } + + $parsed = is_string($state) ? BaselineCaptureMode::tryFrom($state) : null; + + return $parsed?->label() ?? (is_string($state) ? $state : '—'); + }) + ->color(function (mixed $state): string { + $mode = $state instanceof BaselineCaptureMode + ? $state + : (is_string($state) ? BaselineCaptureMode::tryFrom($state) : null); + + return match ($mode) { + BaselineCaptureMode::FullContent => 'success', + BaselineCaptureMode::Opportunistic => 'warning', + BaselineCaptureMode::MetaOnly => 'gray', + default => 'gray', + }; + }), TextEntry::make('version_label') ->label('Version') ->placeholder('—'), @@ -279,6 +322,31 @@ public static function table(Table $table): Table ->color(BadgeRenderer::color(BadgeDomain::BaselineProfileStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::BaselineProfileStatus)) ->sortable(), + TextColumn::make('capture_mode') + ->label('Capture mode') + ->badge() + ->formatStateUsing(function (mixed $state): string { + if ($state instanceof BaselineCaptureMode) { + return $state->label(); + } + + $parsed = is_string($state) ? BaselineCaptureMode::tryFrom($state) : null; + + return $parsed?->label() ?? (is_string($state) ? $state : '—'); + }) + ->color(function (mixed $state): string { + $mode = $state instanceof BaselineCaptureMode + ? $state + : (is_string($state) ? BaselineCaptureMode::tryFrom($state) : null); + + return match ($mode) { + BaselineCaptureMode::FullContent => 'success', + BaselineCaptureMode::Opportunistic => 'warning', + BaselineCaptureMode::MetaOnly => 'gray', + default => 'gray', + }; + }) + ->toggleable(isToggledHiddenByDefault: true), TextColumn::make('version_label') ->label('Version') ->placeholder('—'), diff --git a/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php b/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php index 10f1f14..e2500a2 100644 --- a/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php +++ b/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php @@ -6,11 +6,16 @@ use App\Filament\Resources\BaselineProfileResource; use App\Models\BaselineProfile; +use App\Models\BaselineTenantAssignment; use App\Models\Tenant; use App\Models\User; use App\Models\Workspace; +use App\Services\Auth\CapabilityResolver; use App\Services\Baselines\BaselineCaptureService; +use App\Services\Baselines\BaselineCompareService; use App\Support\Auth\Capabilities; +use App\Support\Baselines\BaselineCaptureMode; +use App\Support\Baselines\BaselineReasonCodes; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; @@ -30,6 +35,7 @@ protected function getHeaderActions(): array { return [ $this->captureAction(), + $this->compareNowAction(), EditAction::make() ->visible(fn (): bool => $this->hasManageCapability()), ]; @@ -37,13 +43,28 @@ protected function getHeaderActions(): array private function captureAction(): Action { + /** @var BaselineProfile $profile */ + $profile = $this->getRecord(); + + $captureMode = $profile->capture_mode instanceof BaselineCaptureMode + ? $profile->capture_mode + : BaselineCaptureMode::Opportunistic; + + $label = $captureMode === BaselineCaptureMode::FullContent + ? 'Capture baseline (full content)' + : 'Capture baseline'; + + $modalDescription = $captureMode === BaselineCaptureMode::FullContent + ? 'Select the source tenant. This will capture content evidence on demand (redacted) and may take longer depending on scope.' + : 'Select the source tenant whose current inventory will be captured as the baseline snapshot.'; + $action = Action::make('capture') - ->label('Capture Snapshot') + ->label($label) ->icon('heroicon-o-camera') ->color('primary') ->requiresConfirmation() - ->modalHeading('Capture Baseline Snapshot') - ->modalDescription('Select the source tenant whose current inventory will be captured as the baseline snapshot.') + ->modalHeading($label) + ->modalDescription($modalDescription) ->form([ Select::make('source_tenant_id') ->label('Source Tenant') @@ -75,9 +96,18 @@ private function captureAction(): Action $result = $service->startCapture($profile, $sourceTenant, $user); if (! $result['ok']) { + $reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown'; + + $message = match ($reasonCode) { + BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED => 'Full-content baseline capture is currently disabled for controlled rollout.', + BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.', + BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT => 'The selected tenant is not available for this baseline profile.', + default => 'Reason: '.str_replace('.', ' ', $reasonCode), + }; + Notification::make() ->title('Cannot start capture') - ->body('Reason: '.str_replace('.', ' ', (string) ($result['reason_code'] ?? 'unknown'))) + ->body($message) ->danger() ->send(); @@ -124,6 +154,141 @@ private function captureAction(): Action ->apply(); } + private function compareNowAction(): Action + { + /** @var BaselineProfile $profile */ + $profile = $this->getRecord(); + + $captureMode = $profile->capture_mode instanceof BaselineCaptureMode + ? $profile->capture_mode + : BaselineCaptureMode::Opportunistic; + + $label = $captureMode === BaselineCaptureMode::FullContent + ? 'Compare now (full content)' + : 'Compare now'; + + $modalDescription = $captureMode === BaselineCaptureMode::FullContent + ? 'Select the target tenant. This will refresh content evidence on demand (redacted) before comparing.' + : 'Select the target tenant to compare its current inventory against the active baseline snapshot.'; + + return Action::make('compareNow') + ->label($label) + ->icon('heroicon-o-play') + ->requiresConfirmation() + ->modalHeading($label) + ->modalDescription($modalDescription) + ->form([ + Select::make('target_tenant_id') + ->label('Target Tenant') + ->options(fn (): array => $this->getEligibleCompareTenantOptions()) + ->required() + ->searchable(), + ]) + ->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === []) + ->action(function (array $data): void { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + /** @var BaselineProfile $profile */ + $profile = $this->getRecord(); + + $targetTenant = Tenant::query()->find((int) $data['target_tenant_id']); + + if (! $targetTenant instanceof Tenant || (int) $targetTenant->workspace_id !== (int) $profile->workspace_id) { + Notification::make() + ->title('Target tenant not found') + ->danger() + ->send(); + + return; + } + + $assignment = BaselineTenantAssignment::query() + ->where('workspace_id', (int) $profile->workspace_id) + ->where('tenant_id', (int) $targetTenant->getKey()) + ->where('baseline_profile_id', (int) $profile->getKey()) + ->first(); + + if (! $assignment instanceof BaselineTenantAssignment) { + Notification::make() + ->title('Tenant not assigned') + ->body('This tenant is not assigned to this baseline profile.') + ->warning() + ->send(); + + return; + } + + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $targetTenant, Capabilities::TENANT_SYNC)) { + Notification::make() + ->title('Permission denied') + ->danger() + ->send(); + + return; + } + + $service = app(BaselineCompareService::class); + $result = $service->startCompare($targetTenant, $user); + + if (! ($result['ok'] ?? false)) { + $reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown'; + + $message = match ($reasonCode) { + BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.', + BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.', + BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT => 'This baseline profile has no active snapshot.', + default => 'Reason: '.str_replace('.', ' ', $reasonCode), + }; + + Notification::make() + ->title('Cannot start comparison') + ->body($message) + ->danger() + ->send(); + + return; + } + + $run = $result['run'] ?? null; + + if (! $run instanceof \App\Models\OperationRun) { + Notification::make() + ->title('Cannot start comparison') + ->body('Reason: missing operation run') + ->danger() + ->send(); + + return; + } + + $viewAction = Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($run, $targetTenant)); + + 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(); + }); + } + /** * @return array */ @@ -142,6 +307,55 @@ private function getWorkspaceTenantOptions(): array ->all(); } + /** + * @return array + */ + private function getEligibleCompareTenantOptions(): array + { + $user = auth()->user(); + + if (! $user instanceof User) { + return []; + } + + /** @var BaselineProfile $profile */ + $profile = $this->getRecord(); + + $tenantIds = BaselineTenantAssignment::query() + ->where('workspace_id', (int) $profile->workspace_id) + ->where('baseline_profile_id', (int) $profile->getKey()) + ->pluck('tenant_id') + ->all(); + + if ($tenantIds === []) { + return []; + } + + $resolver = app(CapabilityResolver::class); + + $options = []; + + $tenants = Tenant::query() + ->where('workspace_id', (int) $profile->workspace_id) + ->whereIn('id', $tenantIds) + ->orderBy('name') + ->get(['id', 'name']); + + foreach ($tenants as $tenant) { + if (! $tenant instanceof Tenant) { + continue; + } + + if (! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC)) { + continue; + } + + $options[(int) $tenant->getKey()] = (string) $tenant->name; + } + + return $options; + } + private function hasManageCapability(): bool { $user = auth()->user(); 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 @@ +iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)), TextEntry::make('fingerprint')->label('Fingerprint')->copyable(), TextEntry::make('scope_key')->label('Scope')->copyable(), - TextEntry::make('subject_display_name')->label('Subject')->placeholder('—'), + TextEntry::make('subject_display_name') + ->label('Subject') + ->placeholder('—') + ->state(function (Finding $record): ?string { + $state = $record->subject_display_name; + if (is_string($state) && trim($state) !== '') { + return $state; + } + + $fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name'); + $fallback = is_string($fallback) ? trim($fallback) : null; + + return $fallback !== '' ? $fallback : null; + }), TextEntry::make('subject_type')->label('Subject type'), TextEntry::make('subject_external_id')->label('External ID')->copyable(), TextEntry::make('baseline_operation_run_id') @@ -372,7 +385,19 @@ public static function table(Table $table): Table default => 'gray', }) ->sortable(), - Tables\Columns\TextColumn::make('subject_display_name')->label('Subject')->placeholder('—'), + Tables\Columns\TextColumn::make('subject_display_name') + ->label('Subject') + ->placeholder('—') + ->formatStateUsing(function (?string $state, Finding $record): ?string { + if (is_string($state) && trim($state) !== '') { + return $state; + } + + $fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name'); + $fallback = is_string($fallback) ? trim($fallback) : null; + + return $fallback !== '' ? $fallback : null; + }), Tables\Columns\TextColumn::make('subject_type')->label('Subject type')->searchable(), Tables\Columns\TextColumn::make('due_at') ->label('Due') diff --git a/app/Filament/Resources/OperationRunResource.php b/app/Filament/Resources/OperationRunResource.php index 78d61ea..6188b35 100644 --- a/app/Filament/Resources/OperationRunResource.php +++ b/app/Filament/Resources/OperationRunResource.php @@ -10,6 +10,7 @@ use App\Models\VerificationCheckAcknowledgement; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; +use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\OperateHub\OperateHubShell; use App\Support\OperationCatalog; use App\Support\OperationRunLinks; @@ -218,6 +219,30 @@ public static function infolist(Schema $schema): Schema 'warnings', 'unproven' => '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 { @@ -259,6 +284,130 @@ public static function infolist(Schema $schema): Schema ->columns(2) ->columnSpanFull(), + Section::make('Baseline compare evidence') + ->schema([ + TextEntry::make('baseline_compare_subjects_total') + ->label('Subjects total') + ->getStateUsing(function (OperationRun $record): ?int { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_compare.subjects_total'); + + return is_numeric($value) ? (int) $value : null; + }) + ->placeholder('—'), + TextEntry::make('baseline_compare_gap_count') + ->label('Evidence gaps') + ->getStateUsing(function (OperationRun $record): ?int { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_compare.evidence_gaps.count'); + + return is_numeric($value) ? (int) $value : null; + }) + ->placeholder('—'), + TextEntry::make('baseline_compare_resume_token') + ->label('Resume token') + ->getStateUsing(function (OperationRun $record): ?string { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_compare.resume_token'); + + return is_string($value) && $value !== '' ? $value : null; + }) + ->copyable() + ->placeholder('—') + ->columnSpanFull() + ->visible(function (OperationRun $record): bool { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_compare.resume_token'); + + return is_string($value) && $value !== ''; + }), + ViewEntry::make('baseline_compare_evidence_capture') + ->label('Evidence capture') + ->view('filament.infolists.entries.snapshot-json') + ->state(function (OperationRun $record): array { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_compare.evidence_capture'); + + return is_array($value) ? $value : []; + }) + ->columnSpanFull(), + ViewEntry::make('baseline_compare_evidence_gaps') + ->label('Evidence gaps') + ->view('filament.infolists.entries.snapshot-json') + ->state(function (OperationRun $record): array { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_compare.evidence_gaps'); + + return is_array($value) ? $value : []; + }) + ->columnSpanFull(), + ]) + ->visible(fn (OperationRun $record): bool => (string) $record->type === 'baseline_compare') + ->columns(2) + ->columnSpanFull(), + + Section::make('Baseline capture evidence') + ->schema([ + TextEntry::make('baseline_capture_subjects_total') + ->label('Subjects total') + ->getStateUsing(function (OperationRun $record): ?int { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_capture.subjects_total'); + + return is_numeric($value) ? (int) $value : null; + }) + ->placeholder('—'), + TextEntry::make('baseline_capture_gap_count') + ->label('Gaps') + ->getStateUsing(function (OperationRun $record): ?int { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_capture.gaps.count'); + + return is_numeric($value) ? (int) $value : null; + }) + ->placeholder('—'), + TextEntry::make('baseline_capture_resume_token') + ->label('Resume token') + ->getStateUsing(function (OperationRun $record): ?string { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_capture.resume_token'); + + return is_string($value) && $value !== '' ? $value : null; + }) + ->copyable() + ->placeholder('—') + ->columnSpanFull() + ->visible(function (OperationRun $record): bool { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_capture.resume_token'); + + return is_string($value) && $value !== ''; + }), + ViewEntry::make('baseline_capture_evidence_capture') + ->label('Evidence capture') + ->view('filament.infolists.entries.snapshot-json') + ->state(function (OperationRun $record): array { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_capture.evidence_capture'); + + return is_array($value) ? $value : []; + }) + ->columnSpanFull(), + ViewEntry::make('baseline_capture_gaps') + ->label('Gaps') + ->view('filament.infolists.entries.snapshot-json') + ->state(function (OperationRun $record): array { + $context = is_array($record->context) ? $record->context : []; + $value = data_get($context, 'baseline_capture.gaps'); + + return is_array($value) ? $value : []; + }) + ->columnSpanFull(), + ]) + ->visible(fn (OperationRun $record): bool => (string) $record->type === 'baseline_capture') + ->columns(2) + ->columnSpanFull(), + Section::make('Verification report') ->schema([ ViewEntry::make('verification_report') 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 685b459..f8148f1 100644 --- a/app/Jobs/CaptureBaselineSnapshotJob.php +++ b/app/Jobs/CaptureBaselineSnapshotJob.php @@ -10,14 +10,19 @@ use App\Models\OperationRun; use App\Models\Tenant; use App\Models\User; +use App\Services\Baselines\BaselineContentCapturePhase; use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Baselines\CurrentStateHashResolver; use App\Services\Baselines\Evidence\ResolvedEvidence; use App\Services\Baselines\InventoryMetaContract; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Support\Baselines\BaselineCaptureMode; +use App\Support\Baselines\BaselineFullContentRolloutGate; use App\Support\Baselines\BaselineProfileStatus; use App\Support\Baselines\BaselineScope; +use App\Support\Baselines\BaselineSubjectKey; +use App\Support\Baselines\PolicyVersionCapturePurpose; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use Illuminate\Bus\Queueable; @@ -53,8 +58,12 @@ public function handle( AuditLogger $auditLogger, OperationRunService $operationRunService, ?CurrentStateHashResolver $hashResolver = null, + ?BaselineContentCapturePhase $contentCapturePhase = null, + ?BaselineFullContentRolloutGate $rolloutGate = null, ): void { $hashResolver ??= app(CurrentStateHashResolver::class); + $contentCapturePhase ??= app(BaselineContentCapturePhase::class); + $rolloutGate ??= app(BaselineFullContentRolloutGate::class); if (! $this->operationRun instanceof OperationRun) { $this->fail(new RuntimeException('OperationRun context is required for CaptureBaselineSnapshotJob.')); @@ -84,107 +93,69 @@ public function handle( $effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null); - $this->auditStarted($auditLogger, $sourceTenant, $profile, $initiator); + $captureMode = $profile->capture_mode instanceof BaselineCaptureMode + ? $profile->capture_mode + : BaselineCaptureMode::Opportunistic; - $snapshotItems = $this->collectSnapshotItems($sourceTenant, $effectiveScope, $metaContract, $hashResolver); - - $identityHash = $identity->computeIdentity($snapshotItems); - - $snapshot = $this->findOrCreateSnapshot( - $profile, - $identityHash, - $snapshotItems, - ); - - $wasNewSnapshot = $snapshot->wasRecentlyCreated; - - if ($profile->status === BaselineProfileStatus::Active) { - $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + if ($captureMode === BaselineCaptureMode::FullContent) { + $rolloutGate->assertEnabled(); } - $summaryCounts = [ - 'total' => count($snapshotItems), - 'processed' => count($snapshotItems), - 'succeeded' => count($snapshotItems), - 'failed' => 0, - ]; + $inventoryResult = $this->collectInventorySubjects($sourceTenant, $effectiveScope); - $operationRunService->updateRun( - $this->operationRun, - status: OperationRunStatus::Completed->value, - outcome: OperationRunOutcome::Succeeded->value, - summaryCounts: $summaryCounts, + $subjects = $inventoryResult['subjects']; + $inventoryByKey = $inventoryResult['inventory_by_key']; + $subjectsTotal = $inventoryResult['subjects_total']; + $captureGaps = $inventoryResult['gaps']; + + $this->auditStarted( + auditLogger: $auditLogger, + tenant: $sourceTenant, + profile: $profile, + initiator: $initiator, + captureMode: $captureMode, + subjectsTotal: $subjectsTotal, + effectiveScope: $effectiveScope, ); - $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; - $updatedContext['result'] = [ - 'snapshot_id' => (int) $snapshot->getKey(), - 'snapshot_identity_hash' => $identityHash, - 'was_new_snapshot' => $wasNewSnapshot, - 'items_captured' => count($snapshotItems), + $phaseStats = [ + 'requested' => 0, + 'succeeded' => 0, + 'skipped' => 0, + 'failed' => 0, + 'throttled' => 0, ]; - $this->operationRun->update(['context' => $updatedContext]); + $phaseGaps = []; + $resumeToken = null; - $this->auditCompleted($auditLogger, $sourceTenant, $profile, $snapshot, $initiator, $snapshotItems); - } + if ($captureMode === BaselineCaptureMode::FullContent) { + $budgets = [ + 'max_items_per_run' => (int) config('tenantpilot.baselines.full_content_capture.max_items_per_run', 200), + 'max_concurrency' => (int) config('tenantpilot.baselines.full_content_capture.max_concurrency', 5), + 'max_retries' => (int) config('tenantpilot.baselines.full_content_capture.max_retries', 3), + ]; - /** - * @return array}> - */ - private function collectSnapshotItems( - Tenant $sourceTenant, - BaselineScope $scope, - InventoryMetaContract $metaContract, - CurrentStateHashResolver $hashResolver, - ): array { - $query = InventoryItem::query() - ->where('tenant_id', $sourceTenant->getKey()); + $resumeTokenIn = null; - $query->whereIn('policy_type', $scope->allTypes()); + if (is_array($context['baseline_capture'] ?? null)) { + $resumeTokenIn = $context['baseline_capture']['resume_token'] ?? null; + } - /** - * @var array - * }> - */ - $inventoryByKey = []; + $phaseResult = $contentCapturePhase->capture( + tenant: $sourceTenant, + subjects: $subjects, + purpose: PolicyVersionCapturePurpose::BaselineCapture, + budgets: $budgets, + resumeToken: is_string($resumeTokenIn) ? $resumeTokenIn : null, + operationRunId: (int) $this->operationRun->getKey(), + baselineProfileId: (int) $profile->getKey(), + createdBy: $initiator?->email, + ); - $query->orderBy('policy_type') - ->orderBy('external_id') - ->chunk(500, function ($inventoryItems) use (&$inventoryByKey, $metaContract): void { - foreach ($inventoryItems as $inventoryItem) { - $metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : []; - $contract = $metaContract->build( - policyType: (string) $inventoryItem->policy_type, - subjectExternalId: (string) $inventoryItem->external_id, - metaJsonb: $metaJsonb, - ); - - $key = (string) $inventoryItem->policy_type.'|'.(string) $inventoryItem->external_id; - - $inventoryByKey[$key] = [ - 'subject_external_id' => (string) $inventoryItem->external_id, - 'policy_type' => (string) $inventoryItem->policy_type, - 'display_name' => is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null, - 'category' => is_string($inventoryItem->category) ? $inventoryItem->category : null, - 'platform' => is_string($inventoryItem->platform) ? $inventoryItem->platform : null, - 'meta_contract' => $contract, - ]; - } - }); - - $subjects = array_values(array_map( - static fn (array $item): array => [ - 'policy_type' => (string) $item['policy_type'], - 'subject_external_id' => (string) $item['subject_external_id'], - ], - $inventoryByKey, - )); + $phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats; + $phaseGaps = is_array($phaseResult['gaps'] ?? null) ? $phaseResult['gaps'] : []; + $resumeToken = is_string($phaseResult['resume_token'] ?? null) ? $phaseResult['resume_token'] : null; + } $resolvedEvidence = $hashResolver->resolveForSubjects( tenant: $sourceTenant, @@ -193,40 +164,295 @@ private function collectSnapshotItems( latestInventorySyncRunId: null, ); + $snapshotItems = $this->buildSnapshotItems( + inventoryByKey: $inventoryByKey, + resolvedEvidence: $resolvedEvidence, + captureMode: $captureMode, + gaps: $captureGaps, + ); + + $items = $snapshotItems['items'] ?? []; + + $identityHash = $identity->computeIdentity($items); + + $gapsByReason = $this->mergeGapCounts($captureGaps, $phaseGaps); + $gapsCount = array_sum($gapsByReason); + + $snapshotSummary = [ + 'total_items' => count($items), + 'policy_type_counts' => $this->countByPolicyType($items), + 'fidelity_counts' => $snapshotItems['fidelity_counts'] ?? ['content' => 0, 'meta' => 0], + 'gaps' => [ + 'count' => $gapsCount, + 'by_reason' => $gapsByReason, + ], + ]; + + $snapshot = $this->findOrCreateSnapshot( + $profile, + $identityHash, + $items, + $snapshotSummary, + ); + + $wasNewSnapshot = $snapshot->wasRecentlyCreated; + + if ($profile->status === BaselineProfileStatus::Active) { + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + } + + $warningsRecorded = $gapsByReason !== [] || $resumeToken !== null; + $warningsRecorded = $warningsRecorded || ($captureMode === BaselineCaptureMode::FullContent && ($snapshotItems['fidelity_counts']['meta'] ?? 0) > 0); + $outcome = $warningsRecorded ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value; + + $summaryCounts = [ + 'total' => $subjectsTotal, + 'processed' => $subjectsTotal, + 'succeeded' => $snapshotItems['items_count'], + 'failed' => max(0, $subjectsTotal - $snapshotItems['items_count']), + ]; + + $operationRunService->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: $outcome, + summaryCounts: $summaryCounts, + ); + + $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $updatedContext['baseline_capture'] = array_merge( + is_array($updatedContext['baseline_capture'] ?? null) ? $updatedContext['baseline_capture'] : [], + [ + 'subjects_total' => $subjectsTotal, + 'evidence_capture' => $phaseStats, + 'gaps' => [ + 'count' => $gapsCount, + 'by_reason' => $gapsByReason, + ], + 'resume_token' => $resumeToken, + ], + ); + $updatedContext['result'] = [ + 'snapshot_id' => (int) $snapshot->getKey(), + 'snapshot_identity_hash' => $identityHash, + 'was_new_snapshot' => $wasNewSnapshot, + 'items_captured' => $snapshotItems['items_count'], + ]; + $this->operationRun->update(['context' => $updatedContext]); + + $this->auditCompleted( + auditLogger: $auditLogger, + tenant: $sourceTenant, + profile: $profile, + snapshot: $snapshot, + initiator: $initiator, + captureMode: $captureMode, + subjectsTotal: $subjectsTotal, + wasNewSnapshot: $wasNewSnapshot, + evidenceCaptureStats: $phaseStats, + gaps: [ + 'count' => $gapsCount, + 'by_reason' => $gapsByReason, + ], + ); + } + + /** + * @return array{ + * subjects_total: int, + * subjects: list, + * inventory_by_key: array, + * gaps: array + * } + */ + private function collectInventorySubjects( + Tenant $sourceTenant, + BaselineScope $scope, + ): array { + $query = InventoryItem::query() + ->where('tenant_id', $sourceTenant->getKey()); + + $query->whereIn('policy_type', $scope->allTypes()); + + /** @var array $inventoryByKey */ + $inventoryByKey = []; + + /** @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, &$gaps, &$ambiguousKeys, &$subjectKeyToInventoryKey): void { + foreach ($inventoryItems as $inventoryItem) { + $metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : []; + $displayName = is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null; + $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); + + if ($subjectKey === null) { + $gaps['missing_subject_key'] = ($gaps['missing_subject_key'] ?? 0) + 1; + + 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: $policyType, + subjectKey: $subjectKey, + ); + + $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' => $policyType, + 'display_name' => $displayName, + 'category' => is_string($inventoryItem->category) ? $inventoryItem->category : null, + 'platform' => is_string($inventoryItem->platform) ? $inventoryItem->platform : null, + ]; + } + }); + + ksort($gaps); + + $subjects = array_values(array_map( + static fn (array $item): array => [ + 'policy_type' => (string) $item['policy_type'], + 'subject_external_id' => (string) $item['tenant_subject_external_id'], + ], + $inventoryByKey, + )); + + return [ + 'subjects_total' => count($subjects), + 'subjects' => $subjects, + 'inventory_by_key' => $inventoryByKey, + 'gaps' => $gaps, + ]; + } + + /** + * @param array $inventoryByKey + * @param array $resolvedEvidence + * @param array $gaps + * @return array{ + * items: array + * }>, + * items_count: int, + * fidelity_counts: array{content: int, meta: int} + * } + */ + private function buildSnapshotItems( + array $inventoryByKey, + array $resolvedEvidence, + BaselineCaptureMode $captureMode, + array &$gaps, + ): array { $items = []; + $fidelityCounts = ['content' => 0, 'meta' => 0]; foreach ($inventoryByKey as $key => $inventoryItem) { $evidence = $resolvedEvidence[$key] ?? null; if (! $evidence instanceof ResolvedEvidence) { + $gaps['missing_evidence'] = ($gaps['missing_evidence'] ?? 0) + 1; + continue; } + $provenance = $evidence->provenance(); + unset($provenance['observed_operation_run_id']); + + $fidelity = (string) ($provenance['fidelity'] ?? 'meta'); + $fidelityCounts[$fidelity === 'content' ? 'content' : 'meta']++; + + if ($captureMode === BaselineCaptureMode::FullContent && $fidelity !== 'content') { + $gaps['meta_fallback'] = ($gaps['meta_fallback'] ?? 0) + 1; + } + $items[] = [ 'subject_type' => 'policy', - 'subject_external_id' => (string) $inventoryItem['subject_external_id'], + 'subject_external_id' => (string) $inventoryItem['workspace_subject_external_id'], + 'subject_key' => (string) $inventoryItem['subject_key'], 'policy_type' => (string) $inventoryItem['policy_type'], 'baseline_hash' => $evidence->hash, 'meta_jsonb' => [ 'display_name' => $inventoryItem['display_name'], 'category' => $inventoryItem['category'], 'platform' => $inventoryItem['platform'], - 'meta_contract' => $inventoryItem['meta_contract'], - 'evidence' => $evidence->provenance(), + 'evidence' => $provenance, ], ]; } - return $items; + return [ + 'items' => $items, + 'items_count' => count($items), + 'fidelity_counts' => $fidelityCounts, + ]; } - /** - * @param array}> $snapshotItems - */ private function findOrCreateSnapshot( BaselineProfile $profile, string $identityHash, array $snapshotItems, + array $summaryJsonb, ): BaselineSnapshot { $existing = BaselineSnapshot::query() ->where('workspace_id', $profile->workspace_id) @@ -243,10 +469,7 @@ private function findOrCreateSnapshot( 'baseline_profile_id' => (int) $profile->getKey(), 'snapshot_identity_hash' => $identityHash, 'captured_at' => now(), - 'summary_jsonb' => [ - 'total_items' => count($snapshotItems), - 'policy_type_counts' => $this->countByPolicyType($snapshotItems), - ], + 'summary_jsonb' => $summaryJsonb, ]); foreach (array_chunk($snapshotItems, 100) as $chunk) { @@ -255,6 +478,7 @@ private function findOrCreateSnapshot( 'baseline_snapshot_id' => (int) $snapshot->getKey(), 'subject_type' => $item['subject_type'], 'subject_external_id' => $item['subject_external_id'], + 'subject_key' => $item['subject_key'], 'policy_type' => $item['policy_type'], 'baseline_hash' => $item['baseline_hash'], 'meta_jsonb' => json_encode($item['meta_jsonb']), @@ -293,6 +517,9 @@ private function auditStarted( Tenant $tenant, BaselineProfile $profile, ?User $initiator, + BaselineCaptureMode $captureMode, + int $subjectsTotal, + BaselineScope $effectiveScope, ): void { $auditLogger->log( tenant: $tenant, @@ -302,6 +529,10 @@ private function auditStarted( 'operation_run_id' => (int) $this->operationRun->getKey(), 'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_name' => (string) $profile->name, + 'purpose' => PolicyVersionCapturePurpose::BaselineCapture->value, + 'capture_mode' => $captureMode->value, + 'scope_types_total' => count($effectiveScope->allTypes()), + 'subjects_total' => $subjectsTotal, ], ], actorId: $initiator?->id, @@ -318,7 +549,11 @@ private function auditCompleted( BaselineProfile $profile, BaselineSnapshot $snapshot, ?User $initiator, - array $snapshotItems, + BaselineCaptureMode $captureMode, + int $subjectsTotal, + bool $wasNewSnapshot, + array $evidenceCaptureStats, + array $gaps, ): void { $auditLogger->log( tenant: $tenant, @@ -328,10 +563,14 @@ private function auditCompleted( 'operation_run_id' => (int) $this->operationRun->getKey(), 'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_name' => (string) $profile->name, + 'purpose' => PolicyVersionCapturePurpose::BaselineCapture->value, + 'capture_mode' => $captureMode->value, + 'subjects_total' => $subjectsTotal, 'snapshot_id' => (int) $snapshot->getKey(), 'snapshot_identity_hash' => (string) $snapshot->snapshot_identity_hash, - 'items_captured' => count($snapshotItems), - 'was_new_snapshot' => $snapshot->wasRecentlyCreated, + 'was_new_snapshot' => $wasNewSnapshot, + 'evidence_capture' => $evidenceCaptureStats, + 'gaps' => $gaps, ], ], actorId: $initiator?->id, @@ -341,4 +580,27 @@ private function auditCompleted( resourceId: (string) $this->operationRun->getKey(), ); } + + /** + * @param array ...$gaps + * @return array + */ + private function mergeGapCounts(array ...$gaps): array + { + $merged = []; + + foreach ($gaps as $gapMap) { + foreach ($gapMap as $reason => $count) { + if (! is_string($reason) || $reason === '') { + continue; + } + + $merged[$reason] = ($merged[$reason] ?? 0) + (int) $count; + } + } + + ksort($merged); + + return $merged; + } } diff --git a/app/Jobs/CompareBaselineToTenantJob.php b/app/Jobs/CompareBaselineToTenantJob.php index 5bc273c..e903bf1 100644 --- a/app/Jobs/CompareBaselineToTenantJob.php +++ b/app/Jobs/CompareBaselineToTenantJob.php @@ -15,6 +15,7 @@ use App\Models\User; use App\Models\Workspace; use App\Services\Baselines\BaselineAutoCloseService; +use App\Services\Baselines\BaselineContentCapturePhase; use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Baselines\CurrentStateHashResolver; use App\Services\Baselines\Evidence\EvidenceProvenance; @@ -23,7 +24,12 @@ use App\Services\Intune\AuditLogger; 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; +use App\Support\Baselines\PolicyVersionCapturePurpose; use App\Support\Inventory\InventoryCoverage; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; @@ -64,11 +70,15 @@ public function handle( ?BaselineAutoCloseService $baselineAutoCloseService = null, ?CurrentStateHashResolver $hashResolver = null, ?MetaEvidenceProvider $metaEvidenceProvider = null, + ?BaselineContentCapturePhase $contentCapturePhase = null, + ?BaselineFullContentRolloutGate $rolloutGate = null, ): void { $settingsResolver ??= app(SettingsResolver::class); $baselineAutoCloseService ??= app(BaselineAutoCloseService::class); $hashResolver ??= app(CurrentStateHashResolver::class); $metaEvidenceProvider ??= app(MetaEvidenceProvider::class); + $contentCapturePhase ??= app(BaselineContentCapturePhase::class); + $rolloutGate ??= app(BaselineFullContentRolloutGate::class); if (! $this->operationRun instanceof OperationRun) { $this->fail(new RuntimeException('OperationRun context is required for CompareBaselineToTenantJob.')); @@ -106,9 +116,61 @@ public function handle( $effectiveTypes = $effectiveScope->allTypes(); $scopeKey = 'baseline_profile:'.$profile->getKey(); - $this->auditStarted($auditLogger, $tenant, $profile, $initiator); + $captureMode = $profile->capture_mode instanceof BaselineCaptureMode + ? $profile->capture_mode + : BaselineCaptureMode::Opportunistic; + + if ($captureMode === BaselineCaptureMode::FullContent) { + 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 === []) { + $this->auditStarted( + auditLogger: $auditLogger, + tenant: $tenant, + profile: $profile, + initiator: $initiator, + captureMode: $captureMode, + subjectsTotal: 0, + effectiveScope: $effectiveScope, + ); + $this->completeWithCoverageWarning( operationRunService: $operationRunService, auditLogger: $auditLogger, @@ -121,6 +183,9 @@ public function handle( coveredTypes: [], uncoveredTypes: [], errorsRecorded: 1, + captureMode: $captureMode, + reasonCode: BaselineCompareReasonCode::NoSubjectsInScope, + evidenceGapsByReason: [], ); return; @@ -132,6 +197,16 @@ public function handle( : null; if (! $inventorySyncRun instanceof OperationRun || ! $coverage instanceof InventoryCoverage) { + $this->auditStarted( + auditLogger: $auditLogger, + tenant: $tenant, + profile: $profile, + initiator: $initiator, + captureMode: $captureMode, + subjectsTotal: 0, + effectiveScope: $effectiveScope, + ); + $this->completeWithCoverageWarning( operationRunService: $operationRunService, auditLogger: $auditLogger, @@ -144,6 +219,7 @@ public function handle( coveredTypes: [], uncoveredTypes: $effectiveTypes, errorsRecorded: count($effectiveTypes), + captureMode: $captureMode, ); return; @@ -153,6 +229,16 @@ public function handle( $uncoveredTypes = array_values(array_diff($effectiveTypes, $coveredTypes)); if ($coveredTypes === []) { + $this->auditStarted( + auditLogger: $auditLogger, + tenant: $tenant, + profile: $profile, + initiator: $initiator, + captureMode: $captureMode, + subjectsTotal: 0, + effectiveScope: $effectiveScope, + ); + $this->completeWithCoverageWarning( operationRunService: $operationRunService, auditLogger: $auditLogger, @@ -165,6 +251,7 @@ public function handle( coveredTypes: [], uncoveredTypes: $effectiveTypes, errorsRecorded: count($effectiveTypes), + captureMode: $captureMode, ); return; @@ -184,8 +271,22 @@ public function handle( ? CarbonImmutable::instance($snapshot->captured_at) : null; - $baselineItems = $this->loadBaselineItems($snapshotId, $coveredTypes); - $currentItems = $this->loadCurrentInventory($tenant, $coveredTypes, (int) $inventorySyncRun->getKey()); + $baselineResult = $this->loadBaselineItems($snapshotId, $coveredTypes); + $baselineItems = $baselineResult['items']; + $baselineGaps = $baselineResult['gaps']; + + $currentResult = $this->loadCurrentInventory($tenant, $coveredTypes, (int) $inventorySyncRun->getKey()); + $currentItems = $currentResult['items']; + $currentGaps = $currentResult['gaps']; + + $ambiguousKeys = array_values(array_unique(array_filter(array_merge( + is_array($baselineResult['ambiguous_keys'] ?? null) ? $baselineResult['ambiguous_keys'] : [], + is_array($currentResult['ambiguous_keys'] ?? null) ? $currentResult['ambiguous_keys'] : [], + ), 'is_string'))); + + foreach ($ambiguousKeys as $ambiguousKey) { + unset($baselineItems[$ambiguousKey], $currentItems[$ambiguousKey]); + } $subjects = array_values(array_map( static fn (array $item): array => [ @@ -195,20 +296,81 @@ public function handle( $currentItems, )); - $resolvedCurrentEvidence = $hashResolver->resolveForSubjects( + $subjectsTotal = count($subjects); + + $this->auditStarted( + auditLogger: $auditLogger, + tenant: $tenant, + profile: $profile, + initiator: $initiator, + captureMode: $captureMode, + subjectsTotal: $subjectsTotal, + effectiveScope: $effectiveScope, + ); + + $phaseStats = [ + 'requested' => 0, + 'succeeded' => 0, + 'skipped' => 0, + 'failed' => 0, + 'throttled' => 0, + ]; + $phaseGaps = []; + $resumeToken = null; + + if ($captureMode === BaselineCaptureMode::FullContent) { + $budgets = [ + 'max_items_per_run' => (int) config('tenantpilot.baselines.full_content_capture.max_items_per_run', 200), + 'max_concurrency' => (int) config('tenantpilot.baselines.full_content_capture.max_concurrency', 5), + 'max_retries' => (int) config('tenantpilot.baselines.full_content_capture.max_retries', 3), + ]; + + $resumeTokenIn = null; + + if (is_array($context['baseline_compare'] ?? null)) { + $resumeTokenIn = $context['baseline_compare']['resume_token'] ?? null; + } + + $phaseResult = $contentCapturePhase->capture( + tenant: $tenant, + subjects: $subjects, + purpose: PolicyVersionCapturePurpose::BaselineCompare, + budgets: $budgets, + resumeToken: is_string($resumeTokenIn) ? $resumeTokenIn : null, + operationRunId: (int) $this->operationRun->getKey(), + baselineProfileId: (int) $profile->getKey(), + createdBy: $initiator?->email, + ); + + $phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats; + $phaseGaps = is_array($phaseResult['gaps'] ?? null) ? $phaseResult['gaps'] : []; + $resumeToken = is_string($phaseResult['resume_token'] ?? null) ? $phaseResult['resume_token'] : null; + } + + $resolvedCurrentEvidenceByExternalId = $hashResolver->resolveForSubjects( tenant: $tenant, subjects: $subjects, since: $since, latestInventorySyncRunId: (int) $inventorySyncRun->getKey(), ); - $resolvedCurrentMetaEvidence = $metaEvidenceProvider->resolve( + $resolvedCurrentMetaEvidenceByExternalId = $metaEvidenceProvider->resolve( tenant: $tenant, subjects: $subjects, since: $since, latestInventorySyncRunId: (int) $inventorySyncRun->getKey(), ); + $resolvedCurrentEvidence = $this->rekeyResolvedEvidenceBySubjectKey( + currentItems: $currentItems, + resolvedByExternalId: $resolvedCurrentEvidenceByExternalId, + ); + + $resolvedCurrentMetaEvidence = $this->rekeyResolvedEvidenceBySubjectKey( + currentItems: $currentItems, + resolvedByExternalId: $resolvedCurrentMetaEvidenceByExternalId, + ); + $resolvedEffectiveCurrentEvidence = $this->resolveEffectiveCurrentEvidence( baselineItems: $baselineItems, currentItems: $currentItems, @@ -223,12 +385,11 @@ public function handle( $this->resolveSeverityMapping($workspace, $settingsResolver), ); $driftResults = $computeResult['drift']; - $evidenceGaps = $computeResult['evidence_gaps']; + $driftGaps = $computeResult['evidence_gaps']; $upsertResult = $this->upsertFindings( $tenant, $profile, - $snapshotId, $scopeKey, $driftResults, ); @@ -236,6 +397,9 @@ public function handle( $severityBreakdown = $this->countBySeverity($driftResults); $countsByChangeType = $this->countByChangeType($driftResults); + $gapsByReason = $this->mergeGapCounts($baselineGaps, $currentGaps, $phaseGaps, $driftGaps); + $gapsCount = array_sum($gapsByReason); + $summaryCounts = [ 'total' => count($driftResults), 'processed' => count($driftResults), @@ -250,10 +414,13 @@ public function handle( 'findings_unchanged' => (int) $upsertResult['unchanged_count'], ]; + $warningsRecorded = $uncoveredTypes !== [] || $resumeToken !== null || $gapsByReason !== []; + $outcome = $warningsRecorded ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value; + $operationRunService->updateRun( $this->operationRun, status: OperationRunStatus::Completed->value, - outcome: $uncoveredTypes !== [] ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value, + outcome: $outcome, summaryCounts: $summaryCounts, ); @@ -272,26 +439,46 @@ public function handle( $operationRunService->updateRun( $this->operationRun, status: OperationRunStatus::Completed->value, - outcome: OperationRunOutcome::Succeeded->value, + outcome: $outcome, summaryCounts: $summaryCounts, ); } - $coverageBreakdown = $this->summarizeCurrentEvidenceCoverage($currentItems, $resolvedCurrentEvidence); + $coverageBreakdown = $this->summarizeCurrentEvidenceCoverage($currentItems, $resolvedEffectiveCurrentEvidence); $baselineCoverage = $this->summarizeBaselineEvidenceCoverage($baselineItems); $overallFidelity = ($baselineCoverage['baseline_meta'] ?? 0) > 0 || ($coverageBreakdown['resolved_meta'] ?? 0) > 0 - || ($evidenceGaps['missing_current'] ?? 0) > 0 + || ($gapsByReason['missing_current'] ?? 0) > 0 ? 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'] : [], [ 'inventory_sync_run_id' => (int) $inventorySyncRun->getKey(), 'since' => $since?->toIso8601String(), + 'subjects_total' => $subjectsTotal, + 'evidence_capture' => $phaseStats, + 'evidence_gaps' => [ + 'count' => $gapsCount, + 'by_reason' => $gapsByReason, + ...$gapsByReason, + ], + 'resume_token' => $resumeToken, 'coverage' => [ 'effective_types' => $effectiveTypes, 'covered_types' => $coveredTypes, @@ -301,7 +488,7 @@ public function handle( ...$baselineCoverage, ], 'fidelity' => $overallFidelity, - 'evidence_gaps' => $evidenceGaps, + 'reason_code' => $reasonCode?->value, ], ); $updatedContext['findings'] = array_merge( @@ -318,7 +505,20 @@ public function handle( ]; $this->operationRun->update(['context' => $updatedContext]); - $this->auditCompleted($auditLogger, $tenant, $profile, $initiator, $summaryCounts); + $this->auditCompleted( + auditLogger: $auditLogger, + tenant: $tenant, + profile: $profile, + initiator: $initiator, + captureMode: $captureMode, + subjectsTotal: $subjectsTotal, + evidenceCaptureStats: $phaseStats, + gaps: [ + 'count' => $gapsCount, + 'by_reason' => $gapsByReason, + ], + summaryCounts: $summaryCounts, + ); } /** @@ -379,6 +579,34 @@ private function resolveEffectiveCurrentEvidence( return $effective; } + /** + * Rekey resolved evidence from "policy_type|external_id" to the current items key ("policy_type|subject_key"). + * + * @param array $currentItems + * @param array $resolvedByExternalId + * @return array + */ + private function rekeyResolvedEvidenceBySubjectKey(array $currentItems, array $resolvedByExternalId): array + { + $rekeyed = []; + + foreach ($currentItems as $key => $currentItem) { + $policyType = (string) ($currentItem['policy_type'] ?? ''); + $externalId = (string) ($currentItem['subject_external_id'] ?? ''); + + if ($policyType === '' || $externalId === '') { + $rekeyed[$key] = null; + + continue; + } + + $resolvedKey = $policyType.'|'.$externalId; + $rekeyed[$key] = $resolvedByExternalId[$resolvedKey] ?? null; + } + + return $rekeyed; + } + private function completeWithCoverageWarning( OperationRunService $operationRunService, AuditLogger $auditLogger, @@ -391,6 +619,9 @@ private function completeWithCoverageWarning( array $coveredTypes, array $uncoveredTypes, int $errorsRecorded, + BaselineCaptureMode $captureMode, + BaselineCompareReasonCode $reasonCode = BaselineCompareReasonCode::CoverageUnproven, + ?array $evidenceGapsByReason = null, ): void { $summaryCounts = [ 'total' => 0, @@ -414,10 +645,31 @@ private function completeWithCoverageWarning( ); $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $evidenceCapture = [ + 'requested' => 0, + 'succeeded' => 0, + 'skipped' => 0, + 'failed' => 0, + 'throttled' => 0, + ]; + + $evidenceGapsByReason ??= [ + BaselineCompareReasonCode::CoverageUnproven->value => max(1, $errorsRecorded), + ]; + $updatedContext['baseline_compare'] = array_merge( is_array($updatedContext['baseline_compare'] ?? null) ? $updatedContext['baseline_compare'] : [], [ 'inventory_sync_run_id' => $inventorySyncRun instanceof OperationRun ? (int) $inventorySyncRun->getKey() : null, + 'subjects_total' => 0, + 'evidence_capture' => $evidenceCapture, + 'evidence_gaps' => [ + 'count' => array_sum($evidenceGapsByReason), + 'by_reason' => $evidenceGapsByReason, + ...$evidenceGapsByReason, + ], + 'resume_token' => null, + 'reason_code' => $reasonCode->value, 'coverage' => [ 'effective_types' => array_values($effectiveTypes), 'covered_types' => array_values($coveredTypes), @@ -431,11 +683,6 @@ private function completeWithCoverageWarning( 'policy_types_meta_only' => [], ], 'fidelity' => 'meta', - 'evidence_gaps' => [ - 'missing_baseline' => 0, - 'missing_current' => 0, - 'missing_both' => 0, - ], ], ); $updatedContext['findings'] = array_merge( @@ -453,20 +700,47 @@ private function completeWithCoverageWarning( $this->operationRun->update(['context' => $updatedContext]); - $this->auditCompleted($auditLogger, $tenant, $profile, $initiator, $summaryCounts); + $this->auditCompleted( + auditLogger: $auditLogger, + tenant: $tenant, + profile: $profile, + initiator: $initiator, + captureMode: $captureMode, + subjectsTotal: 0, + evidenceCaptureStats: $evidenceCapture, + gaps: [ + 'count' => array_sum($evidenceGapsByReason), + 'by_reason' => $evidenceGapsByReason, + ], + summaryCounts: $summaryCounts, + ); } /** - * Load baseline snapshot items keyed by "policy_type|subject_external_id". + * Load baseline snapshot items keyed by "policy_type|subject_key". * - * @return array}> + * @return array{ + * items: array}>, + * gaps: array, + * ambiguous_keys: list + * } */ private function loadBaselineItems(int $snapshotId, array $policyTypes): array { $items = []; + $gaps = []; + + /** + * @var array + */ + $ambiguousKeys = []; if ($policyTypes === []) { - return $items; + return [ + 'items' => $items, + 'gaps' => $gaps, + 'ambiguous_keys' => [], + ]; } $query = BaselineSnapshotItem::query() @@ -476,26 +750,68 @@ private function loadBaselineItems(int $snapshotId, array $policyTypes): array $query ->orderBy('id') - ->chunk(500, function ($snapshotItems) use (&$items): void { + ->chunk(500, function ($snapshotItems) use (&$items, &$gaps, &$ambiguousKeys): void { foreach ($snapshotItems as $item) { - $key = $item->policy_type.'|'.$item->subject_external_id; + $metaJsonb = is_array($item->meta_jsonb) ? $item->meta_jsonb : []; + + $subjectKey = is_string($item->subject_key) ? trim((string) $item->subject_key) : ''; + + if ($subjectKey === '') { + $displayName = $metaJsonb['display_name'] ?? ($metaJsonb['displayName'] ?? null); + $subjectKey = BaselineSubjectKey::fromDisplayName(is_string($displayName) ? $displayName : null) ?? ''; + } else { + $subjectKey = BaselineSubjectKey::fromDisplayName($subjectKey) ?? ''; + } + + if ($subjectKey === '') { + $gaps['missing_subject_key_baseline'] = ($gaps['missing_subject_key_baseline'] ?? 0) + 1; + + continue; + } + + $key = $item->policy_type.'|'.$subjectKey; + + if (array_key_exists($key, $ambiguousKeys)) { + continue; + } + + if (array_key_exists($key, $items)) { + $ambiguousKeys[$key] = true; + unset($items[$key]); + + $gaps['ambiguous_match'] = ($gaps['ambiguous_match'] ?? 0) + 1; + + continue; + } + $items[$key] = [ 'subject_type' => (string) $item->subject_type, 'subject_external_id' => (string) $item->subject_external_id, + 'subject_key' => $subjectKey, 'policy_type' => (string) $item->policy_type, 'baseline_hash' => (string) $item->baseline_hash, - 'meta_jsonb' => is_array($item->meta_jsonb) ? $item->meta_jsonb : [], + 'meta_jsonb' => $metaJsonb, ]; } }); - return $items; + ksort($gaps); + + return [ + 'items' => $items, + 'gaps' => $gaps, + 'ambiguous_keys' => array_values(array_keys($ambiguousKeys)), + ]; } /** - * Load current inventory items keyed by "policy_type|external_id". + * Load current inventory items keyed by "policy_type|subject_key". * - * @return array}> + * @return array{ + * items: array}>, + * gaps: array, + * ambiguous_keys: list + * } */ private function loadCurrentInventory( Tenant $tenant, @@ -510,20 +826,53 @@ private function loadCurrentInventory( } if ($policyTypes === []) { - return []; + return [ + 'items' => [], + 'gaps' => [], + 'ambiguous_keys' => [], + ]; } $query->whereIn('policy_type', $policyTypes); $items = []; + $gaps = []; + + /** + * @var array + */ + $ambiguousKeys = []; $query->orderBy('policy_type') ->orderBy('external_id') - ->chunk(500, function ($inventoryItems) use (&$items): void { + ->chunk(500, function ($inventoryItems) use (&$items, &$gaps, &$ambiguousKeys): void { foreach ($inventoryItems as $inventoryItem) { - $key = $inventoryItem->policy_type.'|'.$inventoryItem->external_id; + $subjectKey = BaselineSubjectKey::fromDisplayName(is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null) ?? ''; + + if ($subjectKey === '') { + $gaps['missing_subject_key_current'] = ($gaps['missing_subject_key_current'] ?? 0) + 1; + + continue; + } + + $key = $inventoryItem->policy_type.'|'.$subjectKey; + + if (array_key_exists($key, $ambiguousKeys)) { + continue; + } + + if (array_key_exists($key, $items)) { + $ambiguousKeys[$key] = true; + unset($items[$key]); + + $gaps['ambiguous_match'] = ($gaps['ambiguous_match'] ?? 0) + 1; + + continue; + } + $items[$key] = [ 'subject_external_id' => (string) $inventoryItem->external_id, + 'subject_key' => $subjectKey, 'policy_type' => (string) $inventoryItem->policy_type, 'meta_jsonb' => [ 'display_name' => $inventoryItem->display_name, @@ -534,7 +883,13 @@ private function loadCurrentInventory( } }); - return $items; + ksort($gaps); + + return [ + 'items' => $items, + 'gaps' => $gaps, + 'ambiguous_keys' => array_values(array_keys($ambiguousKeys)), + ]; } private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun @@ -553,11 +908,14 @@ private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun /** * Compare baseline items vs current inventory and produce drift results. * - * @param array}> $baselineItems - * @param array}> $currentItems + * @param array}> $baselineItems + * @param array}> $currentItems * @param array $resolvedCurrentEvidence * @param array $severityMapping - * @return array{drift: array}>, evidence_gaps: array{missing_baseline: int, missing_current: int, missing_both: int}} + * @return array{ + * drift: array}>, + * evidence_gaps: array + * } */ private function computeDrift(array $baselineItems, array $currentItems, array $resolvedCurrentEvidence, array $severityMapping): array { @@ -565,12 +923,15 @@ private function computeDrift(array $baselineItems, array $currentItems, array $ $missingCurrentEvidence = 0; foreach ($baselineItems as $key => $baselineItem) { - if (! array_key_exists($key, $currentItems)) { + $currentItem = $currentItems[$key] ?? null; + + if (! is_array($currentItem)) { $drift[] = [ 'change_type' => 'missing_policy', 'severity' => $this->severityForChangeType($severityMapping, 'missing_policy'), 'subject_type' => $baselineItem['subject_type'], 'subject_external_id' => $baselineItem['subject_external_id'], + 'subject_key' => $baselineItem['subject_key'], 'policy_type' => $baselineItem['policy_type'], 'evidence_fidelity' => EvidenceProvenance::FidelityMeta, 'baseline_hash' => $baselineItem['baseline_hash'], @@ -578,6 +939,7 @@ private function computeDrift(array $baselineItems, array $currentItems, array $ 'evidence' => [ 'change_type' => 'missing_policy', 'policy_type' => $baselineItem['policy_type'], + 'subject_key' => $baselineItem['subject_key'], 'display_name' => $baselineItem['meta_jsonb']['display_name'] ?? null, ], ]; @@ -597,12 +959,15 @@ private function computeDrift(array $baselineItems, array $currentItems, array $ $baselineProvenance = $this->baselineProvenanceFromMetaJsonb($baselineItem['meta_jsonb']); $baselineFidelity = (string) ($baselineProvenance['fidelity'] ?? EvidenceProvenance::FidelityMeta); $evidenceFidelity = EvidenceProvenance::weakerFidelity($baselineFidelity, $currentEvidence->fidelity); + $displayName = $currentItem['meta_jsonb']['display_name'] + ?? ($baselineItem['meta_jsonb']['display_name'] ?? null); $drift[] = [ 'change_type' => 'different_version', 'severity' => $this->severityForChangeType($severityMapping, 'different_version'), 'subject_type' => $baselineItem['subject_type'], - 'subject_external_id' => $baselineItem['subject_external_id'], + 'subject_external_id' => $currentItem['subject_external_id'], + 'subject_key' => $baselineItem['subject_key'], 'policy_type' => $baselineItem['policy_type'], 'evidence_fidelity' => $evidenceFidelity, 'baseline_hash' => $baselineItem['baseline_hash'], @@ -610,7 +975,8 @@ private function computeDrift(array $baselineItems, array $currentItems, array $ 'evidence' => [ 'change_type' => 'different_version', 'policy_type' => $baselineItem['policy_type'], - 'display_name' => $baselineItem['meta_jsonb']['display_name'] ?? null, + 'subject_key' => $baselineItem['subject_key'], + 'display_name' => $displayName, 'baseline_hash' => $baselineItem['baseline_hash'], 'current_hash' => $currentEvidence->hash, 'baseline' => [ @@ -619,7 +985,7 @@ private function computeDrift(array $baselineItems, array $currentItems, array $ ], 'current' => [ 'hash' => $currentEvidence->hash, - 'provenance' => $currentEvidence->provenance(), + 'provenance' => $currentEvidence->tenantProvenance(), ], ], ]; @@ -641,13 +1007,15 @@ private function computeDrift(array $baselineItems, array $currentItems, array $ 'severity' => $this->severityForChangeType($severityMapping, 'unexpected_policy'), 'subject_type' => 'policy', 'subject_external_id' => $currentItem['subject_external_id'], + 'subject_key' => $currentItem['subject_key'], 'policy_type' => $currentItem['policy_type'], - 'evidence_fidelity' => EvidenceProvenance::FidelityMeta, + 'evidence_fidelity' => $currentEvidence->fidelity, 'baseline_hash' => '', 'current_hash' => $currentEvidence->hash, 'evidence' => [ 'change_type' => 'unexpected_policy', 'policy_type' => $currentItem['policy_type'], + 'subject_key' => $currentItem['subject_key'], 'display_name' => $currentItem['meta_jsonb']['display_name'] ?? null, ], ]; @@ -657,13 +1025,40 @@ private function computeDrift(array $baselineItems, array $currentItems, array $ return [ 'drift' => $drift, 'evidence_gaps' => [ - 'missing_baseline' => 0, 'missing_current' => $missingCurrentEvidence, - 'missing_both' => 0, ], ]; } + /** + * @param array ...$gaps + * @return array + */ + private function mergeGapCounts(array ...$gaps): array + { + $merged = []; + + foreach ($gaps as $gap) { + foreach ($gap as $reason => $count) { + if (! is_string($reason) || ! is_numeric($count)) { + continue; + } + + $count = (int) $count; + + if ($count <= 0) { + continue; + } + + $merged[$reason] = ($merged[$reason] ?? 0) + $count; + } + } + + ksort($merged); + + return $merged; + } + /** * @param array}> $currentItems * @param array $resolvedCurrentEvidence @@ -858,17 +1253,17 @@ private function baselineProvenanceFromMetaJsonb(array $metaJsonb): array /** * Upsert drift findings using stable fingerprints. * - * @param array}> $driftResults + * @param array}> $driftResults * @return array{processed_count: int, created_count: int, reopened_count: int, unchanged_count: int, seen_fingerprints: array} */ private function upsertFindings( Tenant $tenant, BaselineProfile $profile, - int $baselineSnapshotId, string $scopeKey, array $driftResults, ): array { $tenantId = (int) $tenant->getKey(); + $baselineProfileId = (int) $profile->getKey(); $observedAt = CarbonImmutable::now(); $processedCount = 0; $createdCount = 0; @@ -877,12 +1272,18 @@ private function upsertFindings( $seenFingerprints = []; foreach ($driftResults as $driftItem) { + $subjectKey = (string) ($driftItem['subject_key'] ?? ''); + + if (trim($subjectKey) === '') { + continue; + } + $recurrenceKey = $this->recurrenceKey( tenantId: $tenantId, - baselineSnapshotId: $baselineSnapshotId, - policyType: $driftItem['policy_type'], - subjectExternalId: $driftItem['subject_external_id'], - changeType: $driftItem['change_type'], + baselineProfileId: $baselineProfileId, + policyType: (string) ($driftItem['policy_type'] ?? ''), + subjectKey: $subjectKey, + changeType: (string) ($driftItem['change_type'] ?? ''), ); $fingerprint = $recurrenceKey; @@ -985,20 +1386,20 @@ private function observeFinding(Finding $finding, CarbonImmutable $observedAt, i } /** - * Stable identity for baseline-compare findings, scoped to a baseline snapshot. + * Stable identity for baseline-compare findings, scoped to a baseline profile + subject key. */ private function recurrenceKey( int $tenantId, - int $baselineSnapshotId, + int $baselineProfileId, string $policyType, - string $subjectExternalId, + string $subjectKey, string $changeType, ): string { $parts = [ (string) $tenantId, - (string) $baselineSnapshotId, + (string) $baselineProfileId, $this->normalizeKeyPart($policyType), - $this->normalizeKeyPart($subjectExternalId), + $this->normalizeKeyPart($subjectKey), $this->normalizeKeyPart($changeType), ]; @@ -1092,6 +1493,9 @@ private function auditStarted( Tenant $tenant, BaselineProfile $profile, ?User $initiator, + BaselineCaptureMode $captureMode, + int $subjectsTotal, + BaselineScope $effectiveScope, ): void { $auditLogger->log( tenant: $tenant, @@ -1101,6 +1505,10 @@ private function auditStarted( 'operation_run_id' => (int) $this->operationRun->getKey(), 'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_name' => (string) $profile->name, + 'purpose' => PolicyVersionCapturePurpose::BaselineCompare->value, + 'capture_mode' => $captureMode->value, + 'scope_types_total' => count($effectiveScope->allTypes()), + 'subjects_total' => $subjectsTotal, ], ], actorId: $initiator?->id, @@ -1116,6 +1524,10 @@ private function auditCompleted( Tenant $tenant, BaselineProfile $profile, ?User $initiator, + BaselineCaptureMode $captureMode, + int $subjectsTotal, + array $evidenceCaptureStats, + array $gaps, array $summaryCounts, ): void { $auditLogger->log( @@ -1126,10 +1538,15 @@ private function auditCompleted( 'operation_run_id' => (int) $this->operationRun->getKey(), 'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_name' => (string) $profile->name, + 'purpose' => PolicyVersionCapturePurpose::BaselineCompare->value, + 'capture_mode' => $captureMode->value, + 'subjects_total' => $subjectsTotal, 'findings_total' => $summaryCounts['total'] ?? 0, 'high' => $summaryCounts['high'] ?? 0, 'medium' => $summaryCounts['medium'] ?? 0, 'low' => $summaryCounts['low'] ?? 0, + 'evidence_capture' => $evidenceCaptureStats, + 'gaps' => $gaps, ], ], actorId: $initiator?->id, diff --git a/app/Models/BaselineProfile.php b/app/Models/BaselineProfile.php index 4849a0c..9e13331 100644 --- a/app/Models/BaselineProfile.php +++ b/app/Models/BaselineProfile.php @@ -6,6 +6,7 @@ use App\Support\Baselines\BaselineProfileStatus; use App\Support\Baselines\BaselineScope; +use App\Support\Baselines\BaselineCaptureMode; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -39,6 +40,7 @@ class BaselineProfile extends Model 'description', 'version_label', 'status', + 'capture_mode', 'scope_jsonb', 'active_snapshot_id', 'created_by_user_id', @@ -51,6 +53,7 @@ protected function casts(): array { return [ 'status' => BaselineProfileStatus::class, + 'capture_mode' => BaselineCaptureMode::class, ]; } diff --git a/app/Models/PolicyVersion.php b/app/Models/PolicyVersion.php index a3d19bc..f035747 100644 --- a/app/Models/PolicyVersion.php +++ b/app/Models/PolicyVersion.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Support\Concerns\DerivesWorkspaceIdFromTenant; +use App\Support\Baselines\PolicyVersionCapturePurpose; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -22,6 +23,7 @@ class PolicyVersion extends Model 'assignments' => 'array', 'scope_tags' => 'array', 'captured_at' => 'datetime', + 'capture_purpose' => PolicyVersionCapturePurpose::class, ]; public function previous(): ?self @@ -45,6 +47,16 @@ public function policy(): BelongsTo return $this->belongsTo(Policy::class); } + public function operationRun(): BelongsTo + { + return $this->belongsTo(OperationRun::class); + } + + public function baselineProfile(): BelongsTo + { + return $this->belongsTo(BaselineProfile::class); + } + public function scopePruneEligible($query, int $days = 90) { return $query 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/BaselineCaptureService.php b/app/Services/Baselines/BaselineCaptureService.php index 2e0b5cd..4b25966 100644 --- a/app/Services/Baselines/BaselineCaptureService.php +++ b/app/Services/Baselines/BaselineCaptureService.php @@ -10,6 +10,8 @@ use App\Models\Tenant; use App\Models\User; use App\Services\OperationRunService; +use App\Support\Baselines\BaselineCaptureMode; +use App\Support\Baselines\BaselineFullContentRolloutGate; use App\Support\Baselines\BaselineProfileStatus; use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineScope; @@ -19,6 +21,7 @@ final class BaselineCaptureService { public function __construct( private readonly OperationRunService $runs, + private readonly BaselineFullContentRolloutGate $rolloutGate, ) {} /** @@ -39,10 +42,19 @@ public function startCapture( is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null, ); + $captureMode = $profile->capture_mode instanceof BaselineCaptureMode + ? $profile->capture_mode + : BaselineCaptureMode::Opportunistic; + $context = [ + 'target_scope' => [ + 'entra_tenant_id' => $sourceTenant->graphTenantId(), + 'entra_tenant_name' => (string) $sourceTenant->name, + ], 'baseline_profile_id' => (int) $profile->getKey(), 'source_tenant_id' => (int) $sourceTenant->getKey(), 'effective_scope' => $effectiveScope->toEffectiveScopeContext(), + 'capture_mode' => $captureMode->value, ]; $run = $this->runs->ensureRunWithIdentity( @@ -68,6 +80,10 @@ private function validatePreconditions(BaselineProfile $profile, Tenant $sourceT return BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE; } + if ($profile->capture_mode === BaselineCaptureMode::FullContent && ! $this->rolloutGate->enabled()) { + return BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED; + } + if ($sourceTenant->workspace_id === null) { return BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT; } diff --git a/app/Services/Baselines/BaselineCompareService.php b/app/Services/Baselines/BaselineCompareService.php index a6e5a02..3f0d912 100644 --- a/app/Services/Baselines/BaselineCompareService.php +++ b/app/Services/Baselines/BaselineCompareService.php @@ -12,6 +12,8 @@ use App\Models\Tenant; use App\Models\User; use App\Services\OperationRunService; +use App\Support\Baselines\BaselineCaptureMode; +use App\Support\Baselines\BaselineFullContentRolloutGate; use App\Support\Baselines\BaselineProfileStatus; use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineScope; @@ -21,6 +23,7 @@ final class BaselineCompareService { public function __construct( private readonly OperationRunService $runs, + private readonly BaselineFullContentRolloutGate $rolloutGate, ) {} /** @@ -78,10 +81,19 @@ public function startCompare( $effectiveScope = BaselineScope::effective($profileScope, $overrideScope); + $captureMode = $profile->capture_mode instanceof BaselineCaptureMode + ? $profile->capture_mode + : BaselineCaptureMode::Opportunistic; + $context = [ + 'target_scope' => [ + 'entra_tenant_id' => $tenant->graphTenantId(), + 'entra_tenant_name' => (string) $tenant->name, + ], 'baseline_profile_id' => (int) $profile->getKey(), 'baseline_snapshot_id' => $snapshotId, 'effective_scope' => $effectiveScope->toEffectiveScopeContext(), + 'capture_mode' => $captureMode->value, ]; $run = $this->runs->ensureRunWithIdentity( @@ -107,6 +119,10 @@ private function validatePreconditions(BaselineProfile $profile, bool $hasExplic return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE; } + if ($profile->capture_mode === BaselineCaptureMode::FullContent && ! $this->rolloutGate->enabled()) { + return BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED; + } + if (! $hasExplicitSnapshotSelection && $profile->active_snapshot_id === null) { return BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT; } diff --git a/app/Services/Baselines/BaselineContentCapturePhase.php b/app/Services/Baselines/BaselineContentCapturePhase.php new file mode 100644 index 0000000..871ddfe --- /dev/null +++ b/app/Services/Baselines/BaselineContentCapturePhase.php @@ -0,0 +1,208 @@ + $subjects + * @param array{max_items_per_run: int, max_concurrency: int, max_retries: int} $budgets + * @return array{ + * stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int}, + * gaps: array, + * resume_token: ?string + * } + */ + public function capture( + Tenant $tenant, + array $subjects, + PolicyVersionCapturePurpose $purpose, + array $budgets, + ?string $resumeToken = null, + ?int $operationRunId = null, + ?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; + + if (is_string($resumeToken) && $resumeToken !== '') { + $state = BaselineEvidenceResumeToken::decode($resumeToken) ?? []; + $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) : []; + + $stats = [ + 'requested' => count($batch), + 'succeeded' => 0, + 'skipped' => 0, + 'failed' => 0, + 'throttled' => 0, + ]; + + /** @var array $gaps */ + $gaps = []; + + /** + * @var array $seen + */ + $seen = []; + + foreach (array_chunk($batch, $maxConcurrency) as $chunk) { + foreach ($chunk as $subject) { + $policyType = trim((string) ($subject['policy_type'] ?? '')); + $externalId = trim((string) ($subject['subject_external_id'] ?? '')); + + 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; + } + } + } + + $processed = $offset + count($batch); + $resumeTokenOut = null; + + 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); + + return [ + 'stats' => $stats, + 'gaps' => $gaps, + '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/Services/Baselines/BaselineSnapshotIdentity.php b/app/Services/Baselines/BaselineSnapshotIdentity.php index d9162e5..30ca6e6 100644 --- a/app/Services/Baselines/BaselineSnapshotIdentity.php +++ b/app/Services/Baselines/BaselineSnapshotIdentity.php @@ -23,9 +23,9 @@ public function __construct( * Compute identity hash over a set of snapshot items. * * Each item is represented as an associative array with: - * - subject_type, subject_external_id, policy_type, baseline_hash + * - policy_type, subject_key, baseline_hash * - * @param array $items + * @param array $items */ public function computeIdentity(array $items): string { @@ -35,9 +35,8 @@ public function computeIdentity(array $items): string $normalized = array_map( fn (array $item): string => implode('|', [ - trim((string) ($item['subject_type'] ?? '')), - trim((string) ($item['subject_external_id'] ?? '')), trim((string) ($item['policy_type'] ?? '')), + trim((string) ($item['subject_key'] ?? '')), trim((string) ($item['baseline_hash'] ?? '')), ]), $items, diff --git a/app/Services/Baselines/Evidence/ContentEvidenceProvider.php b/app/Services/Baselines/Evidence/ContentEvidenceProvider.php index a25af76..5870930 100644 --- a/app/Services/Baselines/Evidence/ContentEvidenceProvider.php +++ b/app/Services/Baselines/Evidence/ContentEvidenceProvider.php @@ -8,7 +8,9 @@ use App\Models\Tenant; use App\Services\Baselines\CurrentStateEvidenceProvider; use App\Services\Drift\DriftHasher; +use App\Services\Drift\Normalizers\AssignmentsNormalizer; use App\Services\Drift\Normalizers\SettingsNormalizer; +use App\Services\Drift\Normalizers\ScopeTagsNormalizer; use Carbon\CarbonImmutable; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -18,6 +20,8 @@ final class ContentEvidenceProvider implements CurrentStateEvidenceProvider public function __construct( private readonly DriftHasher $hasher, private readonly SettingsNormalizer $settingsNormalizer, + private readonly AssignmentsNormalizer $assignmentsNormalizer, + private readonly ScopeTagsNormalizer $scopeTagsNormalizer, ) {} public function name(): string @@ -85,11 +89,15 @@ public function resolve(Tenant $tenant, array $subjects, ?CarbonImmutable $since $baseQuery = DB::table('policy_versions') ->select([ 'policy_versions.id', + 'policy_versions.operation_run_id', + 'policy_versions.capture_purpose', 'policy_versions.policy_id', 'policy_versions.policy_type', 'policy_versions.platform', 'policy_versions.captured_at', 'policy_versions.snapshot', + 'policy_versions.assignments', + 'policy_versions.scope_tags', 'policy_versions.version_number', ]) ->selectRaw('ROW_NUMBER() OVER (PARTITION BY policy_id ORDER BY captured_at DESC, version_number DESC, id DESC) as rn') @@ -127,6 +135,14 @@ public function resolve(Tenant $tenant, array $subjects, ?CarbonImmutable $since $snapshot = is_array($snapshot) ? $snapshot : (is_string($snapshot) ? json_decode($snapshot, true) : null); $snapshot = is_array($snapshot) ? $snapshot : []; + $assignments = $version->assignments ?? null; + $assignments = is_array($assignments) ? $assignments : (is_string($assignments) ? json_decode($assignments, true) : null); + $assignments = is_array($assignments) ? $assignments : []; + + $scopeTags = $version->scope_tags ?? null; + $scopeTags = is_array($scopeTags) ? $scopeTags : (is_string($scopeTags) ? json_decode($scopeTags, true) : null); + $scopeTags = is_array($scopeTags) ? $scopeTags : []; + $platform = is_string($version->platform ?? null) ? (string) $version->platform : null; $normalized = $this->settingsNormalizer->normalizeForDiff( @@ -135,10 +151,20 @@ public function resolve(Tenant $tenant, array $subjects, ?CarbonImmutable $since platform: $platform, ); - $hash = $this->hasher->hashNormalized($normalized); + $normalizedAssignments = $this->assignmentsNormalizer->normalizeForDiff($assignments); + $normalizedScopeTagIds = $this->scopeTagsNormalizer->normalizeIds($scopeTags); + + $hash = $this->hasher->hashNormalized([ + 'settings' => $normalized, + 'assignments' => $normalizedAssignments, + 'scope_tag_ids' => $normalizedScopeTagIds, + ]); $observedAt = is_string($version->captured_at ?? null) ? CarbonImmutable::parse((string) $version->captured_at) : null; $policyVersionId = is_numeric($version->id ?? null) ? (int) $version->id : null; + $observedOperationRunId = is_numeric($version->operation_run_id ?? null) ? (int) $version->operation_run_id : null; + $capturePurpose = is_string($version->capture_purpose ?? null) ? trim((string) $version->capture_purpose) : null; + $capturePurpose = $capturePurpose !== '' ? $capturePurpose : null; $resolved[$key] = new ResolvedEvidence( policyType: $policyType, @@ -147,9 +173,11 @@ public function resolve(Tenant $tenant, array $subjects, ?CarbonImmutable $since fidelity: EvidenceProvenance::FidelityContent, source: EvidenceProvenance::SourcePolicyVersion, observedAt: $observedAt, - observedOperationRunId: null, + observedOperationRunId: $observedOperationRunId, meta: [ 'policy_version_id' => $policyVersionId, + 'operation_run_id' => $observedOperationRunId, + 'capture_purpose' => $capturePurpose, ], ); } diff --git a/app/Services/Baselines/Evidence/ResolvedEvidence.php b/app/Services/Baselines/Evidence/ResolvedEvidence.php index 5350592..9f57383 100644 --- a/app/Services/Baselines/Evidence/ResolvedEvidence.php +++ b/app/Services/Baselines/Evidence/ResolvedEvidence.php @@ -45,6 +45,18 @@ public function provenance(): array ); } + /** + * Tenant-scoped provenance including additional metadata (e.g. policy_version_id). + * + * Do NOT use this for workspace-owned baseline snapshot items. + * + * @return array + */ + public function tenantProvenance(): array + { + return array_merge($this->provenance(), $this->meta); + } + /** * @return array{hash: string, provenance: array} */ @@ -52,7 +64,7 @@ public function toFindingSideEvidence(): array { return [ 'hash' => $this->hash, - 'provenance' => $this->provenance(), + 'provenance' => $this->tenantProvenance(), ]; } } diff --git a/app/Services/Intune/PolicyCaptureOrchestrator.php b/app/Services/Intune/PolicyCaptureOrchestrator.php index 967d157..3e49e22 100644 --- a/app/Services/Intune/PolicyCaptureOrchestrator.php +++ b/app/Services/Intune/PolicyCaptureOrchestrator.php @@ -5,6 +5,7 @@ use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\Tenant; +use App\Support\Baselines\PolicyVersionCapturePurpose; use App\Services\Graph\AssignmentFetcher; use App\Services\Graph\AssignmentFilterResolver; use App\Services\Graph\GraphException; @@ -23,6 +24,7 @@ class PolicyCaptureOrchestrator public function __construct( private readonly VersionService $versionService, private readonly PolicySnapshotService $snapshotService, + private readonly PolicySnapshotRedactor $snapshotRedactor, private readonly AssignmentFetcher $assignmentFetcher, private readonly GroupResolver $groupResolver, private readonly AssignmentFilterResolver $assignmentFilterResolver, @@ -41,7 +43,10 @@ public function capture( bool $includeAssignments = false, bool $includeScopeTags = false, ?string $createdBy = null, - array $metadata = [] + array $metadata = [], + PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup, + ?int $operationRunId = null, + ?int $baselineProfileId = null, ): array { $graphOptions = $this->graphOptionsResolver->resolveForTenant($tenant); $tenantIdentifier = (string) ($graphOptions['tenant'] ?? ''); @@ -127,11 +132,21 @@ public function capture( $scopeTags = $this->resolveScopeTags($tenant, $scopeTagIds); } - // 4. Check if PolicyVersion with same snapshot already exists - $snapshotHash = hash('sha256', json_encode($payload)); + $redactedPayload = $this->snapshotRedactor->redactPayload($payload); + $redactedAssignments = $this->snapshotRedactor->redactAssignments($assignments); + $redactedScopeTags = $this->snapshotRedactor->redactScopeTags($scopeTags); + + // 4. Check if PolicyVersion with same snapshot already exists (based on redacted content) + $snapshotHash = hash('sha256', json_encode($redactedPayload)); // Find existing version by comparing snapshot content (database-agnostic) - $existingVersion = PolicyVersion::where('policy_id', $policy->id) + $existingVersion = PolicyVersion::query() + ->where('policy_id', $policy->id) + ->where('capture_purpose', $capturePurpose->value) + ->when( + $capturePurpose !== PolicyVersionCapturePurpose::Backup && $baselineProfileId !== null, + fn ($query) => $query->where('baseline_profile_id', $baselineProfileId), + ) ->get() ->first(function ($version) use ($snapshotHash) { return hash('sha256', json_encode($version->snapshot)) === $snapshotHash; @@ -141,13 +156,13 @@ public function capture( $updates = []; if ($includeAssignments && $existingVersion->assignments === null) { - $updates['assignments'] = $assignments; - $updates['assignments_hash'] = $assignments ? hash('sha256', json_encode($assignments)) : null; + $updates['assignments'] = $redactedAssignments; + $updates['assignments_hash'] = $redactedAssignments ? hash('sha256', json_encode($redactedAssignments)) : null; } if ($includeScopeTags && $existingVersion->scope_tags === null) { - $updates['scope_tags'] = $scopeTags; - $updates['scope_tags_hash'] = $scopeTags ? hash('sha256', json_encode($scopeTags)) : null; + $updates['scope_tags'] = $redactedScopeTags; + $updates['scope_tags_hash'] = $redactedScopeTags ? hash('sha256', json_encode($redactedScopeTags)) : null; } if (! empty($updates)) { @@ -165,9 +180,9 @@ public function capture( return [ 'version' => $existingVersion->fresh(), 'captured' => [ - 'payload' => $payload, - 'assignments' => $assignments, - 'scope_tags' => $scopeTags, + 'payload' => $redactedPayload, + 'assignments' => $redactedAssignments, + 'scope_tags' => $redactedScopeTags, 'metadata' => $captureMetadata, ], ]; @@ -183,9 +198,9 @@ public function capture( return [ 'version' => $existingVersion, 'captured' => [ - 'payload' => $payload, - 'assignments' => $assignments, - 'scope_tags' => $scopeTags, + 'payload' => $redactedPayload, + 'assignments' => $redactedAssignments, + 'scope_tags' => $redactedScopeTags, 'metadata' => $captureMetadata, ], ]; @@ -200,11 +215,14 @@ public function capture( $version = $this->versionService->captureVersion( policy: $policy, - payload: $payload, + payload: $redactedPayload, createdBy: $createdBy, metadata: $metadata, - assignments: $assignments, - scopeTags: $scopeTags, + assignments: $redactedAssignments, + scopeTags: $redactedScopeTags, + capturePurpose: $capturePurpose, + operationRunId: $operationRunId, + baselineProfileId: $baselineProfileId, ); Log::info('Policy captured via orchestrator', [ @@ -219,9 +237,9 @@ public function capture( return [ 'version' => $version, 'captured' => [ - 'payload' => $payload, - 'assignments' => $assignments, - 'scope_tags' => $scopeTags, + 'payload' => $redactedPayload, + 'assignments' => $redactedAssignments, + 'scope_tags' => $redactedScopeTags, 'metadata' => $captureMetadata, ], ]; diff --git a/app/Services/Intune/PolicySnapshotRedactor.php b/app/Services/Intune/PolicySnapshotRedactor.php new file mode 100644 index 0000000..073499a --- /dev/null +++ b/app/Services/Intune/PolicySnapshotRedactor.php @@ -0,0 +1,96 @@ + + */ + private const array SENSITIVE_KEY_PATTERNS = [ + '/password/i', + '/secret/i', + '/token/i', + '/client[_-]?secret/i', + '/private[_-]?key/i', + '/shared[_-]?secret/i', + '/preshared/i', + '/certificate/i', + ]; + + /** + * @param array $payload + * @return array + */ + public function redactPayload(array $payload): array + { + return $this->redactValue($payload); + } + + /** + * @param array>|null $assignments + * @return array>|null + */ + public function redactAssignments(?array $assignments): ?array + { + if ($assignments === null) { + return null; + } + + $redacted = $this->redactValue($assignments); + + return is_array($redacted) ? $redacted : $assignments; + } + + /** + * @param array>|null $scopeTags + * @return array>|null + */ + public function redactScopeTags(?array $scopeTags): ?array + { + if ($scopeTags === null) { + return null; + } + + $redacted = $this->redactValue($scopeTags); + + return is_array($redacted) ? $redacted : $scopeTags; + } + + private function isSensitiveKey(string $key): bool + { + foreach (self::SENSITIVE_KEY_PATTERNS as $pattern) { + if (preg_match($pattern, $key) === 1) { + return true; + } + } + + return false; + } + + private function redactValue(mixed $value): mixed + { + if (! is_array($value)) { + return $value; + } + + $redacted = []; + + foreach ($value as $key => $item) { + if (is_string($key) && $this->isSensitiveKey($key)) { + $redacted[$key] = self::REDACTED; + + continue; + } + + $redacted[$key] = $this->redactValue($item); + } + + return $redacted; + } +} + diff --git a/app/Services/Intune/VersionService.php b/app/Services/Intune/VersionService.php index 568618a..96e775a 100644 --- a/app/Services/Intune/VersionService.php +++ b/app/Services/Intune/VersionService.php @@ -11,6 +11,7 @@ use App\Services\Graph\GroupResolver; use App\Services\Graph\ScopeTagResolver; use App\Services\Providers\MicrosoftGraphOptionsResolver; +use App\Support\Baselines\PolicyVersionCapturePurpose; use Carbon\CarbonImmutable; use Illuminate\Database\QueryException; use Illuminate\Database\UniqueConstraintViolationException; @@ -21,6 +22,7 @@ class VersionService public function __construct( private readonly AuditLogger $auditLogger, private readonly PolicySnapshotService $snapshotService, + private readonly PolicySnapshotRedactor $snapshotRedactor, private readonly AssignmentFetcher $assignmentFetcher, private readonly GroupResolver $groupResolver, private readonly AssignmentFilterResolver $assignmentFilterResolver, @@ -35,13 +37,20 @@ public function captureVersion( array $metadata = [], ?array $assignments = null, ?array $scopeTags = null, + PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup, + ?int $operationRunId = null, + ?int $baselineProfileId = null, ): PolicyVersion { + $payload = $this->snapshotRedactor->redactPayload($payload); + $assignments = $this->snapshotRedactor->redactAssignments($assignments); + $scopeTags = $this->snapshotRedactor->redactScopeTags($scopeTags); + $version = null; $versionNumber = null; for ($attempt = 1; $attempt <= 3; $attempt++) { try { - [$version, $versionNumber] = DB::transaction(function () use ($policy, $payload, $createdBy, $metadata, $assignments, $scopeTags): array { + [$version, $versionNumber] = DB::transaction(function () use ($policy, $payload, $createdBy, $metadata, $assignments, $scopeTags, $capturePurpose, $operationRunId, $baselineProfileId): array { // Serialize version number allocation per policy. Policy::query()->whereKey($policy->getKey())->lockForUpdate()->first(); @@ -61,6 +70,9 @@ public function captureVersion( 'scope_tags' => $scopeTags, 'assignments_hash' => $assignments ? hash('sha256', json_encode($assignments)) : null, 'scope_tags_hash' => $scopeTags ? hash('sha256', json_encode($scopeTags)) : null, + 'capture_purpose' => $capturePurpose->value, + 'operation_run_id' => $operationRunId, + 'baseline_profile_id' => $baselineProfileId, ]); return [$version, $versionNumber]; @@ -121,6 +133,9 @@ public function captureFromGraph( array $metadata = [], bool $includeAssignments = true, bool $includeScopeTags = true, + PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup, + ?int $operationRunId = null, + ?int $baselineProfileId = null, ): PolicyVersion { $graphOptions = $this->graphOptionsResolver->resolveForTenant($tenant); $tenantIdentifier = (string) ($graphOptions['tenant'] ?? ''); @@ -213,6 +228,9 @@ public function captureFromGraph( metadata: $metadata, assignments: $assignments, scopeTags: $scopeTags, + capturePurpose: $capturePurpose, + operationRunId: $operationRunId, + baselineProfileId: $baselineProfileId, ); } diff --git a/app/Support/Baselines/BaselineCaptureMode.php b/app/Support/Baselines/BaselineCaptureMode.php new file mode 100644 index 0000000..89df926 --- /dev/null +++ b/app/Support/Baselines/BaselineCaptureMode.php @@ -0,0 +1,36 @@ + 'Meta only', + self::Opportunistic => 'Opportunistic', + self::FullContent => 'Full content', + }; + } + + /** + * @return array + */ + public static function selectOptions(): array + { + $options = []; + + foreach (self::cases() as $case) { + $options[$case->value] = $case->label(); + } + + return $options; + } +} + diff --git a/app/Support/Baselines/BaselineCompareReasonCode.php b/app/Support/Baselines/BaselineCompareReasonCode.php new file mode 100644 index 0000000..fbbbef5 --- /dev/null +++ b/app/Support/Baselines/BaselineCompareReasonCode.php @@ -0,0 +1,25 @@ + 'No subjects were in scope for this comparison.', + 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 ec9be9b..dc9d1c9 100644 --- a/app/Support/Baselines/BaselineCompareStats.php +++ b/app/Support/Baselines/BaselineCompareStats.php @@ -7,14 +7,17 @@ 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 { /** * @param array $severityCounts * @param list $uncoveredTypes + * @param array $evidenceGapsTopReasons */ private function __construct( public readonly string $state, @@ -22,16 +25,21 @@ 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 = [], public readonly ?string $fidelity = null, + public readonly ?int $evidenceGapsCount = null, + public readonly array $evidenceGapsTopReasons = [], ) {} public static function forTenant(?Tenant $tenant): self @@ -64,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, ); } @@ -80,6 +99,8 @@ public static function forTenant(?Tenant $tenant): self ->first(); [$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)) { @@ -89,16 +110,21 @@ 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, fidelity: $fidelity, + evidenceGapsCount: $evidenceGapsCount, + evidenceGapsTopReasons: $evidenceGapsTopReasons, ); } @@ -115,16 +141,21 @@ 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, fidelity: $fidelity, + evidenceGapsCount: $evidenceGapsCount, + evidenceGapsTopReasons: $evidenceGapsTopReasons, ); } @@ -163,16 +194,21 @@ 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, fidelity: $fidelity, + evidenceGapsCount: $evidenceGapsCount, + evidenceGapsTopReasons: $evidenceGapsTopReasons, ); } @@ -185,16 +221,21 @@ 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, fidelity: $fidelity, + evidenceGapsCount: $evidenceGapsCount, + evidenceGapsTopReasons: $evidenceGapsTopReasons, ); } @@ -204,16 +245,21 @@ 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, fidelity: $fidelity, + evidenceGapsCount: $evidenceGapsCount, + evidenceGapsTopReasons: $evidenceGapsTopReasons, ); } @@ -264,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: [ @@ -277,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} */ @@ -321,11 +426,90 @@ 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} + */ + private static function evidenceGapSummaryForRun(?OperationRun $run): array + { + if (! $run instanceof OperationRun) { + return [null, []]; + } + + $context = is_array($run->context) ? $run->context : []; + $baselineCompare = $context['baseline_compare'] ?? null; + + if (! is_array($baselineCompare)) { + return [null, []]; + } + + $gaps = $baselineCompare['evidence_gaps'] ?? null; + + if (! is_array($gaps)) { + return [null, []]; + } + + $count = $gaps['count'] ?? null; + $count = is_numeric($count) ? (int) $count : null; + + $byReason = $gaps['by_reason'] ?? null; + $byReason = is_array($byReason) ? $byReason : []; + + $normalized = []; + + foreach ($byReason as $reason => $value) { + if (! is_string($reason) || trim($reason) === '' || ! is_numeric($value)) { + continue; + } + + $intValue = (int) $value; + + if ($intValue <= 0) { + continue; + } + + $normalized[trim($reason)] = $intValue; + } + + if ($count === null) { + $count = array_sum($normalized); + } + + arsort($normalized); + + return [$count, array_slice($normalized, 0, 6, true)]; + } + private static function empty( string $state, ?string $message, ?string $profileName = null, ?int $profileId = null, + ?int $duplicateNamePoliciesCount = null, ): self { return new self( state: $state, @@ -333,6 +517,7 @@ private static function empty( profileName: $profileName, profileId: $profileId, snapshotId: null, + duplicateNamePoliciesCount: $duplicateNamePoliciesCount, operationRunId: null, findingsCount: null, severityCounts: [], diff --git a/app/Support/Baselines/BaselineEvidenceResumeToken.php b/app/Support/Baselines/BaselineEvidenceResumeToken.php new file mode 100644 index 0000000..2612796 --- /dev/null +++ b/app/Support/Baselines/BaselineEvidenceResumeToken.php @@ -0,0 +1,86 @@ + $state + */ + public static function encode(array $state): string + { + $payload = [ + 'v' => self::VERSION, + 'state' => $state, + ]; + + $json = json_encode($payload, JSON_THROW_ON_ERROR); + + return self::base64UrlEncode($json); + } + + /** + * @return array|null + */ + public static function decode(string $token): ?array + { + $token = trim($token); + + if ($token === '') { + return null; + } + + $json = self::base64UrlDecode($token); + + if ($json === null) { + return null; + } + + try { + $decoded = json_decode($json, true, flags: JSON_THROW_ON_ERROR); + } catch (JsonException) { + return null; + } + + if (! is_array($decoded)) { + return null; + } + + $version = $decoded['v'] ?? null; + $version = is_int($version) ? $version : (is_numeric($version) ? (int) $version : null); + + if ($version !== self::VERSION) { + return null; + } + + $state = $decoded['state'] ?? null; + + return is_array($state) ? $state : null; + } + + private static function base64UrlEncode(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } + + private static function base64UrlDecode(string $value): ?string + { + $padded = strtr($value, '-_', '+/'); + $padding = strlen($padded) % 4; + + if ($padding !== 0) { + $padded .= str_repeat('=', 4 - $padding); + } + + $decoded = base64_decode($padded, true); + + return is_string($decoded) ? $decoded : null; + } +} + diff --git a/app/Support/Baselines/BaselineFullContentRolloutGate.php b/app/Support/Baselines/BaselineFullContentRolloutGate.php new file mode 100644 index 0000000..1d98e71 --- /dev/null +++ b/app/Support/Baselines/BaselineFullContentRolloutGate.php @@ -0,0 +1,23 @@ +enabled()) { + throw new RuntimeException('Baseline full-content capture is disabled by rollout configuration.'); + } + } +} + diff --git a/app/Support/Baselines/BaselineReasonCodes.php b/app/Support/Baselines/BaselineReasonCodes.php index 66db016..9b2b3bd 100644 --- a/app/Support/Baselines/BaselineReasonCodes.php +++ b/app/Support/Baselines/BaselineReasonCodes.php @@ -16,6 +16,8 @@ final class BaselineReasonCodes public const string CAPTURE_PROFILE_NOT_ACTIVE = 'baseline.capture.profile_not_active'; + public const string CAPTURE_ROLLOUT_DISABLED = 'baseline.capture.rollout_disabled'; + public const string COMPARE_NO_ASSIGNMENT = 'baseline.compare.no_assignment'; public const string COMPARE_PROFILE_NOT_ACTIVE = 'baseline.compare.profile_not_active'; @@ -23,4 +25,6 @@ final class BaselineReasonCodes public const string COMPARE_NO_ACTIVE_SNAPSHOT = 'baseline.compare.no_active_snapshot'; public const string COMPARE_INVALID_SNAPSHOT = 'baseline.compare.invalid_snapshot'; + + public const string COMPARE_ROLLOUT_DISABLED = 'baseline.compare.rollout_disabled'; } diff --git a/app/Support/Baselines/BaselineSubjectKey.php b/app/Support/Baselines/BaselineSubjectKey.php new file mode 100644 index 0000000..c0a86ce --- /dev/null +++ b/app/Support/Baselines/BaselineSubjectKey.php @@ -0,0 +1,35 @@ + (bool) env('TENANTPILOT_REVIEW_PACK_INCLUDE_OPERATIONS_DEFAULT', true), ], + 'baselines' => [ + 'full_content_capture' => [ + 'enabled' => (bool) env('TENANTPILOT_BASELINE_FULL_CONTENT_CAPTURE_ENABLED', false), + 'max_items_per_run' => (int) env('TENANTPILOT_BASELINE_EVIDENCE_MAX_ITEMS_PER_RUN', 200), + 'max_concurrency' => (int) env('TENANTPILOT_BASELINE_EVIDENCE_MAX_CONCURRENCY', 5), + 'max_retries' => (int) env('TENANTPILOT_BASELINE_EVIDENCE_MAX_RETRIES', 3), + 'retention_days' => (int) env('TENANTPILOT_BASELINE_EVIDENCE_RETENTION_DAYS', 90), + ], + ], + 'hardening' => [ 'intune_write_gate' => [ 'enabled' => (bool) env('TENANTPILOT_INTUNE_WRITE_GATE_ENABLED', true), diff --git a/database/factories/BaselineProfileFactory.php b/database/factories/BaselineProfileFactory.php index 77bd188..88d4714 100644 --- a/database/factories/BaselineProfileFactory.php +++ b/database/factories/BaselineProfileFactory.php @@ -5,6 +5,7 @@ use App\Models\BaselineProfile; use App\Models\User; use App\Models\Workspace; +use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineProfileStatus; use Illuminate\Database\Eloquent\Factories\Factory; @@ -26,6 +27,7 @@ public function definition(): array 'description' => fake()->optional()->sentence(), 'version_label' => fake()->optional()->numerify('v#.#'), 'status' => BaselineProfileStatus::Draft->value, + 'capture_mode' => BaselineCaptureMode::Opportunistic->value, 'scope_jsonb' => ['policy_types' => [], 'foundation_types' => []], 'active_snapshot_id' => null, 'created_by_user_id' => null, diff --git a/database/factories/BaselineSnapshotItemFactory.php b/database/factories/BaselineSnapshotItemFactory.php index 4c2216d..589ad38 100644 --- a/database/factories/BaselineSnapshotItemFactory.php +++ b/database/factories/BaselineSnapshotItemFactory.php @@ -4,6 +4,7 @@ use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshotItem; +use App\Support\Baselines\BaselineSubjectKey; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -18,13 +19,22 @@ class BaselineSnapshotItemFactory extends Factory */ public function definition(): array { + $displayName = fake()->words(3, true); + $policyType = 'deviceConfiguration'; + + $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); + $subjectExternalId = $subjectKey !== null + ? BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, $subjectKey) + : fake()->uuid(); + return [ 'baseline_snapshot_id' => BaselineSnapshot::factory(), 'subject_type' => 'policy', - 'subject_external_id' => fake()->uuid(), - 'policy_type' => 'deviceConfiguration', + 'subject_external_id' => $subjectExternalId, + 'subject_key' => $subjectKey, + 'policy_type' => $policyType, 'baseline_hash' => hash('sha256', fake()->uuid()), - 'meta_jsonb' => ['display_name' => fake()->words(3, true)], + 'meta_jsonb' => ['display_name' => $displayName], ]; } } diff --git a/database/factories/PolicyVersionFactory.php b/database/factories/PolicyVersionFactory.php index 04c2c1d..6270dac 100644 --- a/database/factories/PolicyVersionFactory.php +++ b/database/factories/PolicyVersionFactory.php @@ -4,6 +4,7 @@ use App\Models\Policy; use App\Models\Tenant; +use App\Support\Baselines\PolicyVersionCapturePurpose; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -28,6 +29,9 @@ public function definition(): array 'captured_at' => now(), 'snapshot' => ['example' => true], 'metadata' => [], + 'capture_purpose' => PolicyVersionCapturePurpose::Backup->value, + 'operation_run_id' => null, + 'baseline_profile_id' => null, ]; } } diff --git a/database/migrations/2026_03_03_100001_add_capture_mode_to_baseline_profiles_table.php b/database/migrations/2026_03_03_100001_add_capture_mode_to_baseline_profiles_table.php new file mode 100644 index 0000000..93e0b44 --- /dev/null +++ b/database/migrations/2026_03_03_100001_add_capture_mode_to_baseline_profiles_table.php @@ -0,0 +1,35 @@ +string('capture_mode')->default('opportunistic')->after('status'); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('baseline_profiles')) { + return; + } + + Schema::table('baseline_profiles', function (Blueprint $table): void { + if (Schema::hasColumn('baseline_profiles', 'capture_mode')) { + $table->dropColumn('capture_mode'); + } + }); + } +}; + diff --git a/database/migrations/2026_03_03_100002_add_subject_key_to_baseline_snapshot_items_table.php b/database/migrations/2026_03_03_100002_add_subject_key_to_baseline_snapshot_items_table.php new file mode 100644 index 0000000..5e8f7a3 --- /dev/null +++ b/database/migrations/2026_03_03_100002_add_subject_key_to_baseline_snapshot_items_table.php @@ -0,0 +1,42 @@ +string('subject_key')->nullable()->after('subject_external_id'); + } + }); + + Schema::table('baseline_snapshot_items', function (Blueprint $table): void { + if (Schema::hasColumn('baseline_snapshot_items', 'subject_key')) { + $table->index(['baseline_snapshot_id', 'policy_type', 'subject_key'], 'baseline_snapshot_items_subject_key_idx'); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('baseline_snapshot_items')) { + return; + } + + Schema::table('baseline_snapshot_items', function (Blueprint $table): void { + if (Schema::hasColumn('baseline_snapshot_items', 'subject_key')) { + $table->dropIndex('baseline_snapshot_items_subject_key_idx'); + $table->dropColumn('subject_key'); + } + }); + } +}; + diff --git a/database/migrations/2026_03_03_100003_add_baseline_purpose_to_policy_versions_table.php b/database/migrations/2026_03_03_100003_add_baseline_purpose_to_policy_versions_table.php new file mode 100644 index 0000000..2c32fce --- /dev/null +++ b/database/migrations/2026_03_03_100003_add_baseline_purpose_to_policy_versions_table.php @@ -0,0 +1,74 @@ +string('capture_purpose')->default('backup')->after('scope_tags_hash'); + } + + if (! Schema::hasColumn('policy_versions', 'operation_run_id')) { + $table->unsignedBigInteger('operation_run_id')->nullable()->after('capture_purpose'); + } + + if (! Schema::hasColumn('policy_versions', 'baseline_profile_id')) { + $table->unsignedBigInteger('baseline_profile_id')->nullable()->after('operation_run_id'); + } + }); + + Schema::table('policy_versions', function (Blueprint $table): void { + if (! Schema::hasColumn('policy_versions', 'capture_purpose')) { + return; + } + + $table->index(['tenant_id', 'policy_id', 'capture_purpose', 'captured_at'], 'policy_versions_tenant_policy_purpose_captured_idx'); + + if (Schema::hasColumn('policy_versions', 'operation_run_id')) { + $table->index(['tenant_id', 'capture_purpose', 'operation_run_id'], 'policy_versions_tenant_purpose_run_idx'); + $table->foreign('operation_run_id')->references('id')->on('operation_runs')->nullOnDelete(); + } + + if (Schema::hasColumn('policy_versions', 'baseline_profile_id')) { + $table->index(['tenant_id', 'capture_purpose', 'baseline_profile_id'], 'policy_versions_tenant_purpose_profile_idx'); + $table->foreign('baseline_profile_id')->references('id')->on('baseline_profiles')->nullOnDelete(); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('policy_versions')) { + return; + } + + Schema::table('policy_versions', function (Blueprint $table): void { + if (Schema::hasColumn('policy_versions', 'baseline_profile_id')) { + $table->dropForeign(['baseline_profile_id']); + $table->dropIndex('policy_versions_tenant_purpose_profile_idx'); + $table->dropColumn('baseline_profile_id'); + } + + if (Schema::hasColumn('policy_versions', 'operation_run_id')) { + $table->dropForeign(['operation_run_id']); + $table->dropIndex('policy_versions_tenant_purpose_run_idx'); + $table->dropColumn('operation_run_id'); + } + + if (Schema::hasColumn('policy_versions', 'capture_purpose')) { + $table->dropIndex('policy_versions_tenant_policy_purpose_captured_idx'); + $table->dropColumn('capture_purpose'); + } + }); + } +}; + 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 224e689..86525d7 100644 --- a/resources/views/filament/pages/baseline-compare-landing.blade.php +++ b/resources/views/filament/pages/baseline-compare-landing.blade.php @@ -5,21 +5,40 @@ @endif @php - $hasCoverageWarnings = in_array(($coverageStatus ?? null), ['warning', 'unproven'], true); + $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 @@ -29,39 +48,49 @@ 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) + + {{ __('baseline-compare.badge_evidence_gaps', ['count' => $evidenceGapsCountValue]) }} @endif
+ + @if ($hasEvidenceGaps && filled($evidenceGapsSummary)) +
+ {{ __('baseline-compare.evidence_gaps_tooltip', ['summary' => $evidenceGapsSummary]) }} +
+ @endif
{{-- 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' && ! $hasCoverageWarnings) - All clear - @elseif ($state === 'ready' && $hasCoverageWarnings) - Coverage warnings + @elseif (($findingsCount ?? 0) === 0 && $state === 'ready') + {{ $whyNoFindingsMessage ?? $whyNoFindingsFallback }} @endif
@@ -69,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
@@ -85,23 +114,28 @@ class="w-fit" {{-- Coverage warnings banner --}} @if ($state === 'ready' && $hasCoverageWarnings) -
+