From 3e0dc438f7c6fa6610ac74fd414032165f46dfa2 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Tue, 3 Mar 2026 22:22:11 +0100 Subject: [PATCH 1/3] wip: baseline compare subject_key + full-content capture --- .env.example | 7 + app/Filament/Pages/BaselineCompareLanding.php | 23 +- .../Resources/BaselineProfileResource.php | 68 +++ .../Pages/ViewBaselineProfile.php | 222 ++++++++- app/Jobs/CaptureBaselineSnapshotJob.php | 437 ++++++++++++---- app/Jobs/CompareBaselineToTenantJob.php | 468 ++++++++++++++++-- app/Models/BaselineProfile.php | 3 + app/Models/PolicyVersion.php | 12 + .../Baselines/BaselineCaptureService.php | 16 + .../Baselines/BaselineCompareService.php | 16 + .../Baselines/BaselineContentCapturePhase.php | 129 +++++ .../Baselines/BaselineSnapshotIdentity.php | 7 +- .../Evidence/ContentEvidenceProvider.php | 32 +- .../Baselines/Evidence/ResolvedEvidence.php | 14 +- .../Intune/PolicyCaptureOrchestrator.php | 58 ++- .../Intune/PolicySnapshotRedactor.php | 96 ++++ app/Services/Intune/VersionService.php | 20 +- app/Support/Baselines/BaselineCaptureMode.php | 36 ++ .../Baselines/BaselineCompareReasonCode.php | 26 + .../Baselines/BaselineEvidenceResumeToken.php | 86 ++++ .../BaselineFullContentRolloutGate.php | 23 + app/Support/Baselines/BaselineReasonCodes.php | 4 + app/Support/Baselines/BaselineSubjectKey.php | 35 ++ .../Baselines/PolicyVersionCapturePurpose.php | 13 + config/tenantpilot.php | 10 + database/factories/BaselineProfileFactory.php | 2 + .../factories/BaselineSnapshotItemFactory.php | 16 +- database/factories/PolicyVersionFactory.php | 4 + ...apture_mode_to_baseline_profiles_table.php | 35 ++ ...t_key_to_baseline_snapshot_items_table.php | 42 ++ ...eline_purpose_to_policy_versions_table.php | 74 +++ .../checklists/requirements.md | 48 ++ .../contracts/openapi.yaml | 95 ++++ specs/118-baseline-drift-engine/data-model.md | 178 +++++++ specs/118-baseline-drift-engine/plan.md | 161 ++++++ specs/118-baseline-drift-engine/quickstart.md | 43 ++ specs/118-baseline-drift-engine/research.md | 102 ++++ specs/118-baseline-drift-engine/spec.md | 281 +++++++++++ specs/118-baseline-drift-engine/tasks.md | 243 +++++++++ .../BaselineCaptureAuditEventsTest.php | 75 +++ ...aselineSnapshotNoTenantIdentifiersTest.php | 107 ++++ .../CaptureBaselineContentTest.php | 27 +- ...CaptureBaselineFullContentOnDemandTest.php | 170 +++++++ .../CaptureBaselineMetaFallbackTest.php | 21 +- .../Feature/Baselines/BaselineCaptureTest.php | 49 +- .../BaselineCompareAmbiguousMatchGapTest.php | 118 +++++ .../BaselineCompareAuditEventsTest.php | 176 +++++++ .../BaselineCompareCoverageProofGuardTest.php | 159 ++++++ .../BaselineCompareCrossTenantMatchTest.php | 146 ++++++ ...aselineCompareFindingRecurrenceKeyTest.php | 160 ++++++ ...BaselineCompareLandingStartSurfaceTest.php | 42 ++ ...BaselineProfileCaptureStartSurfaceTest.php | 31 ++ ...BaselineProfileCompareStartSurfaceTest.php | 136 +++++ .../Intune/PolicySnapshotRedactionTest.php | 90 ++++ 54 files changed, 4473 insertions(+), 219 deletions(-) create mode 100644 app/Services/Baselines/BaselineContentCapturePhase.php create mode 100644 app/Services/Intune/PolicySnapshotRedactor.php create mode 100644 app/Support/Baselines/BaselineCaptureMode.php create mode 100644 app/Support/Baselines/BaselineCompareReasonCode.php create mode 100644 app/Support/Baselines/BaselineEvidenceResumeToken.php create mode 100644 app/Support/Baselines/BaselineFullContentRolloutGate.php create mode 100644 app/Support/Baselines/BaselineSubjectKey.php create mode 100644 app/Support/Baselines/PolicyVersionCapturePurpose.php create mode 100644 database/migrations/2026_03_03_100001_add_capture_mode_to_baseline_profiles_table.php create mode 100644 database/migrations/2026_03_03_100002_add_subject_key_to_baseline_snapshot_items_table.php create mode 100644 database/migrations/2026_03_03_100003_add_baseline_purpose_to_policy_versions_table.php create mode 100644 specs/118-baseline-drift-engine/checklists/requirements.md create mode 100644 specs/118-baseline-drift-engine/contracts/openapi.yaml create mode 100644 specs/118-baseline-drift-engine/data-model.md create mode 100644 specs/118-baseline-drift-engine/plan.md create mode 100644 specs/118-baseline-drift-engine/quickstart.md create mode 100644 specs/118-baseline-drift-engine/research.md create mode 100644 specs/118-baseline-drift-engine/spec.md create mode 100644 specs/118-baseline-drift-engine/tasks.md create mode 100644 tests/Feature/BaselineDriftEngine/BaselineCaptureAuditEventsTest.php create mode 100644 tests/Feature/BaselineDriftEngine/BaselineSnapshotNoTenantIdentifiersTest.php create mode 100644 tests/Feature/BaselineDriftEngine/CaptureBaselineFullContentOnDemandTest.php create mode 100644 tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php create mode 100644 tests/Feature/Baselines/BaselineCompareAuditEventsTest.php create mode 100644 tests/Feature/Baselines/BaselineCompareCoverageProofGuardTest.php create mode 100644 tests/Feature/Baselines/BaselineCompareCrossTenantMatchTest.php create mode 100644 tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php create mode 100644 tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php create mode 100644 tests/Feature/Intune/PolicySnapshotRedactionTest.php 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/Filament/Pages/BaselineCompareLanding.php b/app/Filament/Pages/BaselineCompareLanding.php index f88ec46..12b6e00 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; @@ -140,12 +141,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/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/Jobs/CaptureBaselineSnapshotJob.php b/app/Jobs/CaptureBaselineSnapshotJob.php index 685b459..cb20f60 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,264 @@ 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 = []; + $subjectsTotal = 0; + + /** @var array $gaps */ + $gaps = []; + + $query->orderBy('policy_type') + ->orderBy('external_id') + ->chunk(500, function ($inventoryItems) use (&$inventoryByKey, &$subjectsTotal, &$gaps): void { + foreach ($inventoryItems as $inventoryItem) { + $subjectsTotal++; + + $metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : []; + $displayName = is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null; + $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); + + if ($subjectKey === null) { + $gaps['missing_subject_key'] = ($gaps['missing_subject_key'] ?? 0) + 1; + + continue; + } + + $workspaceSafeId = BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: (string) $inventoryItem->policy_type, + subjectKey: $subjectKey, + ); + + $key = (string) $inventoryItem->policy_type.'|'.(string) $inventoryItem->external_id; + + $inventoryByKey[$key] = [ + 'tenant_subject_external_id' => (string) $inventoryItem->external_id, + 'workspace_subject_external_id' => $workspaceSafeId, + 'subject_key' => $subjectKey, + 'policy_type' => (string) $inventoryItem->policy_type, + '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' => $subjectsTotal, + '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 +438,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 +447,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 +486,9 @@ private function auditStarted( Tenant $tenant, BaselineProfile $profile, ?User $initiator, + BaselineCaptureMode $captureMode, + int $subjectsTotal, + BaselineScope $effectiveScope, ): void { $auditLogger->log( tenant: $tenant, @@ -302,6 +498,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 +518,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 +532,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 +549,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..5fc096c 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,11 @@ use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; use App\Services\Settings\SettingsResolver; +use App\Support\Baselines\BaselineCaptureMode; +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 +69,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 +115,25 @@ 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) { + $rolloutGate->assertEnabled(); + } 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 +146,7 @@ public function handle( coveredTypes: [], uncoveredTypes: [], errorsRecorded: 1, + captureMode: $captureMode, ); return; @@ -132,6 +158,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 +180,7 @@ public function handle( coveredTypes: [], uncoveredTypes: $effectiveTypes, errorsRecorded: count($effectiveTypes), + captureMode: $captureMode, ); return; @@ -153,6 +190,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 +212,7 @@ public function handle( coveredTypes: [], uncoveredTypes: $effectiveTypes, errorsRecorded: count($effectiveTypes), + captureMode: $captureMode, ); return; @@ -184,8 +232,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 +257,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 +346,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 +358,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 +375,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,17 +400,17 @@ 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; @@ -292,6 +420,14 @@ public function handle( [ '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 +437,6 @@ public function handle( ...$baselineCoverage, ], 'fidelity' => $overallFidelity, - 'evidence_gaps' => $evidenceGaps, ], ); $updatedContext['findings'] = array_merge( @@ -318,7 +453,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 +527,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 +567,7 @@ private function completeWithCoverageWarning( array $coveredTypes, array $uncoveredTypes, int $errorsRecorded, + BaselineCaptureMode $captureMode, ): void { $summaryCounts = [ 'total' => 0, @@ -414,10 +591,30 @@ private function completeWithCoverageWarning( ); $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $evidenceCapture = [ + 'requested' => 0, + 'succeeded' => 0, + 'skipped' => 0, + 'failed' => 0, + 'throttled' => 0, + ]; + + $evidenceGapsByReason = [ + 'coverage_unproven' => 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, 'coverage' => [ 'effective_types' => array_values($effectiveTypes), 'covered_types' => array_values($coveredTypes), @@ -431,11 +628,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 +645,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 +695,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 +771,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 +828,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 +853,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 +868,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 +884,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 +904,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 +920,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 +930,7 @@ private function computeDrift(array $baselineItems, array $currentItems, array $ ], 'current' => [ 'hash' => $currentEvidence->hash, - 'provenance' => $currentEvidence->provenance(), + 'provenance' => $currentEvidence->tenantProvenance(), ], ], ]; @@ -641,13 +952,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 +970,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 +1198,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 +1217,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 +1331,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 +1438,9 @@ private function auditStarted( Tenant $tenant, BaselineProfile $profile, ?User $initiator, + BaselineCaptureMode $captureMode, + int $subjectsTotal, + BaselineScope $effectiveScope, ): void { $auditLogger->log( tenant: $tenant, @@ -1101,6 +1450,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 +1469,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 +1483,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/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..9837377 --- /dev/null +++ b/app/Services/Baselines/BaselineContentCapturePhase.php @@ -0,0 +1,129 @@ + $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 { + $maxItemsPerRun = max(0, (int) ($budgets['max_items_per_run'] ?? 0)); + + $offset = 0; + + if (is_string($resumeToken) && $resumeToken !== '') { + $state = BaselineEvidenceResumeToken::decode($resumeToken) ?? []; + $offset = is_numeric($state['offset'] ?? null) ? max(0, (int) $state['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 = []; + + foreach ($batch 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; + } + + $policy = Policy::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('policy_type', $policyType) + ->where('external_id', $externalId) + ->first(); + + if (! $policy instanceof Policy) { + $gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1; + $stats['failed']++; + + continue; + } + + $result = $this->captureOrchestrator->capture( + policy: $policy, + tenant: $tenant, + includeAssignments: true, + includeScopeTags: true, + createdBy: $createdBy, + metadata: [ + 'capture_source' => 'baseline_evidence', + ], + capturePurpose: $purpose, + operationRunId: $operationRunId, + baselineProfileId: $baselineProfileId, + ); + + if (is_array($result) && array_key_exists('failure', $result)) { + $gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1; + $stats['failed']++; + + continue; + } + + $stats['succeeded']++; + } + + $processed = $offset + count($batch); + $resumeTokenOut = null; + + if ($processed < count($subjects)) { + $resumeTokenOut = BaselineEvidenceResumeToken::encode([ + 'offset' => $processed, + ]); + } + + ksort($gaps); + + return [ + 'stats' => $stats, + 'gaps' => $gaps, + 'resume_token' => $resumeTokenOut, + ]; + } +} + 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..1855ac0 --- /dev/null +++ b/app/Support/Baselines/BaselineCompareReasonCode.php @@ -0,0 +1,26 @@ + 'No subjects were in scope for this comparison.', + self::CoverageUnproven => 'Coverage proof was not available, so missing-policy outcomes were suppressed.', + 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/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/specs/118-baseline-drift-engine/checklists/requirements.md b/specs/118-baseline-drift-engine/checklists/requirements.md new file mode 100644 index 0000000..4ceb418 --- /dev/null +++ b/specs/118-baseline-drift-engine/checklists/requirements.md @@ -0,0 +1,48 @@ +# Specification Quality Checklist: Golden Master Deep Drift v2 (Full Content Capture) + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-03 +**Feature**: [specs/118-baseline-drift-engine/spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Constitution & Spec 118 Gates + +- [x] Cross-tenant subject matching terminology is defined and consistent (`subject_key` is defined and tied to normalization rules) +- [x] Workspace-owned snapshot items explicitly forbid persisting tenant identifiers (including tenant IDs and tenant external IDs) +- [x] Compare behavior for missing/ambiguous cross-tenant matching is specified (gap reason + suppress drift evaluation) +- [x] Coverage proof guard is specified (missing-policy outcomes suppressed when coverage is unproven) +- [x] Rollout gate requirement exists for full-content mode (canary flag) +- [x] Security requirement exists to redact secrets/PII before persistence/fingerprinting +- [x] Baseline-purpose evidence visibility is explicitly tied to baseline-related capabilities (no `tenant.view`-only access) +- [x] Audit events requirement exists for starting capture/compare runs (and includes purpose + summary context) +- [x] Retention requirement exists for baseline-purpose evidence distinct from long-term backups +- [x] Findings recurrence identity/lifecycle requirement exists and is independent of fingerprints +- [x] Ops-UX “no silent zeros” requirement exists (reason codes + UI explanation) + +## Notes + +- Validated on 2026-03-03; all checks passing. diff --git a/specs/118-baseline-drift-engine/contracts/openapi.yaml b/specs/118-baseline-drift-engine/contracts/openapi.yaml new file mode 100644 index 0000000..ef199e2 --- /dev/null +++ b/specs/118-baseline-drift-engine/contracts/openapi.yaml @@ -0,0 +1,95 @@ +openapi: 3.0.3 +info: + title: TenantPilot — Spec 118 Golden Master Deep Drift v2 + version: 0.1.0 + description: | + This contract documents existing Filament panel routes and Monitoring surfaces involved + in baseline capture/compare and drift findings. Spec 118 does not add new public HTTP APIs; + it extends queued operation behavior and Filament action surfaces. + +servers: + - url: / + +tags: + - name: Baselines + - name: Findings + - name: Operations + +paths: + /admin/baseline-profiles: + get: + tags: [Baselines] + summary: Baseline profiles index (Filament) + responses: + '200': { description: OK } + + /admin/baseline-profiles/{record}: + get: + tags: [Baselines] + summary: Baseline profile detail (Filament) + parameters: + - name: record + in: path + required: true + schema: { type: string } + responses: + '200': { description: OK } + + /admin/t/{tenant}/baseline-compare-landing: + get: + tags: [Baselines] + summary: Baseline compare landing (Filament tenant-context) + parameters: + - name: tenant + in: path + required: true + schema: { type: string } + responses: + '200': { description: OK } + + /admin/t/{tenant}/findings: + get: + tags: [Findings] + summary: Findings list (Filament tenant-context) + parameters: + - name: tenant + in: path + required: true + schema: { type: string } + responses: + '200': { description: OK } + + /admin/t/{tenant}/findings/{record}: + get: + tags: [Findings] + summary: Finding detail (Filament tenant-context) + parameters: + - name: tenant + in: path + required: true + schema: { type: string } + - name: record + in: path + required: true + schema: { type: string } + responses: + '200': { description: OK } + + /admin/operations: + get: + tags: [Operations] + summary: Operation runs list (Monitoring) + responses: + '200': { description: OK } + + /admin/operations/{run}: + get: + tags: [Operations] + summary: Operation run detail (Monitoring) + parameters: + - name: run + in: path + required: true + schema: { type: string } + responses: + '200': { description: OK } diff --git a/specs/118-baseline-drift-engine/data-model.md b/specs/118-baseline-drift-engine/data-model.md new file mode 100644 index 0000000..3f90f08 --- /dev/null +++ b/specs/118-baseline-drift-engine/data-model.md @@ -0,0 +1,178 @@ +# Data Model — Spec 118 Golden Master Deep Drift v2 + +This document describes the data shapes required to implement full-content baseline capture/compare with quota-aware, resumable evidence capture. + +Spec reference: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/118-baseline-drift-engine/spec.md` + +## Entities (existing) + +### `baseline_profiles` (workspace-owned) + +- Purpose: defines baseline name, scope, and (new) capture mode. +- Current fields (from repo): + - `id`, `workspace_id`, `name`, `description`, `version_label`, `status` + - `scope_jsonb` + - `active_snapshot_id` + - `created_by_user_id` + +### `baseline_snapshots` (workspace-owned) + +- Purpose: immutable baseline snapshot, deduped by a snapshot identity hash. +- Current fields: + - `id`, `workspace_id`, `baseline_profile_id` + - `snapshot_identity_hash` (sha256 string) + - `captured_at` + - `summary_jsonb` + +### `baseline_snapshot_items` (workspace-owned; no tenant identifiers) + +- Purpose: per-subject baseline evidence for drift evaluation. +- Current fields: + - `baseline_snapshot_id` + - `subject_type` (currently `policy`) + - `subject_external_id` (legacy column name; MUST NOT store tenant external IDs in Spec 118 flows) + - `policy_type` + - `baseline_hash` (fingerprint) + - `meta_jsonb` (metadata + provenance) + +### `policy_versions` (tenant-owned evidence) + +- Purpose: immutable captured policy content with assignments/scope tags and hashes, used as content-fidelity evidence. +- Current fields (selected): + - `tenant_id`, `policy_id`, `policy_type`, `platform` + - `captured_at` + - `snapshot`, `metadata`, `assignments`, `scope_tags` + - `assignments_hash`, `scope_tags_hash` + +### `operation_runs` (tenant-owned operational record) + +- Purpose: observable lifecycle for capture/compare operations; `summary_counts` is numeric-only and key-whitelisted; diagnostics go in `context`. + +### `findings` (tenant-owned drift outcomes) + +- Purpose: drift findings produced by compare; recurrence/lifecycle fields already exist in the repo (incl. `recurrence_key`). + +## Proposed changes (Spec 118) + +### 1) BaselineProfile: add capture mode + +**Add column**: `baseline_profiles.capture_mode` (string) + +- Allowed values: `meta_only | opportunistic | full_content` +- Default: `opportunistic` (maintains current behavior unless explicitly enabled) +- Validation: only allow known values + +### 2) Baseline snapshot item: introduce a cross-tenant subject key + +**Add column**: `baseline_snapshot_items.subject_key` (string) + +- Meaning: cross-tenant match key for a subject: `normalized_display_name` +- Normalization rules: trim, collapse internal whitespace, lowercase +- Index: `index(baseline_snapshot_id, policy_type, subject_key)` + +Notes: +- Workspace-owned snapshot items MUST NOT persist tenant identifiers. In Spec 118 flows: + - `baseline_snapshot_items.subject_external_id` is treated as an opaque, workspace-safe **subject id** derived from `policy_type + subject_key` (e.g. `sha256(policy_type|subject_key)`), solely to satisfy existing uniqueness/lookup needs. + - Tenant-specific external IDs remain tenant-scoped and live only in tenant-owned tables (`policies`, `inventory_items`, `policy_versions`) and in tenant-scoped `operation_runs.context`. +- `meta_jsonb` stored on snapshot items MUST be baseline-safe (no tenant external IDs, no operation run IDs, no policy version IDs). It should include only cross-tenant metadata like `display_name`, `policy_type`, and a fidelity indicator (`content` vs `meta`). +- Duplicate/ambiguous `subject_key` values within the same policy type are treated as evidence gaps and are not evaluated for drift. + +### 3) PolicyVersion: purpose tagging + traceability + +**Add columns** (all nullable except purpose): + +- `policy_versions.capture_purpose` (string) + - Allowed: `backup | baseline_capture | baseline_compare` + - Default for existing rows: `backup` (or null → treated as `backup` at read time; exact backfill strategy documented in migration plan) +- `policy_versions.operation_run_id` (unsigned bigint, nullable) → FK to `operation_runs.id` +- `policy_versions.baseline_profile_id` (unsigned bigint, nullable) → FK to `baseline_profiles.id` + +**Indexes** (for audit/debug + idempotency checks): + +- `(tenant_id, policy_id, capture_purpose, captured_at desc)` +- `(tenant_id, capture_purpose, operation_run_id)` +- `(tenant_id, capture_purpose, baseline_profile_id)` + +Retention: +- Baseline-purpose evidence is eligible for shorter retention (configurable) than long-term backup evidence. + +### 4) OperationRun context: baseline capture/compare contract + +Baseline runs should populate `operation_runs.context` with stable, operator-facing keys: + +```json +{ + "target_scope": { + "entra_tenant_id": "...", + "entra_tenant_name": "...", + "directory_context_id": "..." + }, + "baseline_profile_id": 123, + "baseline_snapshot_id": 456, + "capture_mode": "full_content", + "effective_scope": { + "policy_types": ["..."], + "foundation_types": ["..."], + "all_types": ["..."] + }, + "baseline_capture": { + "subjects_total": 500, + "evidence_capture": { + "requested": 200, + "succeeded": 180, + "skipped": 10, + "failed": 10, + "throttled": 0 + }, + "gaps": { + "count": 25, + "top_reasons": ["forbidden", "throttled", "ambiguous_match"] + }, + "resume_token": "opaque_token_string" + }, + "baseline_compare": { + "inventory_sync_run_id": 999, + "since": "2026-03-03T09:00:00Z", + "coverage": { + "proof": true, + "effective_types": ["..."], + "covered_types": ["..."], + "uncovered_types": ["..."] + }, + "fidelity": "content|meta|mixed", + "evidence_capture": { + "requested": 200, + "succeeded": 180, + "skipped": 10, + "failed": 10, + "throttled": 0 + }, + "evidence_gaps": { + "missing_current": 20, + "ambiguous_match": 3 + }, + "reason_code": "no_subjects_in_scope|coverage_unproven|evidence_capture_incomplete|rollout_disabled|no_drift_detected|..." + } +} +``` + +Notes: +- `target_scope` is required for Monitoring UI (“Target” display). +- Rich diagnostics remain in `context`; `summary_counts` stays within the numeric key whitelist. + +## Migration strategy + +1) Add `baseline_profiles.capture_mode`. +2) Add `baseline_snapshot_items.subject_key` + index. +3) Add `policy_versions.capture_purpose`, `operation_run_id`, `baseline_profile_id` + indexes. +4) Backfill strategy: + - Existing `policy_versions` rows: set `capture_purpose = backup` (or treat null as backup in code until backfill finishes). + - Existing baseline snapshot items: set `subject_key` from stored `meta_jsonb.display_name` when available (else empty; treated as gap in new logic). + +## Validation rules + +- `capture_mode` must be one of: `meta_only`, `opportunistic`, `full_content`. +- `subject_key` must be non-empty to be eligible for drift evaluation. +- For full-content capture mode: + - Capture/compare runs must record evidence capture stats and gaps. + - Compare must not emit “missing policy” findings for uncovered policy types. diff --git a/specs/118-baseline-drift-engine/plan.md b/specs/118-baseline-drift-engine/plan.md new file mode 100644 index 0000000..c13fd78 --- /dev/null +++ b/specs/118-baseline-drift-engine/plan.md @@ -0,0 +1,161 @@ +# Implementation Plan: Golden Master Deep Drift v2 (Full Content Capture) + +**Branch**: `118-baseline-drift-engine` | **Date**: 2026-03-03 | **Spec**: /Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/118-baseline-drift-engine/spec.md +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/118-baseline-drift-engine/spec.md` + +## Summary + +Enable reliable, settings-level drift detection (“deep drift”) for Golden Master baselines by making baseline capture and baseline compare self-sufficient: + +- For baseline profiles configured for full-content capture, both capture and compare automatically capture the required policy content evidence on demand (quota-aware, resumable), rather than relying on opportunistic evidence. +- Drift comparison uses the existing canonical fingerprinting pipeline and evidence provider chain (content-first, explicit degraded fallback), with “no legacy” enforced via code paths and automated guards. +- Operations are observable and explainable: each run records effective scope, coverage proof, fidelity breakdown, evidence capture stats, evidence gaps, and “why no findings” reason codes. +- Security and governance constraints are enforced: captured policy evidence is redacted before persistence/fingerprinting, audit events are emitted for capture/compare/resume mutations, baseline-purpose evidence is pruned per retention policy, and full-content mode is gated by a short-lived rollout flag. +- Admin UX exposes single-click actions (“Capture baseline (full content)”, “Compare now (full content)”, and “Resume capture” when applicable), surfaces evidence gaps clearly, and provides baseline snapshot fidelity visibility (content-complete vs gaps). + +## Technical Context + +**Language/Version**: PHP 8.4.15 +**Primary Dependencies**: Laravel 12.52, Filament 5.2, Livewire 4.1, Microsoft Graph integration via `GraphClientInterface` +**Storage**: PostgreSQL (JSONB-heavy for evidence/snapshots) +**Testing**: Pest 4.3 (PHPUnit 12.5) +**Target Platform**: Containerized web app (Local: Sail; Staging/Production: Dokploy) +**Project Type**: Web application (Laravel monolith + Filament admin panel) +**Performance Goals**: Capture/compare runs handle 200–500 in-scope subjects per run under throttling constraints, without blocking UI; evidence capture is bounded and resumable. +**Constraints**: All long-running + remote work is async + observable via `OperationRun`; rate limits (429/503) must back off safely; no secrets/PII persisted in evidence or logs; tenant/workspace isolation is strict. +**Scale/Scope**: Multi-workspace, multi-tenant; per tenant potentially hundreds–thousands of policies; baselines may be assigned to multiple tenants in a workspace. + +**Initial budget defaults (v1, adjustable via config)**: +- `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` + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- PASS — Inventory-first: Inventory remains the subject index (“last observed”), while content evidence is captured explicitly as immutable policy versions for comparison. +- PASS — Read/write separation: this feature adds/extends read-only capture/compare operations (no restore); any destructive UI actions remain confirmed + audited. +- PASS — Graph contract path: evidence capture uses existing Graph client abstractions and contract registry (`/Users/ahmeddarrazi/Documents/projects/TenantAtlas/config/graph_contracts.php`); no direct/adhoc endpoints in feature code. +- PASS — Deterministic capabilities: capability gating continues through the canonical capability resolvers and enforcement helpers (no role-string checks). +- PASS — RBAC-UX: workspace membership + capability gates enforced server-side; non-member access is deny-as-not-found; member missing capability is forbidden. +- PASS — Workspace & tenant isolation: baseline profiles/snapshots are workspace-owned; compare runs/findings/evidence remain tenant-scoped; canonical Monitoring pages remain DB-only at render time. +- PASS — Ops observability: baseline capture/compare are `OperationRun`-backed; start surfaces enqueue-only; no remote work at render time. +- PASS — Ops-UX 3-surface feedback + lifecycle + summary counts: enqueue toast uses the canonical presenter; progress shown only in global widget + run detail; completion emits exactly one terminal DB notification to initiator; status/outcome transitions remain service-owned; summary counts stay numeric-only using canonical keys. +- PASS — Automation & throttling: evidence capture respects 429/503 backoff + jitter (client + phase-level budget handling) and supports resumption via an opaque token stored in run context. +- PASS — BADGE-001: any new/changed badges use existing badge catalog mapping (no ad-hoc). +- PASS — Filament action surface + UX-001: actions are declared, capability-gated, and confirmed where destructive-like; tables maintain an inspect affordance; view uses infolists; empty states have 1 CTA. + +## Project Structure + +### Documentation (this feature) + +```text +/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/118-baseline-drift-engine/ +├── spec.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +└── tasks.md +``` + +### Source Code (repository root) + +```text +/Users/ahmeddarrazi/Documents/projects/TenantAtlas/ +app/ +├── Filament/ +│ ├── Pages/BaselineCompareLanding.php +│ ├── Resources/BaselineProfileResource.php +│ └── Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php +├── Jobs/ +│ ├── CaptureBaselineSnapshotJob.php +│ └── CompareBaselineToTenantJob.php +├── Models/ +│ ├── BaselineProfile.php +│ ├── BaselineSnapshot.php +│ ├── BaselineSnapshotItem.php +│ ├── BaselineTenantAssignment.php +│ ├── Policy.php +│ ├── PolicyVersion.php +│ ├── InventoryItem.php +│ ├── OperationRun.php +│ └── Finding.php +├── Services/ +│ ├── Baselines/ +│ │ ├── BaselineCaptureService.php +│ │ ├── BaselineCompareService.php +│ │ ├── CurrentStateHashResolver.php +│ │ └── Evidence/ +│ ├── Intune/PolicyCaptureOrchestrator.php +│ └── OperationRunService.php +├── Support/ +│ ├── Baselines/ +│ ├── OpsUx/ +│ └── OperationRunType.php +config/ +├── graph_contracts.php +└── tenantpilot.php +database/ +└── migrations/ +tests/ +└── Feature/ +``` + +**Structure Decision**: Laravel monolith. Baseline drift orchestration lives in `app/Services/Baselines` + `app/Jobs`, UI in `app/Filament`, and evidence capture reuses `app/Services/Intune` capture orchestration. + +Tasks are defined in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/118-baseline-drift-engine/tasks.md`. + +## Complexity Tracking + +No constitution violations are required for Spec 118 planning. (Table intentionally omitted.) + +## Phase 0 — Research (output: research.md) + +Goals: +- Confirm precise extension points for adding full-content evidence capture to existing baseline capture/compare jobs. +- Decide the purpose-tagging and idempotency strategy for baseline evidence captured as `PolicyVersion`. +- Confirm Monitoring run detail requirements for `context.target_scope` and baseline-specific context sections. + +Deliverable: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/118-baseline-drift-engine/research.md` + +## Phase 1 — Design (output: data-model.md + contracts/* + quickstart.md) + +Deliverables: +- Data model changes + JSON context shapes: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/118-baseline-drift-engine/data-model.md` +- Route surface contract reference: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/118-baseline-drift-engine/contracts/openapi.yaml` +- Developer quickstart: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/118-baseline-drift-engine/quickstart.md` + +Post-design constitution re-check: PASS (see decisions in research + data model docs; Ops-UX and RBAC constraints preserved). + +## Phase 2 — Implementation Planning (high-level) + +1) Add migrations: + - `baseline_profiles.capture_mode` + - `baseline_snapshot_items.subject_key` + - `policy_versions.capture_purpose`, `operation_run_id`, `baseline_profile_id` + indexes +2) Implement quota-aware, resumable baseline evidence capture phase: + - reuse existing capture orchestration (policy payload + assignments + scope tags) + - emit capture stats + resume token in `OperationRun.context` +3) Integrate the capture phase into: + - baseline capture job (before snapshot build) + - baseline compare job (refresh phase before drift evaluation) +4) Update drift matching to use cross-tenant subject key (`policy_type + subject_key`) where `subject_key` is the normalized display name, and record ambiguous/missing match as evidence gaps (no finding). +5) Update Ops-UX context: + - ensure `context.target_scope` exists for baseline capture/compare runs + - add “why no findings” reason codes +6) Update UI action surfaces: + - Baseline profile: capture mode + “Capture baseline (full content)” + tenant-targeted “Compare now (full content)” + - Operation run detail: evidence capture panel + “Resume capture” when token exists +7) Add focused Pest tests: + - full-content capture creates content-fidelity snapshot items (or warnings + gaps) + - compare detects settings drift with content evidence + - throttling/resume semantics and “no silent zeros” reason codes +8) Add governance hardening: + - enforce rollout gate across UI/services/jobs for full-content mode + - redact secrets/PII from captured evidence before persistence/fingerprinting + - emit audit events for capture/compare/resume operations + - prune baseline-purpose evidence per retention policy (scheduled) diff --git a/specs/118-baseline-drift-engine/quickstart.md b/specs/118-baseline-drift-engine/quickstart.md new file mode 100644 index 0000000..6f587f2 --- /dev/null +++ b/specs/118-baseline-drift-engine/quickstart.md @@ -0,0 +1,43 @@ +# Quickstart — Spec 118 Golden Master Deep Drift v2 + +Spec reference: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/118-baseline-drift-engine/spec.md` + +## Prereqs + +- Docker running +- Dependencies installed: `vendor/bin/sail composer install` +- Containers up: `vendor/bin/sail up -d` + +## Run the minimum checks + +- Format (dirty only): `vendor/bin/sail bin pint --dirty --format agent` +- Tests (focused): `vendor/bin/sail artisan test --compact --filter=Baseline` (adjust filter to match added tests) + +## Manual verification flow (admin) + +1) In `/admin`, open a Baseline Profile and set capture mode to **Full content**. +2) Run **Capture baseline (full content)** and select a source tenant. +3) Open Monitoring → Operations → the capture `OperationRun`: + - Verify `context.target_scope` renders a “Target” (no “No target scope details…”). + - Verify `context.baseline_capture.evidence_capture` and `context.baseline_capture.gaps` exist. + - If capture was incomplete, verify `context.baseline_capture.resume_token` exists and UI offers **Resume capture**. +4) In `/admin/t/{tenant}`, open Baseline Compare and run **Compare now (full content)**. +5) Open Monitoring → the compare `OperationRun`: + - Verify coverage proof is recorded (`context.baseline_compare.coverage`). + - Verify evidence capture stats and evidence gaps are present. + - Verify “Why no findings?” is explained via `context.baseline_compare.reason_code` when applicable. +6) Open Findings: + - Verify drift findings appear when policy settings differ. + - Verify findings include fidelity/provenance fields for baseline and current evidence. + +## Forcing a resumable run (dev-only) + +To test resume behavior without waiting for real throttling: + +- Temporarily reduce the per-run capture budget in config (planned in Spec 118) so the run cannot process the full scope. +- Re-run capture/compare and verify a resume token is recorded and **Resume capture** continues work without duplicating already-captured subjects. + +## Troubleshooting + +- If UI changes don’t appear, run assets: `vendor/bin/sail npm run dev`. +- If tests fail due to stale schema, run: `vendor/bin/sail artisan migrate`. diff --git a/specs/118-baseline-drift-engine/research.md b/specs/118-baseline-drift-engine/research.md new file mode 100644 index 0000000..1348c3d --- /dev/null +++ b/specs/118-baseline-drift-engine/research.md @@ -0,0 +1,102 @@ +# Research — Spec 118 Golden Master Deep Drift v2 + +This document resolves planning unknowns for implementing `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/118-baseline-drift-engine/spec.md` in the existing Laravel + Filament codebase. + +## Decision 1 — Full-content evidence capture orchestration + +**Decision**: Introduce a dedicated “baseline content capture” phase that can be invoked from both baseline capture and baseline compare: + +- Baseline capture (`baseline_capture` run): capture evidence needed to build a content-fidelity baseline snapshot (as budget allows). +- Baseline compare (`baseline_compare` run): refresh current evidence before drift evaluation (as budget allows). + +The phase reuses the existing Intune capture orchestration (`/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Intune/PolicyCaptureOrchestrator.php`) so we do not introduce a second capture implementation. + +**Rationale**: +- Aligns with Spec 118 goal: deep drift by default, without per-policy manual capture. +- Keeps a single source of truth for content capture (policy payload + assignments + scope tags). +- Makes quota management, retries, and resumability explicit at the operation level. + +**Alternatives considered**: +- Opportunistic only (rejected: repeats Spec 117 fragility; “no drift” can still be a silent failure). +- UI-driven per-policy capture (rejected: explicitly out of UX goals). + +## Decision 2 — PolicyVersion purpose tagging + run traceability + +**Decision**: Extend `policy_versions` with baseline-purpose attribution: + +- `capture_purpose`: `backup | baseline_capture | baseline_compare` +- `operation_run_id` (nullable): link to the run that captured the version +- `baseline_profile_id` (nullable): link for baseline_* captures + +**Rationale**: +- Enables audit/debug (“which run produced this evidence, for what purpose?”) without introducing a separate evidence table. +- Supports idempotency and “resume capture” semantics (skip already-captured subjects for the same run/purpose). + +**Alternatives considered**: +- Store purpose only in `policy_versions.metadata` (rejected: harder to index/query; weaker guardrails). +- Create an EvidenceItems model now (rejected: explicitly not required in Spec 118). + +## Decision 3 — Golden Master subject matching across tenants + +**Decision**: Treat the Golden Master “subject identity” as a cross-tenant match key derived from policy display name: + +- Subject match key: `policy_type + normalized_display_name` +- `normalized_display_name` rules: trim leading/trailing whitespace, collapse internal whitespace to single spaces, lowercase. + +Implementation uses a dedicated snapshot-item field (e.g., `baseline_snapshot_items.subject_key`) for matching, while preserving tenant-specific external IDs separately for evidence resolution. + +Ambiguous/missing match handling: +- Missing match in current tenant → eligible for “missing policy” (only with coverage proof). +- Multiple matches for the same key within a tenant/type → record evidence gap and suppress drift evaluation for that subject key (no finding). + +**Rationale**: +- Baselines are workspace-owned and can be assigned to multiple tenants; external IDs are tenant-specific and cannot be used for cross-tenant matching. +- The match key keeps snapshot items free of tenant identifiers while enabling consistent comparisons. + +**Alternatives considered**: +- Match by tenant external ID (rejected: breaks cross-tenant baseline assignment). +- Require per-tenant baseline snapshots (rejected for Spec 118: changes product semantics and assignment UX). +- Introduce an explicit mapping table (rejected for R1: higher effort and requires operational UX not described in spec). + +## Decision 4 — Quota-aware capture + resumable token + +**Decision**: Evidence capture is bounded and resumable: + +- Enforce per-run limits (max items, max concurrency, max retry attempts). +- Store an opaque “resume token” in `operation_runs.context` when a run cannot complete within budget. +- Provide a “Resume capture” UI action that starts a follow-up run continuing from that token. + +**Rationale**: +- Large tenants/scopes must not create uncontrolled queue storms or long-running jobs. +- Operators need explicit visibility into “what was captured vs skipped” and a safe path to completion. + +**Alternatives considered**: +- “Always finish no matter what” (rejected: risks rate limiting and operational instability). +- Mark run failed on any capture failure (rejected: Spec 118 allows partial failure with warnings). + +## Decision 5 — Ops-UX + run context contract (“Why no findings?”) + +**Decision**: Baseline runs explicitly populate: + +- `context.target_scope` (required for Monitoring run detail; avoids “No target scope details…”) +- `context.effective_scope` + `context.capture_mode` +- evidence capture stats + gaps + reason codes when subjects processed = 0 or findings = 0 + +Keep `summary_counts` numeric-only and limited to keys from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/OpsUx/OperationSummaryKeys.php`; store richer detail in `context`. + +**Rationale**: +- Eliminates ambiguous “0 findings” outcomes and improves operator trust. +- Conforms to Ops-UX 3-surface feedback contract and Monitoring expectations. + +**Alternatives considered**: +- Put details into `summary_counts` (rejected: key whitelist contract). +- Only log details (rejected: operators need UI visibility). + +## Notes on current codebase (facts observed) + +- Baseline capture run creation: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Baselines/BaselineCaptureService.php` +- Baseline compare run creation: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Baselines/BaselineCompareService.php` +- Capture job (currently opportunistic content): `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/CaptureBaselineSnapshotJob.php` +- Compare job (provider-chain evidence): `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/CompareBaselineToTenantJob.php` +- Evidence providers + resolver: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Baselines/CurrentStateHashResolver.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Baselines/Evidence/*` +- Monitoring target scope rendering expects `context.target_scope`: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/OperationRunResource.php` diff --git a/specs/118-baseline-drift-engine/spec.md b/specs/118-baseline-drift-engine/spec.md new file mode 100644 index 0000000..a200a6b --- /dev/null +++ b/specs/118-baseline-drift-engine/spec.md @@ -0,0 +1,281 @@ +# Feature Specification: Golden Master Deep Drift v2 (Full Content Capture) + +**Feature Branch**: `118-baseline-drift-engine` +**Created**: 2026-03-03 +**Status**: Draft (implementable) +**Input**: User description: "Spec 118 — Golden Master Deep Drift v2: Full Content Capture (policy snapshot-backed), quota-aware, resumable, no-legacy" + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace (baseline definition + baseline snapshots) + tenant (compare runs + findings + evidence capture) +- **Primary Routes**: + - Workspace admin: Baseline Profiles (list, create/edit, detail) + Baseline Snapshots (list/detail) + - Tenant-context admin: Baseline Compare runs (start, list, detail) + Drift Findings landing +- **Data Ownership**: + - Workspace-owned: baseline profiles, baseline snapshots, baseline snapshot items + - Tenant-scoped (within a workspace): operation runs for baseline capture/compare, drift findings, and tenant policy evidence captured for baseline purposes + - Baseline snapshots are workspace-owned standards captured from a chosen tenant and are comparable against other tenants in the same workspace, but snapshot items MUST NOT persist tenant identifiers (including tenant IDs, tenant external IDs, policy version IDs, and operation run IDs). +- **RBAC**: + - Workspace Baselines: + - `workspace_baselines.view`: view baseline profiles + snapshots + - `workspace_baselines.manage`: create/edit baseline profiles, start baseline capture + - Tenant Compare + Findings: + - `tenant.sync`: start baseline compare runs (and any compare-time evidence refresh) + - `tenant_findings.view`: view drift findings + - Tenant access is required for tenant-context surfaces, in addition to workspace membership + - Evidence created for baseline purposes MUST NOT be broadly discoverable outside baseline-related permissions. + - **Baseline-purpose evidence visibility**: Tenant-owned evidence snapshots / policy versions captured for baseline purposes (e.g. `capture_purpose=baseline_capture|baseline_compare`) MUST be visible only to tenant members with `tenant.sync` or `tenant_findings.view` (never via `tenant.view` alone). + +For canonical-view specs: not applicable (this is not a canonical-view feature). + +## Clarifications + +### Session 2026-03-03 + +- Q: Are baseline snapshots reusable across multiple tenants in the workspace? → A: Yes — baseline snapshots are reusable across multiple tenants in the same workspace (cross-tenant compare is in-scope). +- Q: How should cross-tenant subject matching work? → A: Match by `policy_type + normalized display_name`. +- Q: What should compare do when cross-tenant matching is missing/ambiguous? → A: Record an evidence gap reason and suppress drift evaluation for those subjects. +- Q: What are the exact rules for `normalized display_name`? → A: `trim` + collapse internal whitespace to single spaces + lowercase. + +## Problem Statement + +Golden Master baseline compare frequently produces “no drift” even when policy settings changed, because the current state used for comparison is often limited to a metadata-level signal, while the real configuration is only visible in full policy content. + +This spec makes Golden Master self-sufficient for deep drift: when a baseline profile is configured for full-content capture, baseline capture and baseline compare automatically generate the required evidence on demand and compare stable content-based fingerprints. + +## Goals + +- Deep drift by default for baselines configured for full-content capture. +- One compare engine: no parallel legacy compare / fingerprinting / canonicalization logic paths. +- Quota-aware and resumable evidence capture that remains safe under throttling and transient upstream errors. +- Auditability: each run clearly documents scope, coverage, fidelity, evidence capture stats, and any evidence gaps. +- Operator UX: an admin can “Capture baseline (full content)” and “Compare now (full content)” without per-policy manual capture. + +## Non-Goals + +- No export/PDF/report packaging pipeline. +- No SIEM replacement or ingestion of external audit streams. +- No requirement to introduce a separate “evidence item” reporting model; this spec remains compatible with a future evidence/reporting layer. + +## Definitions + +- **Subject**: a single compare object identified for cross-tenant comparison by `policy_type + subject_key` (tenant context is provided by the run, not persisted in the workspace-owned snapshot item). +- **Normalized display name**: derived from display name by trimming leading/trailing whitespace, collapsing internal whitespace to single spaces, and converting to lowercase. +- **Subject key (`subject_key`)**: the stored, cross-tenant match key for a subject, equal to the normalized display name. +- **Baseline snapshot**: a workspace-owned captured snapshot of subjects within a baseline scope. +- **Evidence snapshot**: an immutable record of full policy content captured from the tenant for a subject, used to produce a stable, comparable fingerprint. +- **Fidelity**: + - **content**: drift signal derived from canonicalized full policy content (including assignments and scope tags when applicable) + - **meta**: drift signal derived from a stable metadata contract (explicitly marked degraded) +- **Coverage proof**: proof that the tenant current-state index is complete enough to safely determine missing-policy outcomes for the scope. + +## Assumptions + +- Baseline drift already records observable run records for capture and compare. +- The system already has a canonical process for turning full policy content into a stable fingerprint for comparison, used by other workflows. +- Some subjects may not be capturable (permissions, unsupported endpoints, temporary upstream issues); these produce warnings and explicit gaps rather than silent success. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Capture a full-content baseline without per-policy steps (Priority: P1) + +As a workspace admin, I want to capture a baseline snapshot with full-content fidelity across the entire configured scope, so that Golden Master comparisons detect settings drift reliably without manually capturing each policy. + +**Why this priority**: This is the primary value proposition for deep drift: baseline capture must produce strong evidence automatically. + +**Independent Test**: Can be tested by creating a baseline profile configured for full-content capture, running “Capture baseline”, and validating that a baseline snapshot is created with content fidelity for all capturable subjects and explicit gaps for any non-capturable subjects. + +**Acceptance Scenarios**: + +1. **Given** a baseline profile configured for full-content capture and a tenant with in-scope subjects, **When** I run “Capture baseline (full content)”, **Then** the system captures evidence snapshots on demand for subjects missing suitable evidence and produces a baseline snapshot with per-subject fingerprints and fidelity. +2. **Given** a capture run where some subjects cannot be captured due to throttling or access limitations, **When** the run completes, **Then** the run outcome is “completed with warnings” and the UI shows an evidence gap summary (counts + reasons) rather than presenting a misleading “fully captured” state. + +--- + +### User Story 2 - Compare now with full content and get explainable drift (Priority: P1) + +As an operator, I want to run “Compare now (full content)” and see reliable drift findings (missing/unexpected/different), with clear context about coverage and evidence fidelity, so that I can act on findings with confidence. + +**Why this priority**: The feature is only trustworthy when “no drift” is explainable and “drift” is based on strong evidence. + +**Independent Test**: Can be tested by capturing a full-content baseline, simulating a settings-only change for a subject, running “Compare now (full content)”, and asserting that a drift finding is produced with content fidelity provenance. + +**Acceptance Scenarios**: + +1. **Given** a full-content baseline snapshot and current evidence refreshed as part of compare, **When** a subject’s settings differ between baseline and current, **Then** compare emits a “different version” finding with content fidelity and stores evidence provenance for both baseline and current. +2. **Given** a compare run where coverage proof is missing for some policy types, **When** compare runs, **Then** the system suppresses “missing policy” outcomes for uncovered types and records a coverage warning and explanation in the run detail. + +--- + +### User Story 3 - Throttling-safe, resumable evidence capture (Priority: P1) + +As an operator, I want evidence capture to respect rate limits and safely resume from where it left off, so that deep drift can be executed in large scopes without manual babysitting. + +**Why this priority**: Full-content capture is only viable if it behaves predictably under real-world quotas. + +**Independent Test**: Can be tested by simulating rate limiting for part of the scope, verifying that the run completes with warnings and a resume token, then resuming and eventually completing without duplicating evidence work. + +**Acceptance Scenarios**: + +1. **Given** a capture/compare run that hits rate limiting before completing the scope, **When** the run ends, **Then** it records an opaque resume token and a deterministic gap list, and it can be resumed via a single UI action. +2. **Given** a resumed capture, **When** it continues from the resume token, **Then** it does not re-capture subjects already captured in the prior run for the same purpose. + +--- + +### User Story 4 - “Why no findings?” is always clear (Priority: P2) + +As an operator, I want the compare run detail to explain “why no findings” (e.g., no subjects, coverage unproven, evidence capture incomplete), so that zero findings never looks like a silent failure. + +**Why this priority**: Operator trust depends on eliminating ambiguous “0 findings” states. + +**Independent Test**: Can be tested by running compare in a scenario that processes zero subjects (or suppresses findings due to coverage), and verifying that the UI shows a clear explanation sourced from run context. + +**Acceptance Scenarios**: + +1. **Given** a compare run where the resolved subject list is empty, **When** the run completes, **Then** the run context contains a reason code explaining why and the UI displays it. +2. **Given** a compare run that produces zero findings but processed subjects, **When** it completes, **Then** it still records a reason code such as “no drift detected” and provides evidence/fidelity context. + +### Edge Cases + +- Scope resolves to zero subjects: compare and capture complete with warnings and an explicit reason code; no silent success. +- Some subjects are forbidden/unsupported: they are recorded as evidence gaps with reasons; drift evaluation is degraded or skipped per rules. +- Evidence is available but cannot be normalized deterministically: the run degrades fidelity for that subject and records the gap reason. +- Compare is retried after a transient failure: findings are not duplicated; lifecycle increments happen at most once per run identity. +- Mixed evidence (some content, some meta): the run clearly reports breakdown; findings display the weaker-of-two fidelity for badge/filter semantics. + +## Requirements *(mandatory)* + +### Constitution alignment (required) + +- This feature performs outbound reads to capture full policy content as evidence, and it does so via observable long-running runs. +- The feature MUST use a single canonical method to produce content-fidelity fingerprints, shared with other workflows. +- “No legacy” is enforced: capture/compare orchestration does not implement per-policy fingerprinting logic and does not call legacy meta drift helpers. + +### Operational UX Contract (Ops-UX) + +- Baseline capture and baseline compare MUST run as observable operations with a run identity, start/stop times, outcome, and a user-facing progress surface. +- Run lifecycle transitions are service-owned. +- “Completed with warnings” MUST be used when evidence capture or coverage proof is incomplete. +- Compare runs MUST NEVER silently produce “0 findings” without an explicit explanation. The run context MUST include a reason code when: + - the resolved subject total is 0, or + - the processed subject count is 0, or + - findings are suppressed due to coverage/evidence rules. + +### Authorization Contract (RBAC-UX) + +- Authorization planes: + - Workspace admin surfaces for baseline profiles/snapshots + - Tenant-context admin surfaces for compare runs and findings +- 404 vs 403 semantics: + - Non-member / not entitled to workspace scope OR tenant scope → 404 (deny-as-not-found) + - Member but missing capability → 403 +- Starting runs (“Capture baseline”, “Compare now”, “Resume capture”) is a mutation and MUST be enforced server-side. + +### Functional Requirements + +#### Configuration & Modes + +- **FR-118-01 Capture mode**: Baseline profiles MUST support a capture mode with at least: meta-only, opportunistic, and full-content. +- **FR-118-02 Deep drift by default**: For baseline profiles with capture mode = full-content, baseline capture and compare MUST prioritize content fidelity for all capturable subjects. +- **FR-118-03 One engine, no legacy**: There MUST be exactly one compare engine and one canonical fingerprinting method. No parallel “legacy compare” or “legacy fingerprint” implementations may exist. + +#### Subject Scope & Coverage + +- **FR-118-04 Effective scope resolution**: Each run MUST resolve and persist an effective scope for the baseline profile (including total subject count). +- **FR-118-04a Cross-tenant matching**: When comparing a workspace-owned baseline snapshot to a tenant’s current state, subject matching MUST use `policy_type + subject_key` where `subject_key` is the normalized display name, and workspace-owned snapshot items MUST NOT persist tenant identifiers. +- **FR-118-04a1 Normalization rules**: The definition of `subject_key` (normalized display name) MUST be consistent across baseline capture and compare: trim leading/trailing whitespace, collapse internal whitespace to single spaces, and lowercase. +- **FR-118-04b Ambiguous/missing match handling**: If cross-tenant matching is missing or ambiguous for a subject (e.g., missing display name, multiple candidates for the same normalized name within a policy type), compare MUST record an evidence gap reason and MUST suppress drift evaluation for that subject. +- **FR-118-05 Coverage proof guard**: Compare MUST only emit “missing policy” outcomes when coverage proof exists for the policy type. If coverage proof is missing/unproven, missing-policy outcomes for that type MUST be suppressed and a warning MUST be recorded. + +#### Baseline Capture (full-content) + +- **FR-118-CAP-01 Preflight**: Capture MUST resolve the subject list for the effective scope and record the total subject count in run context. +- **FR-118-CAP-02 Evidence capture on demand**: For full-content capture, the system MUST capture any missing or stale evidence snapshots for in-scope subjects, up to a configurable per-run budget. +- **FR-118-CAP-03 Idempotency within run**: Within a single run, the same subject MUST NOT be captured more than once for the same capture purpose. +- **FR-118-CAP-04 Snapshot build**: Baseline snapshots MUST store a per-subject stable fingerprint plus: + - fingerprint fidelity (`content` vs `meta`) + - fingerprint source/provenance indicator + - observed timestamp +- **FR-118-CAP-05 Incomplete capture semantics**: If full-content capture is incomplete, the run MUST complete with warnings, the snapshot may still be created, and any subjects that fell back to meta fidelity (or were skipped) MUST be recorded as gaps. + +#### Baseline Compare (full-content) + +- **FR-118-CMP-01 Current evidence refresh**: For full-content compares, compare MUST refresh current evidence for in-scope subjects before drift evaluation, within a configurable budget. +- **FR-118-CMP-02 Best-available state resolution**: Current state resolution MUST always prefer full-content evidence when available and fall back to explicitly degraded metadata evidence only when necessary. Compare orchestration MUST NOT implement fingerprinting itself. +- **FR-118-CMP-03 Drift rules**: For each subject: + - baseline-only → missing policy (only when coverage proof exists for the type) + - current-only → unexpected policy + - both present and fingerprints differ → different version +- **FR-118-CMP-04 Stable finding identity + lifecycle**: Findings MUST have a stable recurrence identity independent of fingerprints, and MUST maintain lifecycle fields (first seen, last seen, times seen). Retries MUST NOT duplicate findings. +- **FR-118-CMP-05 Explainability**: Compare run context MUST include: + - scope totals and processed counts + - coverage proof status + - fidelity breakdown (content vs meta) + - evidence capture stats (requested/succeeded/skipped/failed/throttled) + - evidence gaps (counts + top reasons, including missing/ambiguous cross-tenant match) + +#### Quota, Throttling, Resume + +- **FR-118-Q-01 Budget controls**: Evidence capture MUST be bounded by configurable limits (concurrency, items-per-run, retry limits) with safe defaults; default values MUST be explicitly defined in configuration and documented in the implementation plan. +- **FR-118-Q-02 Throttling behavior**: When rate limiting or temporary upstream errors occur, capture MUST back off and retry within limits, and then record throttling as a gap reason if it cannot complete. +- **FR-118-Q-03 Resumable token**: When a run cannot complete the scope within its budget, the run context MUST include an opaque resume token and enough information to resume deterministically. +- **FR-118-Q-04 Partial failure**: Individual subjects may fail without failing the entire run, but the run MUST complete with warnings and must deterministically report gaps. + +#### Auditability & Retention + +- **FR-118-AUD-01 Run auditing**: Each run MUST record scope, coverage, fidelity breakdown, evidence capture stats, and evidence gaps. +- **FR-118-AUD-02 Evidence purpose tagging**: Evidence snapshots captured for baseline purposes MUST be attributable to the initiating run and baseline profile for audit/debugging. +- **FR-118-AUD-03 Retention policy**: Evidence captured for baseline purposes MUST have a configurable retention distinct from long-term backup evidence. + +#### Security & Compliance + +- **FR-118-SEC-01 Redaction before persistence**: The system MUST remove secrets/PII from captured policy content before it is stored or used to produce fingerprints. +- **FR-118-SEC-02 Least privilege access**: Evidence captured for baseline purposes MUST be access-controlled and not broadly visible outside baseline-related permissions. +- **FR-118-SEC-03 Audit events**: Starting baseline evidence capture and compare runs MUST write audit events that include purpose, scope counts, and gap/warning summaries. + +#### UX Requirements + +- **FR-118-UX-01 Single-action buttons**: Baseline profile screens MUST provide: + - “Capture baseline (full content)” + - “Compare now (full content)” +- **FR-118-UX-02 Evidence gaps panel**: Compare run detail MUST include an “Evidence capture” panel showing content coverage percentage, fallback counts, and top gap reasons, and MUST provide “Resume capture” when a resume token exists. +- **FR-118-UX-03 Snapshot fidelity visibility**: Snapshot list/detail MUST show whether the snapshot is content-complete or captured with gaps, and show counts by fidelity. +- **FR-118-UX-04 Why-no-findings explanation**: When a run processes zero subjects or produces zero findings, the UI MUST display a clear explanation sourced from the run context reason code. + +#### Rollout + +- **FR-118-ROL-01 Controlled rollout**: Full-content baseline capture/compare MUST be gated by a short-lived rollout flag for canary deployment. +- **FR-118-ROL-02 No-legacy regression guard**: Automated guardrails MUST prevent re-introduction of legacy fingerprinting/compare paths. + +## UI Action Matrix *(mandatory when Filament is changed)* + +This spec adds/changes operational actions and run-detail panels on existing baseline/compare surfaces. + +For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?), +RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log. + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Baseline Profile (workspace admin) | Admin workspace | Capture baseline (full content); Compare now (full content) | View/inspect baseline profile | Edit (existing), Archive (existing, confirmed) | None | Create baseline profile (existing) | N/A | Save/Cancel (existing) | Yes | Starting capture/compare writes audit events and creates observable runs | +| Compare Run Detail (tenant-context admin) | Admin tenant-context | Resume capture (only when resume token exists) | Linked from runs list | None | None | N/A | N/A | N/A | Yes | Evidence capture panel + why-no-findings explanation sourced from run context | +| Drift Findings landing (tenant-context admin) | Admin tenant-context | None | Open finding (existing) | None | None | Existing CTA | N/A | N/A | Yes (existing) | Findings show fidelity badge + provenance for baseline/current evidence | + +### Key Entities *(include if feature involves data)* + +- **Baseline profile**: Defines scope and capture mode, and is the parent for baseline snapshots. +- **Baseline snapshot**: A captured baseline reference set for a baseline profile. +- **Baseline snapshot item**: Per-subject baseline evidence (fingerprint, fidelity, provenance, observed timestamp). +- **Evidence snapshot**: Immutable captured policy content used to produce a stable, comparable fingerprint. +- **Operation run**: Observable record of a capture/compare execution, including context, coverage, fidelity breakdown, stats, and gaps. +- **Finding**: Recurring drift result with stable identity and lifecycle fields, plus evidence for baseline/current. +- **Resume token**: Opaque token that enables resuming evidence capture deterministically. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-118-01 Deep drift reliability**: For baseline profiles configured for full-content capture, settings-only changes for in-scope subjects produce a “different version” drift finding with a success rate of at least 95% in controlled tests. +- **SC-118-02 No silent zeros**: 100% of compare runs that process zero subjects or produce zero findings include a run-context reason code and display a corresponding explanation in the UI. +- **SC-118-03 Resumable capture**: In controlled tests with simulated rate limiting, evidence capture completes across one or more resumed runs without duplicating captured subjects and with deterministic gap reporting. +- **SC-118-04 Operator clarity**: On run detail pages, operators can access effective scope, coverage status, fidelity breakdown, capture stats, and evidence gaps without navigating to additional pages. +- **SC-118-05 No-legacy enforcement**: Automated checks reliably fail when legacy fingerprinting/compare helpers are referenced by baseline capture/compare orchestration. diff --git a/specs/118-baseline-drift-engine/tasks.md b/specs/118-baseline-drift-engine/tasks.md new file mode 100644 index 0000000..7f5a144 --- /dev/null +++ b/specs/118-baseline-drift-engine/tasks.md @@ -0,0 +1,243 @@ +--- + +description: "Task list for Spec 118 implementation" + +--- + +# Tasks: Golden Master Deep Drift v2 (Full Content Capture) + +**Input**: Design documents from `/specs/118-baseline-drift-engine/` + +**Tests**: REQUIRED (Pest) — this feature changes runtime behavior. + +**Terminology**: `subject_key` = Spec 118 `normalized display_name` (trim + collapse internal whitespace + lowercase). + +**Data isolation (SCOPE-001)**: Workspace-owned `baseline_snapshot_items` MUST NOT persist tenant identifiers (no tenant IDs, no tenant external IDs, no operation run IDs, no policy version IDs) — only cross-tenant keys + non-tenant metadata. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Establish a safe baseline and introduce feature-level configuration scaffolding. + +- [X] T001 Capture current baseline behavior by running existing suites in `tests/Feature/Baselines/BaselineCaptureTest.php`, `tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php`, and `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php` +- [X] T002 [P] Add Spec 118 rollout + budget env vars to `.env.example` (e.g. `TENANTPILOT_BASELINE_FULL_CONTENT_CAPTURE_ENABLED`, `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`) +- [X] T003 [P] Add config surface for Spec 118 rollout + budgets in `config/tenantpilot.php` (new `baselines.full_content_capture.*` keys sourced from env) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared primitives required by ALL user stories. + +**⚠️ CRITICAL**: No user story work should begin until this phase is complete. + +- [X] T004 Add baseline capture mode enum in `app/Support/Baselines/BaselineCaptureMode.php` (values: `meta_only`, `opportunistic`, `full_content`) +- [X] T005 [P] Add policy version capture purpose enum in `app/Support/Baselines/PolicyVersionCapturePurpose.php` (values: `backup`, `baseline_capture`, `baseline_compare`) +- [X] T006 [P] Add subject-key helper in `app/Support/Baselines/BaselineSubjectKey.php` (normalize display name + derive workspace-safe subject id as `sha256(policy_type|subject_key)` for `baseline_snapshot_items.subject_external_id`) +- [X] T007 [P] Add baseline compare “why no findings” reason codes in `app/Support/Baselines/BaselineCompareReasonCode.php` (e.g. `no_subjects_in_scope`, `coverage_unproven`, `evidence_capture_incomplete`, `rollout_disabled`, `no_drift_detected`) +- [X] T008 [P] Add full-content rollout gate helper in `app/Support/Baselines/BaselineFullContentRolloutGate.php` (reads `config('tenantpilot.baselines.full_content_capture.enabled')`, provides an `assertEnabled()` used by services + jobs) +- [X] T009 [P] Add resume token contract in `app/Support/Baselines/BaselineEvidenceResumeToken.php` (versioned encode/decode; stored as opaque string in `operation_runs.context.*.resume_token`) +- [X] T010 [P] Add policy snapshot redactor in `app/Services/Intune/PolicySnapshotRedactor.php` (remove secrets/PII from payload/assignments/scope tags before persistence + hashing) +- [X] T011 [P] Add redaction coverage test in `tests/Feature/Intune/PolicySnapshotRedactionTest.php` (assert stored `PolicyVersion.snapshot` is redacted and content hash uses redacted content) + +- [X] T012 Add migration for `baseline_profiles.capture_mode` in `database/migrations/2026_03_03_100001_add_capture_mode_to_baseline_profiles_table.php` +- [X] T013 [P] Add migration for `baseline_snapshot_items.subject_key` + index in `database/migrations/2026_03_03_100002_add_subject_key_to_baseline_snapshot_items_table.php` +- [X] T014 [P] Add migration for `policy_versions.capture_purpose`, `policy_versions.operation_run_id`, `policy_versions.baseline_profile_id` + indexes in `database/migrations/2026_03_03_100003_add_baseline_purpose_to_policy_versions_table.php` + +- [X] T015 Update `app/Models/BaselineProfile.php` to store/cast `capture_mode` via `BaselineCaptureMode` and include it in `$fillable` (default: `opportunistic`) +- [X] T016 [P] Update factory defaults/states for capture mode in `database/factories/BaselineProfileFactory.php` +- [X] T017 [P] Update `database/factories/BaselineSnapshotItemFactory.php` to set `subject_key` derived from `meta_jsonb.display_name` via `BaselineSubjectKey` and set `subject_external_id` using the workspace-safe subject id (no tenant external IDs) +- [X] T018 Update `app/Models/PolicyVersion.php` to cast `capture_purpose` and define relationships to `OperationRun` + `BaselineProfile` (new nullable FKs) +- [X] T019 [P] Update `database/factories/PolicyVersionFactory.php` to default `capture_purpose` to `backup` + +- [X] T020 Update `app/Services/Intune/VersionService.php` to apply `PolicySnapshotRedactor` before persistence/hashing and persist `capture_purpose`, `operation_run_id`, and `baseline_profile_id` when capturing versions (including via `captureFromGraph()`) +- [X] T021 Update `app/Services/Intune/PolicyCaptureOrchestrator.php` to pass baseline-purpose attribution into `PolicyVersion` creation/reuse/backfill and ensure snapshot dedupe uses redacted payloads (no secrets/PII in stored snapshots) + +- [X] T022 Update content hashing to include settings + assignments + scope tags in `app/Services/Baselines/Evidence/ContentEvidenceProvider.php` (use `SettingsNormalizer`, hash normalized `assignments`, and hash normalized scope-tag IDs via `ScopeTagsNormalizer`) +- [X] T023 Ensure content evidence provenance includes `policy_version_id`, `operation_run_id`, and `capture_purpose` in `app/Services/Baselines/Evidence/ContentEvidenceProvider.php` (tenant-scoped only; snapshot items must strip tenant identifiers) + +- [X] T024 Implement quota-aware baseline evidence capture phase scaffold in `app/Services/Baselines/BaselineContentCapturePhase.php` (inputs: tenant + subjects + purpose + budgets incl. concurrency + optional resume token; outputs: stats + gaps + optional resume token) + +- [X] T025 Update run start context to include `target_scope` + `capture_mode` and enforce rollout gate for `full_content` in `app/Services/Baselines/BaselineCaptureService.php` (reject start if disabled) +- [X] T026 [P] Update run start context to include `target_scope` + `capture_mode` and enforce rollout gate for `full_content` in `app/Services/Baselines/BaselineCompareService.php` (reject start if disabled) + +- [X] T027 Add capture mode field + badge to Filament baseline profile CRUD in `app/Filament/Resources/BaselineProfileResource.php` (hide/disable `full_content` option when rollout flag is disabled) + +**Checkpoint**: DB + enums + capture phase scaffolding are in place; user stories can be implemented and tested independently. + +--- + +## Phase 3: User Story 1 — Capture a full-content baseline without per-policy steps (Priority: P1) 🎯 MVP + +**Goal**: Capture a baseline snapshot that uses full-content evidence by default (with explicit gaps + warnings if capture is incomplete). + +**Independent Test**: Create a baseline profile configured for full-content capture, run “Capture baseline (full content)”, and validate the snapshot items have content-fidelity evidence (or explicit gaps) and the run context records capture stats. + +### Tests (write first) + +- [X] T028 [P] [US1] Add baseline full-content on-demand evidence test in `tests/Feature/BaselineDriftEngine/CaptureBaselineFullContentOnDemandTest.php` (no PolicyVersion exists → capture creates one with `capture_purpose=baseline_capture` and snapshot item fidelity is `content`) +- [X] T029 [P] [US1] Update meta-fallback test to assert opportunistic mode degrades to meta when evidence is missing in `tests/Feature/BaselineDriftEngine/CaptureBaselineMetaFallbackTest.php` +- [X] T030 [P] [US1] Update capture start surface expectations for full-content labeling + rollout gating in `tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php` +- [X] T031 [P] [US1] Add snapshot item isolation test in `tests/Feature/BaselineDriftEngine/BaselineSnapshotNoTenantIdentifiersTest.php` (assert `baseline_snapshot_items` do not store tenant external IDs and `meta_jsonb` omits tenant identifiers like `meta_contract.subject_external_id` and `evidence.observed_operation_run_id`) +- [X] T032 [P] [US1] Add audit event coverage for baseline capture start/completion in `tests/Feature/BaselineDriftEngine/BaselineCaptureAuditEventsTest.php` (assert action metadata includes purpose, scope counts, and gap/warning summary) + +### Implementation + +- [X] T033 [US1] Update baseline capture action labeling + modal copy + rollout gate messaging in `app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` (show “Capture baseline (full content)” when `capture_mode=full_content`) +- [X] T034 [US1] Integrate `BaselineContentCapturePhase` into baseline capture in `app/Jobs/CaptureBaselineSnapshotJob.php` (purpose `baseline_capture`, budgeted, record `context.baseline_capture.evidence_capture`, `context.baseline_capture.gaps`, `context.baseline_capture.resume_token`, and add job-level rollout gate guard) +- [X] T035 [US1] Persist `subject_key` and workspace-safe `subject_external_id` (derived via `BaselineSubjectKey`) when building snapshot items, and sanitize `meta_jsonb` to exclude tenant identifiers in `app/Jobs/CaptureBaselineSnapshotJob.php` +- [X] T036 [US1] Update baseline snapshot identity hashing to use `policy_type + subject_key + baseline_hash` in `app/Services/Baselines/BaselineSnapshotIdentity.php` (dedupe must not depend on tenant-specific external IDs) +- [X] T037 [US1] Ensure capture run `status`/`outcome` transitions go through `OperationRunService` and mark warnings (`OperationRunOutcome::PartiallySucceeded`) when any subject falls back to meta or is skipped in `app/Jobs/CaptureBaselineSnapshotJob.php` +- [X] T038 [US1] Expand capture audit events to include purpose, scope counts, evidence capture stats, and gap/warning summary in `app/Jobs/CaptureBaselineSnapshotJob.php` +- [X] T039 [US1] Add snapshot fidelity + gaps counts into `baseline_snapshots.summary_jsonb` for snapshot list/detail UX in `app/Jobs/CaptureBaselineSnapshotJob.php` + +**Parallel execution example (US1)**: + +- Developer A: T028, T034, T036 +- Developer B: T030, T033, T035, T038 + +**Checkpoint**: A baseline snapshot can be captured in full-content mode without per-policy steps, and runs are explainable when gaps exist. + +--- + +## Phase 4: User Story 2 — Compare now with full content and get explainable drift (Priority: P1) + +**Goal**: Compare baseline vs current using content-first evidence refresh, cross-tenant subject matching, and explainable run context. + +**Independent Test**: Capture a full-content baseline, simulate a settings-only change for a subject, run “Compare now (full content)”, and assert a “different version” finding exists with content provenance. + +### Tests (write first) + +- [ ] T040 [P] [US2] Add cross-tenant match test (policy_type + `subject_key`) in `tests/Feature/Baselines/BaselineCompareCrossTenantMatchTest.php` +- [ ] T041 [P] [US2] Add ambiguous match suppression test in `tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php` (duplicate `subject_key` values → evidence gap; no finding) +- [ ] T042 [P] [US2] Add coverage proof guard test in `tests/Feature/Baselines/BaselineCompareCoverageProofGuardTest.php` (uncovered types suppress `missing_policy` outcomes; run completes with warnings + records context) +- [ ] T043 [P] [US2] Add stable recurrence identity test in `tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php` (recurrence key independent of hashes; retries don’t duplicate; lifecycle fields update) +- [ ] T044 [P] [US2] Update compare start surface expectations for full-content labeling + rollout gating in `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php` +- [ ] T045 [P] [US2] Add baseline profile “Compare now (full content)” start-surface test in `tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php` +- [ ] T046 [P] [US2] Add audit event coverage for baseline compare start/completion in `tests/Feature/Baselines/BaselineCompareAuditEventsTest.php` (purpose, scope counts, gaps/warnings summary) + +### Implementation + +- [ ] T047 [US2] Add “Compare now (full content)” header action to baseline profile view in `app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` (select target tenant; require `tenant.sync`; enforce rollout gate server-side) +- [ ] T048 [US2] Integrate `BaselineContentCapturePhase` refresh into compare in `app/Jobs/CompareBaselineToTenantJob.php` (purpose `baseline_compare`, budgeted, record `context.baseline_compare.evidence_capture`, `context.baseline_compare.evidence_gaps`, `context.baseline_compare.resume_token`, and add job-level rollout gate guard) +- [ ] T049 [US2] Switch compare matching to `policy_type + subject_key` in `app/Jobs/CompareBaselineToTenantJob.php` (load baseline items by `subject_key`; compute current `subject_key` from inventory display name; detect missing/empty/duplicate keys on either side; record gap reasons; suppress drift evaluation for those keys) +- [ ] T050 [US2] Enforce coverage proof guard behavior in `app/Jobs/CompareBaselineToTenantJob.php` (suppress `missing_policy` for uncovered/unproven types; record warning + `BaselineCompareReasonCode` when suppression affects outcomes) +- [ ] T051 [US2] Update finding recurrence identity to be stable and independent of hashes in `app/Jobs/CompareBaselineToTenantJob.php` (recurrence key uses tenant_id + baseline_profile_id + policy_type + subject_key + change_type; retries must not duplicate findings) +- [ ] T052 [US2] Ensure findings carry `subject_key` + `display_name` fallbacks in `evidence_jsonb` and update subject display name fallback logic in `app/Filament/Resources/FindingResource.php` (COALESCE inventory display name with evidence display name) +- [ ] T053 [US2] Ensure compare run context contains scope totals, processed counts, coverage proof status, fidelity breakdown, evidence capture stats, and top gap reasons in `app/Jobs/CompareBaselineToTenantJob.php` +- [ ] T054 [US2] Update baseline compare landing to label “Compare now (full content)” when applicable in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php` +- [ ] T055 [US2] Extend stats DTO to surface fidelity + evidence gap summary from run context in `app/Support/Baselines/BaselineCompareStats.php` +- [ ] T056 [US2] Add evidence capture + gaps panels for baseline capture/compare runs in Monitoring detail in `app/Filament/Resources/OperationRunResource.php` +- [ ] T057 [US2] Expand compare audit events to include purpose, scope counts, evidence capture stats, and gaps/warnings summary in `app/Jobs/CompareBaselineToTenantJob.php` + +**Parallel execution example (US2)**: + +- Developer A: T040, T048, T050, T056 +- Developer B: T045, T047, T054, T055, T052 + +**Checkpoint**: Compare runs refresh evidence when needed, generate findings reliably, and provide explainable context even with coverage warnings or gaps. + +--- + +## Phase 5: User Story 3 — Throttling-safe, resumable evidence capture (Priority: P1) + +**Goal**: Evidence capture respects quotas, records a resume token, and resumes deterministically without duplicating work. + +**Independent Test**: Simulate throttling/budget exhaustion, verify run records a resume token, then resume and complete without re-capturing already-captured subjects. + +### Tests (write first) + +- [ ] T058 [P] [US3] Add “budget exhaustion produces resume token” test in `tests/Feature/Baselines/BaselineCompareResumeTokenTest.php` +- [ ] T059 [P] [US3] Add “resume is idempotent” test in `tests/Feature/Baselines/BaselineCompareResumeIdempotencyTest.php` +- [ ] T060 [P] [US3] Add resume token contract test in `tests/Feature/Baselines/BaselineEvidenceResumeTokenContractTest.php` (token is opaque; decode yields deterministic resume state) +- [ ] T061 [P] [US3] Add run-detail resume action test in `tests/Feature/Filament/OperationRunResumeCaptureActionTest.php` +- [ ] T062 [P] [US3] Add audit event coverage for resume capture in `tests/Feature/Baselines/BaselineResumeCaptureAuditEventsTest.php` + +### Implementation + +- [ ] T063 [US3] Implement budgets (items-per-run + concurrency + retries) + retry/backoff/jitter + throttling gap reasons + resume cursor handling in `app/Services/Baselines/BaselineContentCapturePhase.php` (use `BaselineEvidenceResumeToken` encode/decode) +- [ ] T064 [US3] Add resume starter service in `app/Services/Baselines/BaselineEvidenceCaptureResumeService.php` (start follow-up `baseline_capture`/`baseline_compare` runs from a prior run + resume token; enforce RBAC; write audit events) +- [ ] T065 [US3] Add “Resume capture” header action for eligible runs in `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` (requires confirmation; uses Ops-UX queued toast + canonical view-run link) +- [ ] T066 [US3] Wire resume token consumption + re-emission into `app/Jobs/CaptureBaselineSnapshotJob.php` (baseline capture) and `app/Jobs/CompareBaselineToTenantJob.php` (baseline compare) + +**Parallel execution example (US3)**: + +- Developer A: T058, T063, T066 +- Developer B: T061, T064, T065 + +**Checkpoint**: Operators can safely complete large scopes via resumable capture without manual per-policy capture. + +--- + +## Phase 6: User Story 4 — “Why no findings?” is always clear (Priority: P2) + +**Goal**: Zero findings never looks like a silent failure; compare run detail clearly explains the outcome. + +**Independent Test**: Run compare with zero subjects (or with suppressed findings due to coverage/gaps) and verify a clear explanation sourced from run context is displayed. + +### Tests (write first) + +- [ ] T067 [P] [US4] Add reason-code coverage test for zero-subject / zero-findings / suppressed-by-coverage outcomes in `tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php` +- [ ] T068 [P] [US4] Add UI assertion test for “why no findings” messaging in `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php` + +### Implementation + +- [ ] T069 [US4] Populate `context.baseline_compare.reason_code` for all 0-subject / 0-findings outcomes in `app/Jobs/CompareBaselineToTenantJob.php` (use `BaselineCompareReasonCode`, including `coverage_unproven`/`rollout_disabled` where applicable) +- [ ] T070 [US4] Render reason-code explanation + evidence context in Monitoring run detail in `app/Filament/Resources/OperationRunResource.php` +- [ ] T071 [US4] Replace “All clear” copy with reason-aware messaging on baseline compare landing in `resources/views/filament/pages/baseline-compare-landing.blade.php` (source reason code from `BaselineCompareStats`) +- [ ] T072 [US4] Propagate reason code + human message from run context in `app/Support/Baselines/BaselineCompareStats.php` + +**Parallel execution example (US4)**: + +- Developer A: T067, T069 +- Developer B: T068, T071, T072 + +**Checkpoint**: Every compare run with “0 findings” has a clear, user-visible explanation and supporting evidence context. + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: Guardrails, visibility, and validation across all stories. + +- [ ] T073 [P] Add Spec 118 no-legacy regression guard(s) in `tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php` (assert capture/compare do not implement hashing outside the provider/hasher pipeline and do not reference deprecated helpers) +- [ ] T074 Update PolicyVersion listing to hide baseline-purpose evidence by default (unless the actor has `tenant.sync` or `tenant_findings.view`) in `app/Filament/Resources/PolicyVersionResource.php` +- [ ] T075 [P] Add visibility/authorization coverage for baseline-purpose PolicyVersions in `tests/Feature/Filament/PolicyVersionBaselineEvidenceVisibilityTest.php` (assert baseline-purpose rows are hidden for `tenant.view`-only actors) +- [ ] T076 Implement baseline-purpose PolicyVersion retention enforcement in `app/Console/Commands/PruneBaselineEvidencePolicyVersionsCommand.php` and schedule it in `routes/console.php` (prune `baseline_capture`/`baseline_compare` older than configured retention; do not prune `backup`) + tests in `tests/Feature/Retention/PruneBaselineEvidencePolicyVersionsTest.php` and `tests/Feature/Scheduling/PruneBaselineEvidencePolicyVersionsScheduleTest.php` +- [ ] T077 Add Baseline Snapshot list/detail surfaces with fidelity visibility in `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/BaselineSnapshotResource/Pages/ListBaselineSnapshots.php`, and `app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php` (badge + counts by fidelity; “captured with gaps” state) + tests in `tests/Feature/Filament/BaselineSnapshotFidelityVisibilityTest.php` +- [ ] T078 Run formatting on changed files using `vendor/bin/sail bin pint --dirty --format agent` (touchpoints include `app/Jobs/CaptureBaselineSnapshotJob.php`, `app/Jobs/CompareBaselineToTenantJob.php`, `app/Services/Baselines/BaselineContentCapturePhase.php`) +- [ ] T079 Run targeted test suite from `specs/118-baseline-drift-engine/quickstart.md` and update it if any step is inaccurate in `specs/118-baseline-drift-engine/quickstart.md` + +--- + +## Dependencies & Execution Order + +### Story completion order + +- Phase 1 (Setup) → Phase 2 (Foundational) → user stories. +- User stories after Phase 2: + - **US1 (P1)** is the MVP capture capability and should be implemented first end-to-end. + - **US2 (P1)** depends on US1 for end-to-end validation (a baseline snapshot must exist), but implementation can proceed in parallel after Phase 2. + - **US3 (P1)** depends on the capture phase being integrated in US1/US2. + - **US4 (P2)** depends on US2’s run-context fields. + +### Dependency graph + +```mermaid +graph TD + P1["Phase 1: Setup"] --> P2["Phase 2: Foundational"] + P2 --> US1["US1: Capture baseline (full content)"] + P2 --> US2["US2: Compare now (full content)"] + US1 --> US2 + US2 --> US3["US3: Resumable capture"] + US2 --> US4["US4: Why no findings"] + US3 --> POLISH["Phase 7: Polish"] + US4 --> POLISH +``` + +## Implementation Strategy (MVP-first) + +1) Ship **US1** with a strict run-context contract and explicit gap reporting (no silent success). +2) Add **US2** compare refresh + cross-tenant matching with explainability. +3) Harden with **US3** resumability and throttle-safe behavior. +4) Complete operator trust with **US4** reason-code UX. +5) Enforce “no legacy” and visibility constraints in **Polish**. diff --git a/tests/Feature/BaselineDriftEngine/BaselineCaptureAuditEventsTest.php b/tests/Feature/BaselineDriftEngine/BaselineCaptureAuditEventsTest.php new file mode 100644 index 0000000..e4f415a --- /dev/null +++ b/tests/Feature/BaselineDriftEngine/BaselineCaptureAuditEventsTest.php @@ -0,0 +1,75 @@ +active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'capture_mode' => BaselineCaptureMode::Opportunistic->value, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'external_id' => 'audit-policy-a', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Audit Policy A', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_AUDIT'], + ]); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_capture', + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'source_tenant_id' => (int) $tenant->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ], + initiator: $user, + ); + + (new CaptureBaselineSnapshotJob($run))->handle( + app(BaselineSnapshotIdentity::class), + app(InventoryMetaContract::class), + app(AuditLogger::class), + $opService, + ); + + $started = AuditLog::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('action', 'baseline.capture.started') + ->latest('id') + ->first(); + + $completed = AuditLog::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('action', 'baseline.capture.completed') + ->latest('id') + ->first(); + + expect($started)->not->toBeNull(); + expect($completed)->not->toBeNull(); + + $startedMeta = is_array($started?->metadata) ? $started->metadata : []; + expect($startedMeta)->toHaveKey('purpose'); + expect($startedMeta)->toHaveKey('subjects_total'); + + $completedMeta = is_array($completed?->metadata) ? $completed->metadata : []; + expect($completedMeta)->toHaveKey('purpose'); + expect($completedMeta)->toHaveKey('subjects_total'); + expect($completedMeta)->toHaveKey('gaps'); +}); + diff --git a/tests/Feature/BaselineDriftEngine/BaselineSnapshotNoTenantIdentifiersTest.php b/tests/Feature/BaselineDriftEngine/BaselineSnapshotNoTenantIdentifiersTest.php new file mode 100644 index 0000000..6154b5f --- /dev/null +++ b/tests/Feature/BaselineDriftEngine/BaselineSnapshotNoTenantIdentifiersTest.php @@ -0,0 +1,107 @@ +active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'capture_mode' => BaselineCaptureMode::Opportunistic->value, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'tenant-policy-external-id', + 'platform' => 'windows', + 'display_name' => 'Isolated Policy', + ]); + + $lastSeenRun = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::InventorySync->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => (string) $policy->policy_type, + 'external_id' => (string) $policy->external_id, + 'display_name' => (string) $policy->display_name, + 'meta_jsonb' => [ + 'odata_type' => '#microsoft.graph.deviceConfiguration', + 'etag' => 'E_ISOLATION', + 'scope_tag_ids' => [], + 'assignment_target_count' => 1, + ], + 'last_seen_operation_run_id' => (int) $lastSeenRun->getKey(), + 'last_seen_at' => now()->subHour(), + ]); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_capture', + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'source_tenant_id' => (int) $tenant->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ], + initiator: $user, + ); + + (new CaptureBaselineSnapshotJob($run))->handle( + app(BaselineSnapshotIdentity::class), + app(InventoryMetaContract::class), + app(AuditLogger::class), + $opService, + ); + + $snapshot = BaselineSnapshot::query() + ->where('baseline_profile_id', (int) $profile->getKey()) + ->sole(); + + $subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name); + expect($subjectKey)->not->toBeNull(); + + $workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: (string) $policy->policy_type, + subjectKey: (string) $subjectKey, + ); + + $item = BaselineSnapshotItem::query() + ->where('baseline_snapshot_id', (int) $snapshot->getKey()) + ->where('subject_key', (string) $subjectKey) + ->sole(); + + expect($item->subject_external_id)->toBe($workspaceSafeExternalId); + expect($item->subject_external_id)->not->toBe((string) $policy->external_id); + + $meta = is_array($item->meta_jsonb) ? $item->meta_jsonb : []; + expect(data_get($meta, 'meta_contract.subject_external_id'))->toBeNull(); + expect(data_get($meta, 'evidence.observed_operation_run_id'))->toBeNull(); + expect(data_get($meta, 'evidence.policy_version_id'))->toBeNull(); +}); + diff --git a/tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php b/tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php index 8cd46d2..dd55c8f 100644 --- a/tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php +++ b/tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php @@ -10,9 +10,12 @@ use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Baselines\InventoryMetaContract; use App\Services\Drift\DriftHasher; +use App\Services\Drift\Normalizers\AssignmentsNormalizer; use App\Services\Drift\Normalizers\SettingsNormalizer; +use App\Services\Drift\Normalizers\ScopeTagsNormalizer; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Support\Baselines\BaselineSubjectKey; it('Baseline capture stores content fidelity hash when PolicyVersion evidence exists', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -62,7 +65,11 @@ ]); $expectedContentHash = app(DriftHasher::class)->hashNormalized( - app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'), + [ + 'settings' => app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'), + 'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]), + 'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]), + ], ); $opService = app(OperationRunService::class); @@ -89,24 +96,24 @@ ->where('baseline_profile_id', (int) $profile->getKey()) ->sole(); + $subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name); + expect($subjectKey)->not->toBeNull(); + + $workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: (string) $policy->policy_type, + subjectKey: (string) $subjectKey, + ); + $item = BaselineSnapshotItem::query() ->where('baseline_snapshot_id', (int) $snapshot->getKey()) - ->where('subject_external_id', (string) $policy->external_id) + ->where('subject_external_id', $workspaceSafeExternalId) ->sole(); expect($item->baseline_hash)->toBe($expectedContentHash); $meta = is_array($item->meta_jsonb) ? $item->meta_jsonb : []; - expect($meta)->toHaveKey('meta_contract'); expect($meta)->toHaveKey('evidence'); expect(data_get($meta, 'evidence.fidelity'))->toBe('content'); expect(data_get($meta, 'evidence.source'))->toBe('policy_version'); expect(data_get($meta, 'evidence.observed_at'))->not->toBeNull(); - - $contract = app(InventoryMetaContract::class)->build( - policyType: (string) $inventory->policy_type, - subjectExternalId: (string) $inventory->external_id, - metaJsonb: is_array($inventory->meta_jsonb) ? $inventory->meta_jsonb : [], - ); - expect($meta['meta_contract'])->toBe($contract); }); diff --git a/tests/Feature/BaselineDriftEngine/CaptureBaselineFullContentOnDemandTest.php b/tests/Feature/BaselineDriftEngine/CaptureBaselineFullContentOnDemandTest.php new file mode 100644 index 0000000..2a34f5d --- /dev/null +++ b/tests/Feature/BaselineDriftEngine/CaptureBaselineFullContentOnDemandTest.php @@ -0,0 +1,170 @@ +set('tenantpilot.baselines.full_content_capture.enabled', true); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'capture_mode' => BaselineCaptureMode::FullContent->value, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'policy-on-demand', + 'platform' => 'windows', + 'display_name' => 'Policy On Demand', + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => (string) $policy->policy_type, + 'external_id' => (string) $policy->external_id, + 'display_name' => (string) $policy->display_name, + 'meta_jsonb' => [ + 'odata_type' => '#microsoft.graph.deviceConfiguration', + 'etag' => 'E_ON_DEMAND', + 'scope_tag_ids' => [], + 'assignment_target_count' => 1, + ], + 'last_seen_at' => now()->subHour(), + ]); + + expect(PolicyVersion::query()->where('policy_id', (int) $policy->getKey())->count())->toBe(0); + + $fakeOrchestrator = new class extends PolicyCaptureOrchestrator + { + /** + * @var list> + */ + public array $calls = []; + + public function __construct() {} + + public function capture( + Policy $policy, + Tenant $tenant, + bool $includeAssignments = false, + bool $includeScopeTags = false, + ?string $createdBy = null, + array $metadata = [], + PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup, + ?int $operationRunId = null, + ?int $baselineProfileId = null, + ): array { + $this->calls[] = [ + 'policy_id' => (int) $policy->getKey(), + 'capture_purpose' => $capturePurpose->value, + 'operation_run_id' => $operationRunId, + 'baseline_profile_id' => $baselineProfileId, + ]; + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_type' => (string) $policy->policy_type, + 'platform' => (string) $policy->platform, + 'captured_at' => now(), + 'snapshot' => [ + 'settings' => [ + ['displayName' => 'SettingX', 'value' => 1], + ], + ], + 'assignments' => [], + 'scope_tags' => [], + 'capture_purpose' => $capturePurpose, + 'operation_run_id' => $operationRunId, + 'baseline_profile_id' => $baselineProfileId, + ]); + + return [ + 'version' => $version, + 'captured' => [ + 'payload' => $version->snapshot, + 'assignments' => [], + 'scope_tags' => [], + ], + ]; + } + }; + + $contentCapturePhase = new BaselineContentCapturePhase($fakeOrchestrator); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_capture', + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'source_tenant_id' => (int) $tenant->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ], + initiator: $user, + ); + + (new CaptureBaselineSnapshotJob($run))->handle( + app(BaselineSnapshotIdentity::class), + app(InventoryMetaContract::class), + app(AuditLogger::class), + $opService, + app(CurrentStateHashResolver::class), + $contentCapturePhase, + ); + + expect($fakeOrchestrator->calls)->toHaveCount(1); + + $version = PolicyVersion::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('policy_id', (int) $policy->getKey()) + ->latest('id') + ->first(); + + expect($version)->not->toBeNull(); + expect($version?->capture_purpose)->toBe(PolicyVersionCapturePurpose::BaselineCapture); + expect($version?->operation_run_id)->toBe((int) $run->getKey()); + expect($version?->baseline_profile_id)->toBe((int) $profile->getKey()); + + $snapshot = BaselineSnapshot::query() + ->where('baseline_profile_id', (int) $profile->getKey()) + ->sole(); + + $subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name); + expect($subjectKey)->not->toBeNull(); + + $workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: (string) $policy->policy_type, + subjectKey: (string) $subjectKey, + ); + + $item = BaselineSnapshotItem::query() + ->where('baseline_snapshot_id', (int) $snapshot->getKey()) + ->where('subject_external_id', $workspaceSafeExternalId) + ->sole(); + + $meta = is_array($item->meta_jsonb) ? $item->meta_jsonb : []; + expect(data_get($meta, 'evidence.fidelity'))->toBe('content'); +}); + diff --git a/tests/Feature/BaselineDriftEngine/CaptureBaselineMetaFallbackTest.php b/tests/Feature/BaselineDriftEngine/CaptureBaselineMetaFallbackTest.php index 2e5895d..d9ad109 100644 --- a/tests/Feature/BaselineDriftEngine/CaptureBaselineMetaFallbackTest.php +++ b/tests/Feature/BaselineDriftEngine/CaptureBaselineMetaFallbackTest.php @@ -11,15 +11,18 @@ use App\Services\Baselines\InventoryMetaContract; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Support\Baselines\BaselineCaptureMode; +use App\Support\Baselines\BaselineSubjectKey; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\OperationRunType; -it('Baseline capture falls back to meta fidelity when PolicyVersion evidence is missing', function () { +it('Baseline capture degrades to meta fidelity in opportunistic mode when PolicyVersion evidence is missing', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => $tenant->workspace_id, + 'capture_mode' => BaselineCaptureMode::Opportunistic->value, 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], ]); @@ -81,13 +84,25 @@ $opService, ); + $run->refresh(); + expect($run->status)->toBe(OperationRunStatus::Completed->value); + expect($run->outcome)->toBe(OperationRunOutcome::Succeeded->value); + $snapshot = BaselineSnapshot::query() ->where('baseline_profile_id', (int) $profile->getKey()) ->sole(); + $subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name); + expect($subjectKey)->not->toBeNull(); + + $workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: (string) $policy->policy_type, + subjectKey: (string) $subjectKey, + ); + $item = BaselineSnapshotItem::query() ->where('baseline_snapshot_id', (int) $snapshot->getKey()) - ->where('subject_external_id', (string) $policy->external_id) + ->where('subject_external_id', $workspaceSafeExternalId) ->sole(); expect($item->baseline_hash)->toBe($expectedMetaHash); @@ -95,5 +110,5 @@ $meta = is_array($item->meta_jsonb) ? $item->meta_jsonb : []; expect(data_get($meta, 'evidence.fidelity'))->toBe('meta'); expect(data_get($meta, 'evidence.source'))->toBe('inventory'); - expect(data_get($meta, 'evidence.observed_operation_run_id'))->toBe((int) $lastSeenRun->getKey()); + expect(data_get($meta, 'evidence.observed_operation_run_id'))->toBeNull(); }); diff --git a/tests/Feature/Baselines/BaselineCaptureTest.php b/tests/Feature/Baselines/BaselineCaptureTest.php index 05bf90b..554d47e 100644 --- a/tests/Feature/Baselines/BaselineCaptureTest.php +++ b/tests/Feature/Baselines/BaselineCaptureTest.php @@ -13,6 +13,7 @@ use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; use App\Support\Baselines\BaselineProfileStatus; +use App\Support\Baselines\BaselineSubjectKey; use Illuminate\Support\Facades\Queue; // --- T031: Capture enqueue + precondition tests --- @@ -146,25 +147,28 @@ 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], ]); - InventoryItem::factory()->create([ + $inventoryA = InventoryItem::factory()->create([ 'tenant_id' => $tenant->getKey(), 'workspace_id' => $tenant->workspace_id, 'external_id' => 'policy-a', 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Policy A', 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E1'], ]); - InventoryItem::factory()->create([ + $inventoryB = InventoryItem::factory()->create([ 'tenant_id' => $tenant->getKey(), 'workspace_id' => $tenant->workspace_id, 'external_id' => 'policy-b', 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Policy B', 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E2'], ]); - InventoryItem::factory()->create([ + $inventoryC = InventoryItem::factory()->create([ 'tenant_id' => $tenant->getKey(), 'workspace_id' => $tenant->workspace_id, 'external_id' => 'policy-c', 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Policy C', 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E3'], ]); @@ -212,29 +216,50 @@ ->orderBy('subject_external_id') ->get(); - expect($items->pluck('subject_external_id')->all())->toBe(['policy-a', 'policy-b', 'policy-c']); + $subjectKeyA = BaselineSubjectKey::fromDisplayName((string) $inventoryA->display_name); + $subjectKeyB = BaselineSubjectKey::fromDisplayName((string) $inventoryB->display_name); + $subjectKeyC = BaselineSubjectKey::fromDisplayName((string) $inventoryC->display_name); + + expect($subjectKeyA)->not->toBeNull(); + expect($subjectKeyB)->not->toBeNull(); + expect($subjectKeyC)->not->toBeNull(); + + $expectedSubjectExternalIds = [ + BaselineSubjectKey::workspaceSafeSubjectExternalId((string) $inventoryA->policy_type, (string) $subjectKeyA), + BaselineSubjectKey::workspaceSafeSubjectExternalId((string) $inventoryB->policy_type, (string) $subjectKeyB), + BaselineSubjectKey::workspaceSafeSubjectExternalId((string) $inventoryC->policy_type, (string) $subjectKeyC), + ]; + sort($expectedSubjectExternalIds, SORT_STRING); + + expect($items->pluck('subject_external_id')->all())->toBe($expectedSubjectExternalIds); + + $inventoryBySubjectKey = [ + (string) $subjectKeyA => $inventoryA, + (string) $subjectKeyB => $inventoryB, + (string) $subjectKeyC => $inventoryC, + ]; foreach ($items as $item) { /** @var BaselineSnapshotItem $item */ - $inventory = InventoryItem::query() - ->where('tenant_id', $tenant->getKey()) - ->where('policy_type', $item->policy_type) - ->where('external_id', $item->subject_external_id) - ->first(); + $inventory = $inventoryBySubjectKey[(string) $item->subject_key] ?? null; expect($inventory)->not->toBeNull(); + $contractSubjectExternalId = (string) ($inventory->external_id ?? ''); + $contract = $builder->build( policyType: (string) $inventory->policy_type, - subjectExternalId: (string) $inventory->external_id, + subjectExternalId: $contractSubjectExternalId, metaJsonb: is_array($inventory->meta_jsonb) ? $inventory->meta_jsonb : [], ); expect($item->baseline_hash)->toBe($hasher->hashNormalized($contract)); $meta = is_array($item->meta_jsonb) ? $item->meta_jsonb : []; - expect($meta)->toHaveKey('meta_contract'); - expect($meta['meta_contract'])->toBe($contract); + expect(data_get($meta, 'display_name'))->toBe((string) ($inventory->display_name ?? '')); + expect(data_get($meta, 'evidence.fidelity'))->toBe('meta'); + expect(data_get($meta, 'evidence.source'))->toBe('inventory'); + expect(data_get($meta, 'meta_contract'))->toBeNull(); } $profile->refresh(); diff --git a/tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php b/tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php new file mode 100644 index 0000000..ff726a5 --- /dev/null +++ b/tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php @@ -0,0 +1,118 @@ +active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + 'captured_at' => now()->subMinute(), + ]); + + $profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]); + + $displayName = 'Duplicate Policy'; + $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); + expect($subjectKey)->not->toBeNull(); + + $baselineSubjectExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: 'deviceConfiguration', + subjectKey: (string) $subjectKey, + ); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => $baselineSubjectExternalId, + 'subject_key' => (string) $subjectKey, + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'baseline'), + 'meta_jsonb' => [ + 'display_name' => $displayName, + 'evidence' => [ + 'fidelity' => 'content', + 'source' => 'policy_version', + 'observed_at' => now()->toIso8601String(), + ], + ], + ]); + + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + + // Two current policies with the same display name (→ same subject_key). + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'external_id' => 'dup-1', + 'policy_type' => 'deviceConfiguration', + 'display_name' => $displayName, + 'meta_jsonb' => ['etag' => 'E1'], + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'external_id' => 'dup-2', + 'policy_type' => 'deviceConfiguration', + 'display_name' => $displayName, + 'meta_jsonb' => ['etag' => 'E2'], + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: OperationRunType::BaselineCompare->value, + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($run))->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $run->refresh(); + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value); + + expect( + Finding::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('source', 'baseline.compare') + ->count(), + )->toBe(0); + + $context = is_array($run->context) ? $run->context : []; + expect(data_get($context, 'baseline_compare.evidence_gaps.by_reason.ambiguous_match'))->toBe(1); +}); + diff --git a/tests/Feature/Baselines/BaselineCompareAuditEventsTest.php b/tests/Feature/Baselines/BaselineCompareAuditEventsTest.php new file mode 100644 index 0000000..904c225 --- /dev/null +++ b/tests/Feature/Baselines/BaselineCompareAuditEventsTest.php @@ -0,0 +1,176 @@ +set('tenantpilot.baselines.full_content_capture.enabled', true); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'capture_mode' => BaselineCaptureMode::FullContent->value, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + 'captured_at' => now()->subMinute(), + ]); + + $profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]); + + $displayName = 'Audit Compare Policy'; + $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); + expect($subjectKey)->not->toBeNull(); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey), + 'subject_key' => (string) $subjectKey, + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'baseline'), + 'meta_jsonb' => [ + 'display_name' => $displayName, + 'evidence' => [ + 'fidelity' => 'content', + 'source' => 'policy_version', + 'observed_at' => now()->toIso8601String(), + ], + ], + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'audit-compare-policy', + 'platform' => 'windows', + 'display_name' => $displayName, + ]); + + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'external_id' => (string) $policy->external_id, + 'policy_type' => (string) $policy->policy_type, + 'display_name' => (string) $policy->display_name, + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'], + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + $fakeOrchestrator = new class extends PolicyCaptureOrchestrator + { + public function __construct() {} + + public function capture( + Policy $policy, + \App\Models\Tenant $tenant, + bool $includeAssignments = false, + bool $includeScopeTags = false, + ?string $createdBy = null, + array $metadata = [], + PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup, + ?int $operationRunId = null, + ?int $baselineProfileId = null, + ): array { + $version = \App\Models\PolicyVersion::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_type' => (string) $policy->policy_type, + 'platform' => (string) $policy->platform, + 'captured_at' => now(), + 'snapshot' => [ + 'settings' => [ + ['displayName' => 'SettingX', 'value' => 2], + ], + ], + 'assignments' => [], + 'scope_tags' => [], + 'capture_purpose' => $capturePurpose, + 'operation_run_id' => $operationRunId, + 'baseline_profile_id' => $baselineProfileId, + ]); + + return [ + 'version' => $version, + 'captured' => [ + 'payload' => $version->snapshot, + 'assignments' => [], + 'scope_tags' => [], + ], + ]; + } + }; + + $contentCapturePhase = new BaselineContentCapturePhase($fakeOrchestrator); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: OperationRunType::BaselineCompare->value, + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($run))->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + contentCapturePhase: $contentCapturePhase, + ); + + $started = AuditLog::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('action', 'baseline.compare.started') + ->latest('id') + ->first(); + + $completed = AuditLog::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('action', 'baseline.compare.completed') + ->latest('id') + ->first(); + + expect($started)->not->toBeNull(); + expect($completed)->not->toBeNull(); + + $startedMeta = is_array($started?->metadata) ? $started->metadata : []; + expect($startedMeta)->toHaveKey('purpose'); + expect($startedMeta)->toHaveKey('subjects_total'); + expect($startedMeta)->toHaveKey('scope_types_total'); + + $completedMeta = is_array($completed?->metadata) ? $completed->metadata : []; + expect($completedMeta)->toHaveKey('purpose'); + expect($completedMeta)->toHaveKey('subjects_total'); + expect($completedMeta)->toHaveKey('evidence_capture'); + expect($completedMeta)->toHaveKey('gaps'); +}); + diff --git a/tests/Feature/Baselines/BaselineCompareCoverageProofGuardTest.php b/tests/Feature/Baselines/BaselineCompareCoverageProofGuardTest.php new file mode 100644 index 0000000..8b2c4fa --- /dev/null +++ b/tests/Feature/Baselines/BaselineCompareCoverageProofGuardTest.php @@ -0,0 +1,159 @@ +active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'scope_jsonb' => [ + 'policy_types' => ['deviceConfiguration', 'deviceCompliancePolicy'], + 'foundation_types' => [], + ], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + 'captured_at' => now()->subMinute(), + ]); + + $profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]); + + $coveredExternalId = 'covered-uuid'; + $coveredDisplayName = 'Covered Policy'; + $coveredKey = BaselineSubjectKey::fromDisplayName($coveredDisplayName); + expect($coveredKey)->not->toBeNull(); + + $coveredWorkspaceId = BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: 'deviceConfiguration', + subjectKey: (string) $coveredKey, + ); + + $baselineHash = app(BaselineSnapshotIdentity::class)->hashItemContent( + policyType: 'deviceConfiguration', + subjectExternalId: $coveredExternalId, + metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'], + ); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => $coveredWorkspaceId, + 'subject_key' => (string) $coveredKey, + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => $baselineHash, + 'meta_jsonb' => [ + 'display_name' => $coveredDisplayName, + 'evidence' => [ + 'fidelity' => 'meta', + 'source' => 'inventory', + 'observed_at' => now()->toIso8601String(), + ], + ], + ]); + + $uncoveredDisplayName = 'Uncovered Policy'; + $uncoveredKey = BaselineSubjectKey::fromDisplayName($uncoveredDisplayName); + expect($uncoveredKey)->not->toBeNull(); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceCompliancePolicy', (string) $uncoveredKey), + 'subject_key' => (string) $uncoveredKey, + 'policy_type' => 'deviceCompliancePolicy', + 'baseline_hash' => hash('sha256', 'uncovered'), + 'meta_jsonb' => [ + 'display_name' => $uncoveredDisplayName, + 'evidence' => [ + 'fidelity' => 'meta', + 'source' => 'inventory', + 'observed_at' => now()->toIso8601String(), + ], + ], + ]); + + $inventorySyncRun = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::InventorySync->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::PartiallySucceeded->value, + 'completed_at' => now(), + 'context' => [ + 'inventory' => [ + 'coverage' => [ + 'policy_types' => [ + 'deviceConfiguration' => ['status' => 'succeeded'], + 'deviceCompliancePolicy' => ['status' => 'failed'], + ], + 'foundation_types' => [], + ], + ], + ], + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'external_id' => $coveredExternalId, + 'policy_type' => 'deviceConfiguration', + 'display_name' => $coveredDisplayName, + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'], + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + $opService = app(OperationRunService::class); + $compareRun = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: OperationRunType::BaselineCompare->value, + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => [ + 'policy_types' => ['deviceConfiguration', 'deviceCompliancePolicy'], + 'foundation_types' => [], + ], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($compareRun))->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $compareRun->refresh(); + expect($compareRun->status)->toBe('completed'); + expect($compareRun->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value); + + $findings = Finding::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('source', 'baseline.compare') + ->get(); + + expect($findings)->toHaveCount(1); + expect((string) data_get($findings->first(), 'evidence_jsonb.change_type'))->toBe('different_version'); + + $context = is_array($compareRun->context) ? $compareRun->context : []; + expect(data_get($context, 'baseline_compare.coverage.uncovered_types'))->toContain('deviceCompliancePolicy'); +}); + diff --git a/tests/Feature/Baselines/BaselineCompareCrossTenantMatchTest.php b/tests/Feature/Baselines/BaselineCompareCrossTenantMatchTest.php new file mode 100644 index 0000000..84d0dca --- /dev/null +++ b/tests/Feature/Baselines/BaselineCompareCrossTenantMatchTest.php @@ -0,0 +1,146 @@ +create([ + 'workspace_id' => (int) $sourceTenant->workspace_id, + ]); + $user->tenants()->syncWithoutDetaching([ + $targetTenant->getKey() => ['role' => 'owner'], + ]); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $sourceTenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $sourceTenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + 'captured_at' => now()->subMinute(), + ]); + + $displayName = 'Shared Policy'; + $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); + expect($subjectKey)->not->toBeNull(); + + $baselineSubjectExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: 'deviceConfiguration', + subjectKey: (string) $subjectKey, + ); + + $snapshotPayload = [ + 'settings' => [ + ['displayName' => 'SettingX', 'value' => 1], + ], + ]; + + $expectedContentHash = app(DriftHasher::class)->hashNormalized([ + 'settings' => app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'), + 'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]), + 'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]), + ]); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => $baselineSubjectExternalId, + 'subject_key' => (string) $subjectKey, + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => $expectedContentHash, + 'meta_jsonb' => [ + 'display_name' => $displayName, + 'evidence' => [ + 'fidelity' => 'content', + 'source' => 'policy_version', + 'observed_at' => now()->toIso8601String(), + ], + ], + ]); + + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $targetTenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $targetTenant->getKey(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'tenant-policy-uuid', + 'platform' => 'windows', + 'display_name' => $displayName, + ]); + + PolicyVersion::factory()->create([ + 'tenant_id' => (int) $targetTenant->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_type' => (string) $policy->policy_type, + 'platform' => (string) $policy->platform, + 'captured_at' => now(), + 'snapshot' => $snapshotPayload, + 'assignments' => [], + 'scope_tags' => [], + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $targetTenant->getKey(), + 'workspace_id' => (int) $targetTenant->workspace_id, + 'external_id' => (string) $policy->external_id, + 'policy_type' => (string) $policy->policy_type, + 'display_name' => $displayName, + 'meta_jsonb' => ['etag' => 'E1'], + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $targetTenant, + type: OperationRunType::BaselineCompare->value, + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($run))->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $run->refresh(); + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('succeeded'); + + expect( + Finding::query() + ->where('tenant_id', (int) $targetTenant->getKey()) + ->where('source', 'baseline.compare') + ->count(), + )->toBe(0); +}); + diff --git a/tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php b/tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php new file mode 100644 index 0000000..f13bf38 --- /dev/null +++ b/tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php @@ -0,0 +1,160 @@ +active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ]); + + $displayName = 'Policy X'; + $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); + expect($subjectKey)->not->toBeNull(); + + $workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: 'deviceConfiguration', + subjectKey: (string) $subjectKey, + ); + + $snapshot1 = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + 'captured_at' => now()->subMinutes(2), + ]); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot1->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => $workspaceSafeExternalId, + 'subject_key' => (string) $subjectKey, + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'baseline-v1'), + 'meta_jsonb' => [ + 'display_name' => $displayName, + 'evidence' => [ + 'fidelity' => 'meta', + 'source' => 'inventory', + 'observed_at' => now()->toIso8601String(), + ], + ], + ]); + + $snapshot2 = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + 'captured_at' => now()->subMinute(), + ]); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot2->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => $workspaceSafeExternalId, + 'subject_key' => (string) $subjectKey, + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'baseline-v2'), + 'meta_jsonb' => [ + 'display_name' => $displayName, + 'evidence' => [ + 'fidelity' => 'meta', + 'source' => 'inventory', + 'observed_at' => now()->toIso8601String(), + ], + ], + ]); + + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'external_id' => 'policy-x-uuid', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_1'], + 'display_name' => $displayName, + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + $opService = app(OperationRunService::class); + + $run1 = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: OperationRunType::BaselineCompare->value, + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot1->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ], + initiator: $user, + ); + + $job = new CompareBaselineToTenantJob($run1); + $job->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $finding = Finding::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('source', 'baseline.compare') + ->sole(); + + expect($finding->recurrence_key)->not->toBeNull(); + expect($finding->fingerprint)->toBe($finding->recurrence_key); + expect($finding->times_seen)->toBe(1); + + $fingerprint = (string) $finding->fingerprint; + + // Retry the same run ID (job retry): times_seen MUST NOT increment twice for the same run. + $job->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $finding->refresh(); + expect($finding->times_seen)->toBe(1); + expect((string) $finding->fingerprint)->toBe($fingerprint); + + // Compare against a different baseline snapshot (hash changes), but recurrence identity stays stable. + $run2 = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: OperationRunType::BaselineCompare->value, + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot2->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($run2))->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $finding->refresh(); + expect((string) $finding->fingerprint)->toBe($fingerprint); + expect($finding->times_seen)->toBe(2); +}); + diff --git a/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php b/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php index 6ab94d1..d401d8f 100644 --- a/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php +++ b/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php @@ -68,6 +68,7 @@ it('dispatches ops-ux run-enqueued after starting baseline compare', function (): void { Queue::fake(); + config()->set('tenantpilot.baselines.full_content_capture.enabled', true); [$user, $tenant] = createUserWithTenant(role: 'owner'); $this->actingAs($user); @@ -77,6 +78,7 @@ $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => (int) $tenant->workspace_id, + 'capture_mode' => \App\Support\Baselines\BaselineCaptureMode::FullContent->value, ]); $snapshot = BaselineSnapshot::factory()->create([ @@ -93,6 +95,7 @@ ]); Livewire::test(BaselineCompareLanding::class) + ->assertActionHasLabel('compareNow', 'Compare now (full content)') ->callAction('compareNow') ->assertDispatchedTo(BulkOperationProgress::class, OpsUxBrowserEvents::RunEnqueued, tenantId: (int) $tenant->getKey()); @@ -108,6 +111,45 @@ expect($run?->status)->toBe('queued'); }); +it('does not start full-content baseline compare when rollout is disabled', function (): void { + Queue::fake(); + config()->set('tenantpilot.baselines.full_content_capture.enabled', false); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'capture_mode' => \App\Support\Baselines\BaselineCaptureMode::FullContent->value, + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]); + + BaselineTenantAssignment::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + Livewire::test(BaselineCompareLanding::class) + ->assertActionHasLabel('compareNow', 'Compare now (full content)') + ->assertActionEnabled('compareNow') + ->callAction('compareNow') + ->assertNotified('Cannot start comparison') + ->assertStatus(200); + + Queue::assertNotPushed(CompareBaselineToTenantJob::class); + expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0); +}); + it('can refresh stats without calling mount directly', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); $this->actingAs($user); diff --git a/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php b/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php index 4852546..51f7e03 100644 --- a/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php +++ b/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php @@ -7,6 +7,7 @@ use App\Jobs\CaptureBaselineSnapshotJob; use App\Models\BaselineProfile; use App\Models\OperationRun; +use App\Support\Baselines\BaselineCaptureMode; use App\Support\Workspaces\WorkspaceContext; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; @@ -56,6 +57,7 @@ Livewire::actingAs($user) ->test(ViewBaselineProfile::class, ['record' => $profile->getKey()]) ->assertActionVisible('capture') + ->assertActionHasLabel('capture', 'Capture baseline') ->assertActionDisabled('capture') ->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()]) ->assertStatus(200); @@ -65,11 +67,13 @@ it('starts capture successfully for authorized workspace members', function (): void { Queue::fake(); + config()->set('tenantpilot.baselines.full_content_capture.enabled', true); [$user, $tenant] = createUserWithTenant(role: 'owner'); $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => (int) $tenant->workspace_id, + 'capture_mode' => BaselineCaptureMode::FullContent->value, ]); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); @@ -77,6 +81,7 @@ Livewire::actingAs($user) ->test(ViewBaselineProfile::class, ['record' => $profile->getKey()]) ->assertActionVisible('capture') + ->assertActionHasLabel('capture', 'Capture baseline (full content)') ->assertActionEnabled('capture') ->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()]) ->assertStatus(200); @@ -92,3 +97,29 @@ expect($run)->not->toBeNull(); expect($run?->status)->toBe('queued'); }); + +it('does not start full-content capture when rollout is disabled', function (): void { + Queue::fake(); + config()->set('tenantpilot.baselines.full_content_capture.enabled', false); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'capture_mode' => BaselineCaptureMode::FullContent->value, + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + Livewire::actingAs($user) + ->test(ViewBaselineProfile::class, ['record' => $profile->getKey()]) + ->assertActionVisible('capture') + ->assertActionHasLabel('capture', 'Capture baseline (full content)') + ->assertActionEnabled('capture') + ->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()]) + ->assertNotified('Cannot start capture') + ->assertStatus(200); + + Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); + expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); +}); diff --git a/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php b/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php new file mode 100644 index 0000000..43ca209 --- /dev/null +++ b/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php @@ -0,0 +1,136 @@ +set('tenantpilot.baselines.full_content_capture.enabled', true); + + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'capture_mode' => BaselineCaptureMode::FullContent->value, + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]); + + BaselineTenantAssignment::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + Livewire::actingAs($user) + ->test(ViewBaselineProfile::class, ['record' => $profile->getKey()]) + ->assertActionVisible('compareNow') + ->assertActionHasLabel('compareNow', 'Compare now (full content)') + ->assertActionDisabled('compareNow') + ->callAction('compareNow', data: ['target_tenant_id' => (int) $tenant->getKey()]) + ->assertStatus(200); + + Queue::assertNotPushed(CompareBaselineToTenantJob::class); +}); + +it('starts baseline compare successfully for authorized workspace members', function (): void { + Queue::fake(); + config()->set('tenantpilot.baselines.full_content_capture.enabled', true); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'capture_mode' => BaselineCaptureMode::FullContent->value, + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]); + + BaselineTenantAssignment::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + Livewire::actingAs($user) + ->test(ViewBaselineProfile::class, ['record' => $profile->getKey()]) + ->assertActionVisible('compareNow') + ->assertActionHasLabel('compareNow', 'Compare now (full content)') + ->assertActionEnabled('compareNow') + ->callAction('compareNow', data: ['target_tenant_id' => (int) $tenant->getKey()]) + ->assertStatus(200); + + Queue::assertPushed(CompareBaselineToTenantJob::class); + + $run = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'baseline_compare') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + expect($run?->status)->toBe('queued'); +}); + +it('does not start full-content baseline compare when rollout is disabled', function (): void { + Queue::fake(); + config()->set('tenantpilot.baselines.full_content_capture.enabled', false); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'capture_mode' => BaselineCaptureMode::FullContent->value, + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]); + + BaselineTenantAssignment::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + Livewire::actingAs($user) + ->test(ViewBaselineProfile::class, ['record' => $profile->getKey()]) + ->assertActionVisible('compareNow') + ->assertActionHasLabel('compareNow', 'Compare now (full content)') + ->assertActionEnabled('compareNow') + ->callAction('compareNow', data: ['target_tenant_id' => (int) $tenant->getKey()]) + ->assertNotified('Cannot start comparison') + ->assertStatus(200); + + Queue::assertNotPushed(CompareBaselineToTenantJob::class); + expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0); +}); + diff --git a/tests/Feature/Intune/PolicySnapshotRedactionTest.php b/tests/Feature/Intune/PolicySnapshotRedactionTest.php new file mode 100644 index 0000000..3aebb31 --- /dev/null +++ b/tests/Feature/Intune/PolicySnapshotRedactionTest.php @@ -0,0 +1,90 @@ +create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => 'windows10', + ]); + + /** @var VersionService $service */ + $service = app(VersionService::class); + + $version1 = $service->captureVersion( + policy: $policy, + payload: [ + 'wifi' => [ + 'ssid' => 'Corp', + 'password' => 'super-secret-1', + ], + 'settings' => [ + 'example' => true, + ], + ], + createdBy: $user->email, + ); + + $version2 = $service->captureVersion( + policy: $policy, + payload: [ + 'wifi' => [ + 'ssid' => 'Corp', + 'password' => 'super-secret-2', + ], + 'settings' => [ + 'example' => true, + ], + ], + createdBy: $user->email, + ); + + $fresh1 = PolicyVersion::query()->findOrFail((int) $version1->getKey()); + $fresh2 = PolicyVersion::query()->findOrFail((int) $version2->getKey()); + + expect($fresh1->snapshot['wifi']['password'])->toBe('[REDACTED]'); + expect($fresh2->snapshot['wifi']['password'])->toBe('[REDACTED]'); + expect($fresh1->snapshot['wifi']['ssid'])->toBe('Corp'); + expect($fresh2->snapshot['wifi']['ssid'])->toBe('Corp'); + + $settingsNormalizer = app(SettingsNormalizer::class); + $assignmentsNormalizer = app(AssignmentsNormalizer::class); + $scopeTagsNormalizer = app(ScopeTagsNormalizer::class); + $hasher = app(DriftHasher::class); + + $hash1 = $hasher->hashNormalized([ + 'settings' => $settingsNormalizer->normalizeForDiff( + snapshot: $fresh1->snapshot, + policyType: (string) $fresh1->policy_type, + platform: is_string($fresh1->platform) ? (string) $fresh1->platform : null, + ), + 'assignments' => $assignmentsNormalizer->normalizeForDiff($fresh1->assignments ?? []), + 'scope_tag_ids' => $scopeTagsNormalizer->normalizeIds($fresh1->scope_tags ?? []), + ]); + + $hash2 = $hasher->hashNormalized([ + 'settings' => $settingsNormalizer->normalizeForDiff( + snapshot: $fresh2->snapshot, + policyType: (string) $fresh2->policy_type, + platform: is_string($fresh2->platform) ? (string) $fresh2->platform : null, + ), + 'assignments' => $assignmentsNormalizer->normalizeForDiff($fresh2->assignments ?? []), + 'scope_tag_ids' => $scopeTagsNormalizer->normalizeIds($fresh2->scope_tags ?? []), + ]); + + expect($hash1)->toBe($hash2); +}); -- 2.45.2 From 559bba09a0c495886ddacd63dd8df7a19c697298 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 4 Mar 2026 00:09:27 +0100 Subject: [PATCH 2/3] fix: align compare UI + drift engine tests with subject_key --- app/Filament/Pages/BaselineCompareLanding.php | 8 + app/Filament/Resources/FindingResource.php | 29 +- .../Resources/OperationRunResource.php | 124 +++++++++ .../Baselines/BaselineCompareStats.php | 67 +++++ .../pages/baseline-compare-landing.blade.php | 41 ++- specs/118-baseline-drift-engine/tasks.md | 36 +-- .../CompareContentEvidenceTest.php | 41 ++- .../CompareFidelityMismatchTest.php | 37 ++- .../FindingFidelityTest.php | 29 +- .../FindingProvenanceTest.php | 22 +- .../BaselineDriftEngine/ResolverTest.php | 10 +- .../BaselineCompareCoverageGuardTest.php | 23 +- .../Baselines/BaselineCompareFindingsTest.php | 251 +++++++++++++----- 13 files changed, 593 insertions(+), 125 deletions(-) diff --git a/app/Filament/Pages/BaselineCompareLanding.php b/app/Filament/Pages/BaselineCompareLanding.php index 12b6e00..c2360e8 100644 --- a/app/Filament/Pages/BaselineCompareLanding.php +++ b/app/Filament/Pages/BaselineCompareLanding.php @@ -72,6 +72,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(); @@ -116,6 +121,9 @@ public function refreshStats(): void $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; } public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration diff --git a/app/Filament/Resources/FindingResource.php b/app/Filament/Resources/FindingResource.php index 8363c8a..29d31ba 100644 --- a/app/Filament/Resources/FindingResource.php +++ b/app/Filament/Resources/FindingResource.php @@ -144,7 +144,20 @@ public static function infolist(Schema $schema): Schema ->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..b7bd719 100644 --- a/app/Filament/Resources/OperationRunResource.php +++ b/app/Filament/Resources/OperationRunResource.php @@ -259,6 +259,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/Support/Baselines/BaselineCompareStats.php b/app/Support/Baselines/BaselineCompareStats.php index ec9be9b..3cbf473 100644 --- a/app/Support/Baselines/BaselineCompareStats.php +++ b/app/Support/Baselines/BaselineCompareStats.php @@ -15,6 +15,7 @@ final class BaselineCompareStats /** * @param array $severityCounts * @param list $uncoveredTypes + * @param array $evidenceGapsTopReasons */ private function __construct( public readonly string $state, @@ -32,6 +33,8 @@ private function __construct( 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 @@ -80,6 +83,7 @@ public static function forTenant(?Tenant $tenant): self ->first(); [$coverageStatus, $uncoveredTypes, $fidelity] = self::coverageInfoForRun($latestRun); + [$evidenceGapsCount, $evidenceGapsTopReasons] = self::evidenceGapSummaryForRun($latestRun); // Active run (queued/running) if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) { @@ -99,6 +103,8 @@ public static function forTenant(?Tenant $tenant): self uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypes: $uncoveredTypes, fidelity: $fidelity, + evidenceGapsCount: $evidenceGapsCount, + evidenceGapsTopReasons: $evidenceGapsTopReasons, ); } @@ -125,6 +131,8 @@ public static function forTenant(?Tenant $tenant): self uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypes: $uncoveredTypes, fidelity: $fidelity, + evidenceGapsCount: $evidenceGapsCount, + evidenceGapsTopReasons: $evidenceGapsTopReasons, ); } @@ -173,6 +181,8 @@ public static function forTenant(?Tenant $tenant): self uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypes: $uncoveredTypes, fidelity: $fidelity, + evidenceGapsCount: $evidenceGapsCount, + evidenceGapsTopReasons: $evidenceGapsTopReasons, ); } @@ -195,6 +205,8 @@ public static function forTenant(?Tenant $tenant): self uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypes: $uncoveredTypes, fidelity: $fidelity, + evidenceGapsCount: $evidenceGapsCount, + evidenceGapsTopReasons: $evidenceGapsTopReasons, ); } @@ -214,6 +226,8 @@ public static function forTenant(?Tenant $tenant): self uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypes: $uncoveredTypes, fidelity: $fidelity, + evidenceGapsCount: $evidenceGapsCount, + evidenceGapsTopReasons: $evidenceGapsTopReasons, ); } @@ -321,6 +335,59 @@ private static function coverageInfoForRun(?OperationRun $run): array return [$coverageStatus, $uncoveredTypes, $fidelity]; } + /** + * @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, diff --git a/resources/views/filament/pages/baseline-compare-landing.blade.php b/resources/views/filament/pages/baseline-compare-landing.blade.php index 224e689..27458ee 100644 --- a/resources/views/filament/pages/baseline-compare-landing.blade.php +++ b/resources/views/filament/pages/baseline-compare-landing.blade.php @@ -6,6 +6,29 @@ @php $hasCoverageWarnings = in_array(($coverageStatus ?? null), ['warning', 'unproven'], true); + $evidenceGapsCountValue = (int) ($evidenceGapsCount ?? 0); + $hasEvidenceGaps = $evidenceGapsCountValue > 0; + $hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps; + + $evidenceGapsSummary = null; + $evidenceGapsTooltip = null; + + if ($hasEvidenceGaps && is_array($evidenceGapsTopReasons ?? null) && $evidenceGapsTopReasons !== []) { + $parts = []; + + foreach (array_slice($evidenceGapsTopReasons, 0, 5, true) as $reason => $count) { + if (! is_string($reason) || $reason === '' || ! is_numeric($count)) { + continue; + } + + $parts[] = $reason.' ('.((int) $count).')'; + } + + if ($parts !== []) { + $evidenceGapsSummary = implode(', ', $parts); + $evidenceGapsTooltip = 'Top gaps: '.$evidenceGapsSummary; + } + } @endphp {{-- Row 1: Stats Overview --}} @@ -38,7 +61,19 @@ class="w-fit" Fidelity: {{ Str::title($fidelity) }} @endif + + @if ($hasEvidenceGaps) + + Evidence gaps: {{ $evidenceGapsCountValue }} + + @endif + + @if ($hasEvidenceGaps && filled($evidenceGapsSummary)) +
+ Top gaps: {{ $evidenceGapsSummary }} +
+ @endif @@ -49,7 +84,7 @@ class="w-fit" @if ($state === 'failed')
Error
@else -
+
{{ $findingsCount ?? 0 }}
@endif @@ -58,10 +93,12 @@ class="w-fit" Comparing…
- @elseif (($findingsCount ?? 0) === 0 && $state === 'ready' && ! $hasCoverageWarnings) + @elseif (($findingsCount ?? 0) === 0 && $state === 'ready' && ! $hasWarnings) All clear @elseif ($state === 'ready' && $hasCoverageWarnings) Coverage warnings + @elseif ($state === 'ready' && $hasEvidenceGaps) + Evidence gaps @endif diff --git a/specs/118-baseline-drift-engine/tasks.md b/specs/118-baseline-drift-engine/tasks.md index 7f5a144..f8d41fa 100644 --- a/specs/118-baseline-drift-engine/tasks.md +++ b/specs/118-baseline-drift-engine/tasks.md @@ -107,27 +107,27 @@ ## Phase 4: User Story 2 — Compare now with full content and get explainable d ### Tests (write first) -- [ ] T040 [P] [US2] Add cross-tenant match test (policy_type + `subject_key`) in `tests/Feature/Baselines/BaselineCompareCrossTenantMatchTest.php` -- [ ] T041 [P] [US2] Add ambiguous match suppression test in `tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php` (duplicate `subject_key` values → evidence gap; no finding) -- [ ] T042 [P] [US2] Add coverage proof guard test in `tests/Feature/Baselines/BaselineCompareCoverageProofGuardTest.php` (uncovered types suppress `missing_policy` outcomes; run completes with warnings + records context) -- [ ] T043 [P] [US2] Add stable recurrence identity test in `tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php` (recurrence key independent of hashes; retries don’t duplicate; lifecycle fields update) -- [ ] T044 [P] [US2] Update compare start surface expectations for full-content labeling + rollout gating in `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php` -- [ ] T045 [P] [US2] Add baseline profile “Compare now (full content)” start-surface test in `tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php` -- [ ] T046 [P] [US2] Add audit event coverage for baseline compare start/completion in `tests/Feature/Baselines/BaselineCompareAuditEventsTest.php` (purpose, scope counts, gaps/warnings summary) +- [X] T040 [P] [US2] Add cross-tenant match test (policy_type + `subject_key`) in `tests/Feature/Baselines/BaselineCompareCrossTenantMatchTest.php` +- [X] T041 [P] [US2] Add ambiguous match suppression test in `tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php` (duplicate `subject_key` values → evidence gap; no finding) +- [X] T042 [P] [US2] Add coverage proof guard test in `tests/Feature/Baselines/BaselineCompareCoverageProofGuardTest.php` (uncovered types suppress `missing_policy` outcomes; run completes with warnings + records context) +- [X] T043 [P] [US2] Add stable recurrence identity test in `tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php` (recurrence key independent of hashes; retries don’t duplicate; lifecycle fields update) +- [X] T044 [P] [US2] Update compare start surface expectations for full-content labeling + rollout gating in `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php` +- [X] T045 [P] [US2] Add baseline profile “Compare now (full content)” start-surface test in `tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php` +- [X] T046 [P] [US2] Add audit event coverage for baseline compare start/completion in `tests/Feature/Baselines/BaselineCompareAuditEventsTest.php` (purpose, scope counts, gaps/warnings summary) ### Implementation -- [ ] T047 [US2] Add “Compare now (full content)” header action to baseline profile view in `app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` (select target tenant; require `tenant.sync`; enforce rollout gate server-side) -- [ ] T048 [US2] Integrate `BaselineContentCapturePhase` refresh into compare in `app/Jobs/CompareBaselineToTenantJob.php` (purpose `baseline_compare`, budgeted, record `context.baseline_compare.evidence_capture`, `context.baseline_compare.evidence_gaps`, `context.baseline_compare.resume_token`, and add job-level rollout gate guard) -- [ ] T049 [US2] Switch compare matching to `policy_type + subject_key` in `app/Jobs/CompareBaselineToTenantJob.php` (load baseline items by `subject_key`; compute current `subject_key` from inventory display name; detect missing/empty/duplicate keys on either side; record gap reasons; suppress drift evaluation for those keys) -- [ ] T050 [US2] Enforce coverage proof guard behavior in `app/Jobs/CompareBaselineToTenantJob.php` (suppress `missing_policy` for uncovered/unproven types; record warning + `BaselineCompareReasonCode` when suppression affects outcomes) -- [ ] T051 [US2] Update finding recurrence identity to be stable and independent of hashes in `app/Jobs/CompareBaselineToTenantJob.php` (recurrence key uses tenant_id + baseline_profile_id + policy_type + subject_key + change_type; retries must not duplicate findings) -- [ ] T052 [US2] Ensure findings carry `subject_key` + `display_name` fallbacks in `evidence_jsonb` and update subject display name fallback logic in `app/Filament/Resources/FindingResource.php` (COALESCE inventory display name with evidence display name) -- [ ] T053 [US2] Ensure compare run context contains scope totals, processed counts, coverage proof status, fidelity breakdown, evidence capture stats, and top gap reasons in `app/Jobs/CompareBaselineToTenantJob.php` -- [ ] T054 [US2] Update baseline compare landing to label “Compare now (full content)” when applicable in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php` -- [ ] T055 [US2] Extend stats DTO to surface fidelity + evidence gap summary from run context in `app/Support/Baselines/BaselineCompareStats.php` -- [ ] T056 [US2] Add evidence capture + gaps panels for baseline capture/compare runs in Monitoring detail in `app/Filament/Resources/OperationRunResource.php` -- [ ] T057 [US2] Expand compare audit events to include purpose, scope counts, evidence capture stats, and gaps/warnings summary in `app/Jobs/CompareBaselineToTenantJob.php` +- [X] T047 [US2] Add “Compare now (full content)” header action to baseline profile view in `app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` (select target tenant; require `tenant.sync`; enforce rollout gate server-side) +- [X] T048 [US2] Integrate `BaselineContentCapturePhase` refresh into compare in `app/Jobs/CompareBaselineToTenantJob.php` (purpose `baseline_compare`, budgeted, record `context.baseline_compare.evidence_capture`, `context.baseline_compare.evidence_gaps`, `context.baseline_compare.resume_token`, and add job-level rollout gate guard) +- [X] T049 [US2] Switch compare matching to `policy_type + subject_key` in `app/Jobs/CompareBaselineToTenantJob.php` (load baseline items by `subject_key`; compute current `subject_key` from inventory display name; detect missing/empty/duplicate keys on either side; record gap reasons; suppress drift evaluation for those keys) +- [X] T050 [US2] Enforce coverage proof guard behavior in `app/Jobs/CompareBaselineToTenantJob.php` (suppress `missing_policy` for uncovered/unproven types; record warning + `BaselineCompareReasonCode` when suppression affects outcomes) +- [X] T051 [US2] Update finding recurrence identity to be stable and independent of hashes in `app/Jobs/CompareBaselineToTenantJob.php` (recurrence key uses tenant_id + baseline_profile_id + policy_type + subject_key + change_type; retries must not duplicate findings) +- [X] T052 [US2] Ensure findings carry `subject_key` + `display_name` fallbacks in `evidence_jsonb` and update subject display name fallback logic in `app/Filament/Resources/FindingResource.php` (COALESCE inventory display name with evidence display name) +- [X] T053 [US2] Ensure compare run context contains scope totals, processed counts, coverage proof status, fidelity breakdown, evidence capture stats, and top gap reasons in `app/Jobs/CompareBaselineToTenantJob.php` +- [X] T054 [US2] Update baseline compare landing to label “Compare now (full content)” when applicable in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php` +- [X] T055 [US2] Extend stats DTO to surface fidelity + evidence gap summary from run context in `app/Support/Baselines/BaselineCompareStats.php` +- [X] T056 [US2] Add evidence capture + gaps panels for baseline capture/compare runs in Monitoring detail in `app/Filament/Resources/OperationRunResource.php` +- [X] T057 [US2] Expand compare audit events to include purpose, scope counts, evidence capture stats, and gaps/warnings summary in `app/Jobs/CompareBaselineToTenantJob.php` **Parallel execution example (US2)**: diff --git a/tests/Feature/BaselineDriftEngine/CompareContentEvidenceTest.php b/tests/Feature/BaselineDriftEngine/CompareContentEvidenceTest.php index 6f5e53b..7dcd4d4 100644 --- a/tests/Feature/BaselineDriftEngine/CompareContentEvidenceTest.php +++ b/tests/Feature/BaselineDriftEngine/CompareContentEvidenceTest.php @@ -10,9 +10,12 @@ use App\Models\PolicyVersion; use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Drift\DriftHasher; +use App\Services\Drift\Normalizers\AssignmentsNormalizer; use App\Services\Drift\Normalizers\SettingsNormalizer; +use App\Services\Drift\Normalizers\ScopeTagsNormalizer; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Support\Baselines\BaselineSubjectKey; use App\Support\OperationRunType; use Carbon\CarbonImmutable; @@ -53,18 +56,27 @@ ], ]; - $baselineHash = app(DriftHasher::class)->hashNormalized( - app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshot, 'deviceConfiguration', 'windows'), - ); + $baselineHash = app(DriftHasher::class)->hashNormalized([ + 'settings' => app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshot, 'deviceConfiguration', 'windows'), + 'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]), + 'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]), + ]); + + $subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name); + expect($subjectKey)->not->toBeNull(); BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => (int) $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => (string) $policy->external_id, + 'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: (string) $policy->policy_type, + subjectKey: (string) $subjectKey, + ), + 'subject_key' => (string) $subjectKey, 'policy_type' => (string) $policy->policy_type, 'baseline_hash' => $baselineHash, 'meta_jsonb' => [ - 'display_name' => 'Policy A', + 'display_name' => (string) $policy->display_name, 'evidence' => [ 'fidelity' => 'content', 'source' => 'policy_version', @@ -168,18 +180,27 @@ ], ]; - $baselineHash = app(DriftHasher::class)->hashNormalized( - app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'), - ); + $baselineHash = app(DriftHasher::class)->hashNormalized([ + 'settings' => app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'), + 'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]), + 'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]), + ]); + + $subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name); + expect($subjectKey)->not->toBeNull(); BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => (int) $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => (string) $policy->external_id, + 'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: (string) $policy->policy_type, + subjectKey: (string) $subjectKey, + ), + 'subject_key' => (string) $subjectKey, 'policy_type' => (string) $policy->policy_type, 'baseline_hash' => $baselineHash, 'meta_jsonb' => [ - 'display_name' => 'Policy B', + 'display_name' => (string) $policy->display_name, 'evidence' => [ 'fidelity' => 'content', 'source' => 'policy_version', diff --git a/tests/Feature/BaselineDriftEngine/CompareFidelityMismatchTest.php b/tests/Feature/BaselineDriftEngine/CompareFidelityMismatchTest.php index 43ef3f6..da91741 100644 --- a/tests/Feature/BaselineDriftEngine/CompareFidelityMismatchTest.php +++ b/tests/Feature/BaselineDriftEngine/CompareFidelityMismatchTest.php @@ -10,9 +10,12 @@ use App\Models\PolicyVersion; use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Drift\DriftHasher; +use App\Services\Drift\Normalizers\AssignmentsNormalizer; use App\Services\Drift\Normalizers\SettingsNormalizer; +use App\Services\Drift\Normalizers\ScopeTagsNormalizer; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Support\Baselines\BaselineSubjectKey; use App\Support\OperationRunType; use Carbon\CarbonImmutable; @@ -68,14 +71,21 @@ metaJsonb: is_array($inventory->meta_jsonb) ? $inventory->meta_jsonb : [], ); + $subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name); + expect($subjectKey)->not->toBeNull(); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => (int) $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => (string) $policy->external_id, + 'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: (string) $policy->policy_type, + subjectKey: (string) $subjectKey, + ), + 'subject_key' => (string) $subjectKey, 'policy_type' => (string) $policy->policy_type, 'baseline_hash' => $baselineMetaHash, 'meta_jsonb' => [ - 'display_name' => 'Policy Meta', + 'display_name' => (string) $policy->display_name, 'evidence' => [ 'fidelity' => 'meta', 'source' => 'inventory', @@ -123,7 +133,7 @@ $context = is_array($run->context) ? $run->context : []; expect(data_get($context, 'baseline_compare.fidelity'))->toBe('meta'); - expect(data_get($context, 'baseline_compare.coverage.resolved_content'))->toBe(1); + expect(data_get($context, 'baseline_compare.coverage.resolved_meta'))->toBe(1); expect(data_get($context, 'baseline_compare.coverage.baseline_meta'))->toBe(1); }); @@ -164,18 +174,27 @@ ], ]; - $baselineContentHash = app(DriftHasher::class)->hashNormalized( - app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'), - ); + $baselineContentHash = app(DriftHasher::class)->hashNormalized([ + 'settings' => app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'), + 'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]), + 'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]), + ]); + + $subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name); + expect($subjectKey)->not->toBeNull(); BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => (int) $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => (string) $policy->external_id, + 'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: (string) $policy->policy_type, + subjectKey: (string) $subjectKey, + ), + 'subject_key' => (string) $subjectKey, 'policy_type' => (string) $policy->policy_type, 'baseline_hash' => $baselineContentHash, 'meta_jsonb' => [ - 'display_name' => 'Policy Content', + 'display_name' => (string) $policy->display_name, 'evidence' => [ 'fidelity' => 'content', 'source' => 'policy_version', @@ -234,7 +253,7 @@ $context = is_array($run->context) ? $run->context : []; expect(data_get($context, 'baseline_compare.fidelity'))->toBe('meta'); - expect(data_get($context, 'baseline_compare.coverage.resolved_meta'))->toBe(1); + expect(data_get($context, 'baseline_compare.coverage.resolved_meta'))->toBe(0); expect(data_get($context, 'baseline_compare.coverage.baseline_content'))->toBe(1); expect(data_get($context, 'baseline_compare.evidence_gaps.missing_current'))->toBe(1); }); diff --git a/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php b/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php index 93e3b68..74cd6ca 100644 --- a/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php +++ b/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php @@ -10,9 +10,12 @@ use App\Models\PolicyVersion; use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Drift\DriftHasher; +use App\Services\Drift\Normalizers\AssignmentsNormalizer; use App\Services\Drift\Normalizers\SettingsNormalizer; +use App\Services\Drift\Normalizers\ScopeTagsNormalizer; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Support\Baselines\BaselineSubjectKey; use App\Support\OperationRunType; use Carbon\CarbonImmutable; @@ -51,14 +54,23 @@ ], ]; - $baselineHash = app(DriftHasher::class)->hashNormalized( - app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshotPayload, 'deviceConfiguration', 'windows'), - ); + $baselineHash = app(DriftHasher::class)->hashNormalized([ + 'settings' => app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshotPayload, 'deviceConfiguration', 'windows'), + 'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]), + 'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]), + ]); + + $subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name); + expect($subjectKey)->not->toBeNull(); BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => (int) $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => (string) $policy->external_id, + 'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: (string) $policy->policy_type, + subjectKey: (string) $subjectKey, + ), + 'subject_key' => (string) $subjectKey, 'policy_type' => (string) $policy->policy_type, 'baseline_hash' => $baselineHash, 'meta_jsonb' => [ @@ -169,10 +181,17 @@ metaJsonb: $baselineMetaJsonb, ); + $subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name); + expect($subjectKey)->not->toBeNull(); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => (int) $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => (string) $policy->external_id, + 'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: (string) $policy->policy_type, + subjectKey: (string) $subjectKey, + ), + 'subject_key' => (string) $subjectKey, 'policy_type' => (string) $policy->policy_type, 'baseline_hash' => $baselineHash, 'meta_jsonb' => [ diff --git a/tests/Feature/BaselineDriftEngine/FindingProvenanceTest.php b/tests/Feature/BaselineDriftEngine/FindingProvenanceTest.php index 62d0132..84b2def 100644 --- a/tests/Feature/BaselineDriftEngine/FindingProvenanceTest.php +++ b/tests/Feature/BaselineDriftEngine/FindingProvenanceTest.php @@ -10,9 +10,12 @@ use App\Models\PolicyVersion; use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Drift\DriftHasher; +use App\Services\Drift\Normalizers\AssignmentsNormalizer; use App\Services\Drift\Normalizers\SettingsNormalizer; +use App\Services\Drift\Normalizers\ScopeTagsNormalizer; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Support\Baselines\BaselineSubjectKey; use App\Support\OperationRunType; use Carbon\CarbonImmutable; @@ -51,18 +54,27 @@ ], ]; - $baselineHash = app(DriftHasher::class)->hashNormalized( - app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshotPayload, 'deviceConfiguration', 'windows'), - ); + $baselineHash = app(DriftHasher::class)->hashNormalized([ + 'settings' => app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshotPayload, 'deviceConfiguration', 'windows'), + 'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]), + 'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]), + ]); + + $subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name); + expect($subjectKey)->not->toBeNull(); BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => (int) $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => (string) $policy->external_id, + 'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId( + policyType: (string) $policy->policy_type, + subjectKey: (string) $subjectKey, + ), + 'subject_key' => (string) $subjectKey, 'policy_type' => (string) $policy->policy_type, 'baseline_hash' => $baselineHash, 'meta_jsonb' => [ - 'display_name' => 'Policy Provenance', + 'display_name' => (string) $policy->display_name, 'evidence' => [ 'fidelity' => 'content', 'source' => 'policy_version', diff --git a/tests/Feature/BaselineDriftEngine/ResolverTest.php b/tests/Feature/BaselineDriftEngine/ResolverTest.php index b74d2e8..821d68c 100644 --- a/tests/Feature/BaselineDriftEngine/ResolverTest.php +++ b/tests/Feature/BaselineDriftEngine/ResolverTest.php @@ -6,7 +6,9 @@ use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Baselines\CurrentStateHashResolver; 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; it('Baseline resolver prefers content evidence over meta evidence when available', function () { @@ -46,13 +48,15 @@ 'last_seen_operation_run_id' => null, ]); - $expectedContentHash = app(DriftHasher::class)->hashNormalized( - app(SettingsNormalizer::class)->normalizeForDiff( + $expectedContentHash = app(DriftHasher::class)->hashNormalized([ + 'settings' => app(SettingsNormalizer::class)->normalizeForDiff( is_array($policyVersion->snapshot) ? $policyVersion->snapshot : [], (string) $policyVersion->policy_type, is_string($policyVersion->platform) ? $policyVersion->platform : null, ), - ); + 'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]), + 'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]), + ]); $expectedMetaHash = app(BaselineSnapshotIdentity::class)->hashItemContent( policyType: (string) $inventory->policy_type, diff --git a/tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php b/tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php index f109d84..1131856 100644 --- a/tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php +++ b/tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php @@ -12,6 +12,7 @@ use App\Services\Drift\DriftHasher; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Support\Baselines\BaselineSubjectKey; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\OperationRunType; @@ -43,13 +44,19 @@ metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'], ); + $coveredDisplayName = 'Covered Policy'; + $coveredSubjectKey = BaselineSubjectKey::fromDisplayName($coveredDisplayName); + expect($coveredSubjectKey)->not->toBeNull(); + $coveredWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $coveredSubjectKey); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => (int) $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'covered-uuid', + 'subject_external_id' => $coveredWorkspaceSafeExternalId, + 'subject_key' => (string) $coveredSubjectKey, 'policy_type' => 'deviceConfiguration', 'baseline_hash' => $hasher->hashNormalized($coveredContract), - 'meta_jsonb' => ['display_name' => 'Covered Policy'], + 'meta_jsonb' => ['display_name' => $coveredDisplayName], ]); $uncoveredContract = $builder->build( @@ -58,13 +65,19 @@ metaJsonb: ['odata_type' => '#microsoft.graph.deviceCompliancePolicy', 'etag' => 'E_BASELINE'], ); + $uncoveredDisplayName = 'Uncovered Policy'; + $uncoveredSubjectKey = BaselineSubjectKey::fromDisplayName($uncoveredDisplayName); + expect($uncoveredSubjectKey)->not->toBeNull(); + $uncoveredWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceCompliancePolicy', (string) $uncoveredSubjectKey); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => (int) $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'uncovered-uuid', + 'subject_external_id' => $uncoveredWorkspaceSafeExternalId, + 'subject_key' => (string) $uncoveredSubjectKey, 'policy_type' => 'deviceCompliancePolicy', 'baseline_hash' => $hasher->hashNormalized($uncoveredContract), - 'meta_jsonb' => ['display_name' => 'Uncovered Policy'], + 'meta_jsonb' => ['display_name' => $uncoveredDisplayName], ]); $inventorySyncRun = OperationRun::factory()->create([ @@ -93,7 +106,7 @@ 'external_id' => 'covered-uuid', 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'], - 'display_name' => 'Covered Policy Changed', + 'display_name' => $coveredDisplayName, 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), 'last_seen_at' => now(), ]); diff --git a/tests/Feature/Baselines/BaselineCompareFindingsTest.php b/tests/Feature/Baselines/BaselineCompareFindingsTest.php index 6ed5527..cd81ae6 100644 --- a/tests/Feature/Baselines/BaselineCompareFindingsTest.php +++ b/tests/Feature/Baselines/BaselineCompareFindingsTest.php @@ -15,6 +15,7 @@ use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; use App\Services\Settings\SettingsResolver; +use App\Support\Baselines\BaselineSubjectKey; use App\Support\OperationRunType; use App\Support\OpsUx\OperationSummaryKeys; @@ -42,22 +43,46 @@ statusByType: ['deviceConfiguration' => 'succeeded'], ); + $policyType = 'deviceConfiguration'; + + $displayNameA = 'Policy A'; + $subjectKeyA = BaselineSubjectKey::fromDisplayName($displayNameA); + expect($subjectKeyA)->not->toBeNull(); + $workspaceSafeExternalIdA = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKeyA); + $baselineHashA = app(BaselineSnapshotIdentity::class)->hashItemContent( + policyType: $policyType, + subjectExternalId: 'policy-a-uuid', + metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE_A'], + ); + + $displayNameB = 'Policy B'; + $subjectKeyB = BaselineSubjectKey::fromDisplayName($displayNameB); + expect($subjectKeyB)->not->toBeNull(); + $workspaceSafeExternalIdB = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKeyB); + $baselineHashB = app(BaselineSnapshotIdentity::class)->hashItemContent( + policyType: $policyType, + subjectExternalId: 'policy-b-uuid', + metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE_B'], + ); + // Baseline has policyA and policyB BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'policy-a-uuid', - 'policy_type' => 'deviceConfiguration', - 'baseline_hash' => hash('sha256', 'content-a'), - 'meta_jsonb' => ['display_name' => 'Policy A'], + 'subject_external_id' => $workspaceSafeExternalIdA, + 'subject_key' => (string) $subjectKeyA, + 'policy_type' => $policyType, + 'baseline_hash' => $baselineHashA, + 'meta_jsonb' => ['display_name' => $displayNameA], ]); BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'policy-b-uuid', - 'policy_type' => 'deviceConfiguration', - 'baseline_hash' => hash('sha256', 'content-b'), - 'meta_jsonb' => ['display_name' => 'Policy B'], + 'subject_external_id' => $workspaceSafeExternalIdB, + 'subject_key' => (string) $subjectKeyB, + 'policy_type' => $policyType, + 'baseline_hash' => $baselineHashB, + 'meta_jsonb' => ['display_name' => $displayNameB], ]); // Tenant has policyA (different content) and policyC (unexpected) @@ -65,9 +90,9 @@ 'tenant_id' => $tenant->getKey(), 'workspace_id' => $tenant->workspace_id, 'external_id' => 'policy-a-uuid', - 'policy_type' => 'deviceConfiguration', - 'meta_jsonb' => ['different_content' => true], - 'display_name' => 'Policy A modified', + 'policy_type' => $policyType, + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_A'], + 'display_name' => $displayNameA, 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), 'last_seen_at' => now(), ]); @@ -75,9 +100,9 @@ 'tenant_id' => $tenant->getKey(), 'workspace_id' => $tenant->workspace_id, 'external_id' => 'policy-c-uuid', - 'policy_type' => 'deviceConfiguration', - 'meta_jsonb' => ['new_policy' => true], - 'display_name' => 'Policy C unexpected', + 'policy_type' => $policyType, + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_C'], + 'display_name' => 'Policy C', 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), 'last_seen_at' => now(), ]); @@ -157,30 +182,54 @@ statusByType: ['deviceConfiguration' => 'succeeded'], ); + $policyType = 'deviceConfiguration'; + + $displayNameA = 'Policy A'; + $subjectKeyA = BaselineSubjectKey::fromDisplayName($displayNameA); + expect($subjectKeyA)->not->toBeNull(); + $workspaceSafeExternalIdA = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKeyA); + $baselineHashA = app(BaselineSnapshotIdentity::class)->hashItemContent( + policyType: $policyType, + subjectExternalId: 'policy-a-uuid', + metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE_A'], + ); + + $displayNameB = 'Policy B'; + $subjectKeyB = BaselineSubjectKey::fromDisplayName($displayNameB); + expect($subjectKeyB)->not->toBeNull(); + $workspaceSafeExternalIdB = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKeyB); + $baselineHashB = app(BaselineSnapshotIdentity::class)->hashItemContent( + policyType: $policyType, + subjectExternalId: 'policy-b-uuid', + metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE_B'], + ); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'policy-a-uuid', - 'policy_type' => 'deviceConfiguration', - 'baseline_hash' => hash('sha256', 'content-a'), - 'meta_jsonb' => ['display_name' => 'Policy A'], + 'subject_external_id' => $workspaceSafeExternalIdA, + 'subject_key' => (string) $subjectKeyA, + 'policy_type' => $policyType, + 'baseline_hash' => $baselineHashA, + 'meta_jsonb' => ['display_name' => $displayNameA], ]); BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'policy-b-uuid', - 'policy_type' => 'deviceConfiguration', - 'baseline_hash' => hash('sha256', 'content-b'), - 'meta_jsonb' => ['display_name' => 'Policy B'], + 'subject_external_id' => $workspaceSafeExternalIdB, + 'subject_key' => (string) $subjectKeyB, + 'policy_type' => $policyType, + 'baseline_hash' => $baselineHashB, + 'meta_jsonb' => ['display_name' => $displayNameB], ]); InventoryItem::factory()->create([ 'tenant_id' => $tenant->getKey(), 'workspace_id' => $tenant->workspace_id, 'external_id' => 'policy-a-uuid', - 'policy_type' => 'deviceConfiguration', - 'meta_jsonb' => ['different_content' => true], - 'display_name' => 'Policy A modified', + 'policy_type' => $policyType, + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_A'], + 'display_name' => $displayNameA, 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), 'last_seen_at' => now(), ]); @@ -266,13 +315,19 @@ ], ); + $displayName = 'Settings Catalog A'; + $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); + expect($subjectKey)->not->toBeNull(); + $workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('settingsCatalogPolicy', (string) $subjectKey); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'settings-catalog-policy-uuid', + 'subject_external_id' => $workspaceSafeExternalId, + 'subject_key' => (string) $subjectKey, 'policy_type' => 'settingsCatalogPolicy', 'baseline_hash' => hash('sha256', 'content-a'), - 'meta_jsonb' => ['display_name' => 'Settings Catalog A'], + 'meta_jsonb' => ['display_name' => $displayName], ]); // Inventory item exists, but it was NOT observed in the latest sync run. @@ -281,7 +336,7 @@ 'workspace_id' => $tenant->workspace_id, 'external_id' => 'settings-catalog-policy-uuid', 'policy_type' => 'settingsCatalogPolicy', - 'display_name' => 'Settings Catalog A', + 'display_name' => $displayName, 'meta_jsonb' => ['etag' => 'abc'], 'last_seen_operation_run_id' => (int) $olderInventoryRun->getKey(), 'last_seen_at' => now()->subMinutes(5), @@ -351,13 +406,19 @@ metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'], ); + $displayName = 'Policy X'; + $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); + expect($subjectKey)->not->toBeNull(); + $workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'policy-x-uuid', + 'subject_external_id' => $workspaceSafeExternalId, + 'subject_key' => (string) $subjectKey, 'policy_type' => 'deviceConfiguration', 'baseline_hash' => $hasher->hashNormalized($baselineContract), - 'meta_jsonb' => ['display_name' => 'Policy X'], + 'meta_jsonb' => ['display_name' => $displayName], ]); InventoryItem::factory()->create([ @@ -366,7 +427,7 @@ 'external_id' => 'policy-x-uuid', 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_1'], - 'display_name' => 'Policy X modified', + 'display_name' => $displayName, 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), 'last_seen_at' => now(), ]); @@ -453,7 +514,7 @@ expect((string) ($finding->evidence_jsonb['current_hash'] ?? ''))->not->toBe($currentHash1); }); -it('creates new finding identities when a new snapshot is captured (snapshot-scoped recurrence)', function () { +it('does not create new finding identities when a new snapshot is captured', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); $profile = BaselineProfile::factory()->active()->create([ @@ -476,6 +537,11 @@ ); $baselineHash = $hasher->hashNormalized($baselineContract); + $displayName = 'Policy X'; + $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); + expect($subjectKey)->not->toBeNull(); + $workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey); + $snapshot1 = BaselineSnapshot::factory()->create([ 'workspace_id' => $tenant->workspace_id, 'baseline_profile_id' => $profile->getKey(), @@ -484,9 +550,11 @@ BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot1->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'policy-x-uuid', + 'subject_external_id' => $workspaceSafeExternalId, + 'subject_key' => (string) $subjectKey, 'policy_type' => 'deviceConfiguration', 'baseline_hash' => $baselineHash, + 'meta_jsonb' => ['display_name' => $displayName], ]); InventoryItem::factory()->create([ @@ -495,6 +563,7 @@ 'external_id' => 'policy-x-uuid', 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'], + 'display_name' => $displayName, 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), 'last_seen_at' => now(), ]); @@ -520,13 +589,14 @@ $scopeKey = 'baseline_profile:'.$profile->getKey(); - $fingerprint1 = (string) Finding::query() + $finding = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('source', 'baseline.compare') ->where('scope_key', $scopeKey) - ->orderBy('id') - ->firstOrFail() - ->fingerprint; + ->sole(); + + expect($finding->times_seen)->toBe(1); + $fingerprint1 = (string) $finding->fingerprint; $snapshot2 = BaselineSnapshot::factory()->create([ 'workspace_id' => $tenant->workspace_id, @@ -536,9 +606,11 @@ BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot2->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'policy-x-uuid', + 'subject_external_id' => $workspaceSafeExternalId, + 'subject_key' => (string) $subjectKey, 'policy_type' => 'deviceConfiguration', 'baseline_hash' => $baselineHash, + 'meta_jsonb' => ['display_name' => $displayName], ]); $run2 = $opService->ensureRunWithIdentity( @@ -566,9 +638,9 @@ ->orderBy('id') ->get(); - expect($findings)->toHaveCount(2); - expect($findings->pluck('fingerprint')->unique()->count())->toBe(2); - expect($findings->pluck('fingerprint')->all())->toContain($fingerprint1); + expect($findings)->toHaveCount(1); + expect((string) $findings->first()?->fingerprint)->toBe($fingerprint1); + expect((int) $findings->first()?->times_seen)->toBe(2); }); it('creates zero findings when baseline matches tenant inventory exactly', function () { @@ -607,13 +679,19 @@ metaJsonb: $metaContent, )); + $displayName = 'Matching Policy'; + $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); + expect($subjectKey)->not->toBeNull(); + $workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'matching-uuid', + 'subject_external_id' => $workspaceSafeExternalId, + 'subject_key' => (string) $subjectKey, 'policy_type' => 'deviceConfiguration', 'baseline_hash' => $contentHash, - 'meta_jsonb' => ['display_name' => 'Matching Policy'], + 'meta_jsonb' => ['display_name' => $displayName], ]); // Tenant inventory with same content → same hash @@ -623,7 +701,7 @@ 'external_id' => 'matching-uuid', 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => $metaContent, - 'display_name' => 'Matching Policy', + 'display_name' => $displayName, 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), 'last_seen_at' => now(), ]); @@ -698,22 +776,37 @@ metaJsonb: $metaContent, )); - BaselineSnapshotItem::factory()->create([ - 'baseline_snapshot_id' => $snapshot->getKey(), - 'subject_type' => 'policy', - 'subject_external_id' => 'matching-uuid', - 'policy_type' => 'deviceConfiguration', - 'baseline_hash' => $contentHash, - 'meta_jsonb' => ['display_name' => 'Matching Policy'], - ]); + $displayName = 'Matching Policy'; + $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); + expect($subjectKey)->not->toBeNull(); + $workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey); BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'foundation-uuid', + 'subject_external_id' => $workspaceSafeExternalId, + 'subject_key' => (string) $subjectKey, + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => $contentHash, + 'meta_jsonb' => ['display_name' => $displayName], + ]); + + $foundationDisplayName = 'Foundation Template'; + $foundationSubjectKey = BaselineSubjectKey::fromDisplayName($foundationDisplayName); + expect($foundationSubjectKey)->not->toBeNull(); + $foundationWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId( + 'notificationMessageTemplate', + (string) $foundationSubjectKey, + ); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => $foundationWorkspaceSafeExternalId, + 'subject_key' => (string) $foundationSubjectKey, 'policy_type' => 'notificationMessageTemplate', 'baseline_hash' => hash('sha256', 'foundation-content'), - 'meta_jsonb' => ['display_name' => 'Foundation Template'], + 'meta_jsonb' => ['display_name' => $foundationDisplayName], ]); InventoryItem::factory()->create([ @@ -722,7 +815,7 @@ 'external_id' => 'matching-uuid', 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => $metaContent, - 'display_name' => 'Matching Policy', + 'display_name' => $displayName, 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), 'last_seen_at' => now(), ]); @@ -733,7 +826,7 @@ 'external_id' => 'foundation-uuid', 'policy_type' => 'notificationMessageTemplate', 'meta_jsonb' => ['some' => 'value'], - 'display_name' => 'Foundation Template', + 'display_name' => $foundationDisplayName, 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), 'last_seen_at' => now(), ]); @@ -795,21 +888,34 @@ ); // 2 baseline items: one will be missing (high), one will be different (medium) + $missingDisplayName = 'Missing Policy'; + $missingSubjectKey = BaselineSubjectKey::fromDisplayName($missingDisplayName); + expect($missingSubjectKey)->not->toBeNull(); + $missingWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $missingSubjectKey); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'missing-uuid', + 'subject_external_id' => $missingWorkspaceSafeExternalId, + 'subject_key' => (string) $missingSubjectKey, 'policy_type' => 'deviceConfiguration', 'baseline_hash' => hash('sha256', 'missing-content'), - 'meta_jsonb' => ['display_name' => 'Missing Policy'], + 'meta_jsonb' => ['display_name' => $missingDisplayName], ]); + + $changedDisplayName = 'Changed Policy'; + $changedSubjectKey = BaselineSubjectKey::fromDisplayName($changedDisplayName); + expect($changedSubjectKey)->not->toBeNull(); + $changedWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $changedSubjectKey); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'changed-uuid', + 'subject_external_id' => $changedWorkspaceSafeExternalId, + 'subject_key' => (string) $changedSubjectKey, 'policy_type' => 'deviceConfiguration', 'baseline_hash' => hash('sha256', 'original-content'), - 'meta_jsonb' => ['display_name' => 'Changed Policy'], + 'meta_jsonb' => ['display_name' => $changedDisplayName], ]); // Tenant only has changed-uuid with different content + extra-uuid (unexpected) @@ -819,7 +925,7 @@ 'external_id' => 'changed-uuid', 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => ['modified_content' => true], - 'display_name' => 'Changed Policy', + 'display_name' => $changedDisplayName, 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), 'last_seen_at' => now(), ]); @@ -1126,21 +1232,34 @@ statusByType: ['deviceConfiguration' => 'succeeded'], ); + $missingDisplayName = 'Missing Policy'; + $missingSubjectKey = BaselineSubjectKey::fromDisplayName($missingDisplayName); + expect($missingSubjectKey)->not->toBeNull(); + $missingWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $missingSubjectKey); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'missing-policy', + 'subject_external_id' => $missingWorkspaceSafeExternalId, + 'subject_key' => (string) $missingSubjectKey, 'policy_type' => 'deviceConfiguration', 'baseline_hash' => hash('sha256', 'baseline-a'), - 'meta_jsonb' => ['display_name' => 'Missing Policy'], + 'meta_jsonb' => ['display_name' => $missingDisplayName], ]); + + $differentDisplayName = 'Different Policy'; + $differentSubjectKey = BaselineSubjectKey::fromDisplayName($differentDisplayName); + expect($differentSubjectKey)->not->toBeNull(); + $differentWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $differentSubjectKey); + BaselineSnapshotItem::factory()->create([ 'baseline_snapshot_id' => $snapshot->getKey(), 'subject_type' => 'policy', - 'subject_external_id' => 'different-policy', + 'subject_external_id' => $differentWorkspaceSafeExternalId, + 'subject_key' => (string) $differentSubjectKey, 'policy_type' => 'deviceConfiguration', 'baseline_hash' => hash('sha256', 'baseline-b'), - 'meta_jsonb' => ['display_name' => 'Different Policy'], + 'meta_jsonb' => ['display_name' => $differentDisplayName], ]); InventoryItem::factory()->create([ @@ -1149,7 +1268,7 @@ 'external_id' => 'different-policy', 'policy_type' => 'deviceConfiguration', 'meta_jsonb' => ['different_content' => true], - 'display_name' => 'Different Policy', + 'display_name' => $differentDisplayName, 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), 'last_seen_at' => now(), ]); -- 2.45.2 From e3a062c1a2ba5b74bdab06db738a223c79351b00 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 4 Mar 2026 23:22:16 +0100 Subject: [PATCH 3/3] feat(spec-118): resumable baseline evidence + snapshot UX --- ...eBaselineEvidencePolicyVersionsCommand.php | 48 +++ app/Filament/Pages/BaselineCompareLanding.php | 104 +++++++ .../TenantlessOperationRunViewer.php | 124 ++++++++ .../Resources/BaselineSnapshotResource.php | 275 ++++++++++++++++++ .../Pages/ListBaselineSnapshots.php | 18 ++ .../Pages/ViewBaselineSnapshot.php | 18 ++ .../Resources/OperationRunResource.php | 25 ++ .../PolicyResource/Pages/ListPolicies.php | 5 +- .../Resources/PolicyVersionResource.php | 22 +- app/Jobs/CaptureBaselineSnapshotJob.php | 47 ++- app/Jobs/CompareBaselineToTenantJob.php | 61 +++- app/Providers/Filament/AdminPanelProvider.php | 2 + .../Baselines/BaselineContentCapturePhase.php | 167 ++++++++--- .../BaselineEvidenceCaptureResumeService.php | 148 ++++++++++ .../Baselines/BaselineCompareReasonCode.php | 3 +- .../Baselines/BaselineCompareStats.php | 118 ++++++++ lang/en/baseline-compare.php | 81 ++++++ .../pages/baseline-compare-landing.blade.php | 140 ++++----- routes/console.php | 5 + specs/118-baseline-drift-engine/tasks.md | 44 +-- .../BaselineCaptureAmbiguousMatchGapTest.php | 104 +++++++ .../BaselineCompareResumeIdempotencyTest.php | 199 +++++++++++++ .../BaselineCompareResumeTokenTest.php | 172 +++++++++++ ...lineCompareWhyNoFindingsReasonCodeTest.php | 242 +++++++++++++++ ...aselineEvidenceResumeTokenContractTest.php | 31 ++ .../BaselineResumeCaptureAuditEventsTest.php | 60 ++++ ...CompareLandingDuplicateNamesBannerTest.php | 58 ++++ ...aselineCompareLandingWhyNoFindingsTest.php | 64 ++++ ...BaselineSnapshotFidelityVisibilityTest.php | 64 ++++ .../OperationRunResumeCaptureActionTest.php | 69 +++++ .../Filament/PolicySyncCtaPlacementTest.php | 6 +- ...yVersionBaselineEvidenceVisibilityTest.php | 98 +++++++ .../Spec118NoLegacyBaselineDriftGuardTest.php | 31 ++ ...runeBaselineEvidencePolicyVersionsTest.php | 68 +++++ ...lineEvidencePolicyVersionsScheduleTest.php | 16 + 35 files changed, 2581 insertions(+), 156 deletions(-) create mode 100644 app/Console/Commands/PruneBaselineEvidencePolicyVersionsCommand.php create mode 100644 app/Filament/Resources/BaselineSnapshotResource.php create mode 100644 app/Filament/Resources/BaselineSnapshotResource/Pages/ListBaselineSnapshots.php create mode 100644 app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php create mode 100644 app/Services/Baselines/BaselineEvidenceCaptureResumeService.php create mode 100644 lang/en/baseline-compare.php create mode 100644 tests/Feature/Baselines/BaselineCaptureAmbiguousMatchGapTest.php create mode 100644 tests/Feature/Baselines/BaselineCompareResumeIdempotencyTest.php create mode 100644 tests/Feature/Baselines/BaselineCompareResumeTokenTest.php create mode 100644 tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php create mode 100644 tests/Feature/Baselines/BaselineEvidenceResumeTokenContractTest.php create mode 100644 tests/Feature/Baselines/BaselineResumeCaptureAuditEventsTest.php create mode 100644 tests/Feature/Filament/BaselineCompareLandingDuplicateNamesBannerTest.php create mode 100644 tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php create mode 100644 tests/Feature/Filament/BaselineSnapshotFidelityVisibilityTest.php create mode 100644 tests/Feature/Filament/OperationRunResumeCaptureActionTest.php create mode 100644 tests/Feature/Filament/PolicyVersionBaselineEvidenceVisibilityTest.php create mode 100644 tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php create mode 100644 tests/Feature/Retention/PruneBaselineEvidencePolicyVersionsTest.php create mode 100644 tests/Feature/Scheduling/PruneBaselineEvidencePolicyVersionsScheduleTest.php diff --git a/app/Console/Commands/PruneBaselineEvidencePolicyVersionsCommand.php b/app/Console/Commands/PruneBaselineEvidencePolicyVersionsCommand.php new file mode 100644 index 0000000..0a3a58b --- /dev/null +++ b/app/Console/Commands/PruneBaselineEvidencePolicyVersionsCommand.php @@ -0,0 +1,48 @@ +option('days') ?: config('tenantpilot.baselines.full_content_capture.retention_days', 90)); + + if ($days < 1) { + $this->error('Retention days must be at least 1.'); + + return self::FAILURE; + } + + $cutoff = now()->subDays($days); + + $deleted = PolicyVersion::query() + ->whereNull('deleted_at') + ->whereIn('capture_purpose', [ + PolicyVersionCapturePurpose::BaselineCapture->value, + PolicyVersionCapturePurpose::BaselineCompare->value, + ]) + ->where('captured_at', '<', $cutoff) + ->delete(); + + $this->info("Pruned {$deleted} baseline evidence policy version(s) older than {$days} days."); + + return self::SUCCESS; + } +} diff --git a/app/Filament/Pages/BaselineCompareLanding.php b/app/Filament/Pages/BaselineCompareLanding.php index c2360e8..1c80b02 100644 --- a/app/Filament/Pages/BaselineCompareLanding.php +++ b/app/Filament/Pages/BaselineCompareLanding.php @@ -44,12 +44,18 @@ class BaselineCompareLanding extends Page public ?string $message = null; + public ?string $reasonCode = null; + + public ?string $reasonMessage = null; + public ?string $profileName = null; public ?int $profileId = null; public ?int $snapshotId = null; + public ?int $duplicateNamePoliciesCount = null; + public ?int $operationRunId = null; public ?int $findingsCount = null; @@ -110,12 +116,15 @@ public function refreshStats(): void $this->profileName = $stats->profileName; $this->profileId = $stats->profileId; $this->snapshotId = $stats->snapshotId; + $this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount; $this->operationRunId = $stats->operationRunId; $this->findingsCount = $stats->findingsCount; $this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null; $this->lastComparedAt = $stats->lastComparedHuman; $this->lastComparedIso = $stats->lastComparedIso; $this->failureReason = $stats->failureReason; + $this->reasonCode = $stats->reasonCode; + $this->reasonMessage = $stats->reasonMessage; $this->coverageStatus = $stats->coverageStatus; $this->uncoveredTypesCount = $stats->uncoveredTypesCount; @@ -126,6 +135,101 @@ public function refreshStats(): void $this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null; } + /** + * Computed view data exposed to the Blade template. + * + * Moves presentational logic out of Blade `@php` blocks so the + * template only receives ready-to-render values. + * + * @return array + */ + protected function getViewData(): array + { + $hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true); + $evidenceGapsCountValue = (int) ($this->evidenceGapsCount ?? 0); + $hasEvidenceGaps = $evidenceGapsCountValue > 0; + $hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps; + + $evidenceGapsSummary = null; + $evidenceGapsTooltip = null; + + if ($hasEvidenceGaps && is_array($this->evidenceGapsTopReasons) && $this->evidenceGapsTopReasons !== []) { + $parts = []; + + foreach (array_slice($this->evidenceGapsTopReasons, 0, 5, true) as $reason => $count) { + if (! is_string($reason) || $reason === '' || ! is_numeric($count)) { + continue; + } + + $parts[] = $reason.' ('.((int) $count).')'; + } + + if ($parts !== []) { + $evidenceGapsSummary = implode(', ', $parts); + $evidenceGapsTooltip = __('baseline-compare.evidence_gaps_tooltip', ['summary' => $evidenceGapsSummary]); + } + } + + // Derive the colour class for the findings-count stat card. + // Only show danger-red when high-severity findings exist; + // use warning-orange for low/medium-only, and success-green for zero. + $findingsColorClass = $this->resolveFindingsColorClass($hasWarnings); + + // "Why no findings" explanation when count is zero. + $whyNoFindingsMessage = filled($this->reasonMessage) ? (string) $this->reasonMessage : null; + $whyNoFindingsFallback = ! $hasWarnings + ? __('baseline-compare.no_findings_all_clear') + : ($hasCoverageWarnings + ? __('baseline-compare.no_findings_coverage_warnings') + : ($hasEvidenceGaps + ? __('baseline-compare.no_findings_evidence_gaps') + : __('baseline-compare.no_findings_default'))); + $whyNoFindingsColor = $hasWarnings + ? 'text-warning-600 dark:text-warning-400' + : 'text-success-600 dark:text-success-400'; + + if ($this->reasonCode === 'no_subjects_in_scope') { + $whyNoFindingsColor = 'text-gray-600 dark:text-gray-400'; + } + + return [ + 'hasCoverageWarnings' => $hasCoverageWarnings, + 'evidenceGapsCountValue' => $evidenceGapsCountValue, + 'hasEvidenceGaps' => $hasEvidenceGaps, + 'hasWarnings' => $hasWarnings, + 'evidenceGapsSummary' => $evidenceGapsSummary, + 'evidenceGapsTooltip' => $evidenceGapsTooltip, + 'findingsColorClass' => $findingsColorClass, + 'whyNoFindingsMessage' => $whyNoFindingsMessage, + 'whyNoFindingsFallback' => $whyNoFindingsFallback, + 'whyNoFindingsColor' => $whyNoFindingsColor, + ]; + } + + /** + * Resolve the Tailwind colour class for the Total Findings stat. + * + * - Red (danger) only when high-severity findings exist + * - Orange (warning) for medium/low-only findings or when warnings present + * - Green (success) when fully clear + */ + private function resolveFindingsColorClass(bool $hasWarnings): string + { + $count = (int) ($this->findingsCount ?? 0); + + if ($count === 0) { + return $hasWarnings + ? 'text-warning-600 dark:text-warning-400' + : 'text-success-600 dark:text-success-400'; + } + + $hasHigh = ($this->severityCounts['high'] ?? 0) > 0; + + return $hasHigh + ? 'text-danger-600 dark:text-danger-400' + : 'text-warning-600 dark:text-warning-400'; + } + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) diff --git a/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php b/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php index ce7c3aa..a083156 100644 --- a/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +++ b/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php @@ -9,10 +9,16 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Auth\CapabilityResolver; +use App\Services\Auth\WorkspaceCapabilityResolver; +use App\Services\Baselines\BaselineEvidenceCaptureResumeService; +use App\Support\Auth\Capabilities; use App\Support\OperateHub\OperateHubShell; use App\Support\OperationRunLinks; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; use Filament\Actions\Action; use Filament\Actions\ActionGroup; +use Filament\Notifications\Notification; use Filament\Pages\Page; use Filament\Schemas\Components\EmbeddedSchema; use Filament\Schemas\Schema; @@ -105,6 +111,8 @@ protected function getHeaderActions(): array ->color('gray'); } + $actions[] = $this->resumeCaptureAction(); + return $actions; } @@ -139,4 +147,120 @@ public function content(Schema $schema): Schema EmbeddedSchema::make('infolist'), ]); } + + private function resumeCaptureAction(): Action + { + return Action::make('resumeCapture') + ->label('Resume capture') + ->icon('heroicon-o-forward') + ->requiresConfirmation() + ->modalHeading('Resume capture') + ->modalDescription('This will start a follow-up operation to capture remaining baseline evidence for this scope.') + ->visible(fn (): bool => $this->canResumeCapture()) + ->action(function (): void { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + if (! isset($this->run)) { + Notification::make() + ->title('Run not loaded') + ->danger() + ->send(); + + return; + } + + $service = app(BaselineEvidenceCaptureResumeService::class); + $result = $service->resume($this->run, $user); + + if (! ($result['ok'] ?? false)) { + $reason = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown'; + + Notification::make() + ->title('Cannot resume capture') + ->body('Reason: '.str_replace('.', ' ', $reason)) + ->danger() + ->send(); + + return; + } + + $run = $result['run'] ?? null; + + if (! $run instanceof OperationRun) { + Notification::make() + ->title('Cannot resume capture') + ->body('Reason: missing operation run') + ->danger() + ->send(); + + return; + } + + $viewAction = Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::tenantlessView($run)); + + if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) { + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::alreadyQueuedToast((string) $run->type) + ->actions([$viewAction]) + ->send(); + + return; + } + + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::queuedToast((string) $run->type) + ->actions([$viewAction]) + ->send(); + }); + } + + private function canResumeCapture(): bool + { + if (! isset($this->run)) { + return false; + } + + if ((string) $this->run->status !== 'completed') { + return false; + } + + if (! in_array((string) $this->run->type, ['baseline_capture', 'baseline_compare'], true)) { + return false; + } + + $context = is_array($this->run->context) ? $this->run->context : []; + $tokenKey = (string) $this->run->type === 'baseline_capture' + ? 'baseline_capture.resume_token' + : 'baseline_compare.resume_token'; + $token = data_get($context, $tokenKey); + + if (! is_string($token) || trim($token) === '') { + return false; + } + + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $workspace = $this->run->workspace; + + if (! $workspace instanceof \App\Models\Workspace) { + return false; + } + + $resolver = app(WorkspaceCapabilityResolver::class); + + return $resolver->isMember($user, $workspace) + && $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE); + } } diff --git a/app/Filament/Resources/BaselineSnapshotResource.php b/app/Filament/Resources/BaselineSnapshotResource.php new file mode 100644 index 0000000..56f2e43 --- /dev/null +++ b/app/Filament/Resources/BaselineSnapshotResource.php @@ -0,0 +1,275 @@ +getId() !== 'admin') { + return false; + } + + return parent::shouldRegisterNavigation(); + } + + public static function canViewAny(): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $workspace = self::resolveWorkspace(); + + if (! $workspace instanceof Workspace) { + return false; + } + + $resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class); + + return $resolver->isMember($user, $workspace) + && $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW); + } + + public static function canCreate(): bool + { + return false; + } + + public static function canEdit(Model $record): bool + { + return false; + } + + public static function canDelete(Model $record): bool + { + return false; + } + + public static function canView(Model $record): bool + { + return self::canViewAny(); + } + + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) + ->exempt(ActionSurfaceSlot::ListHeader, 'Snapshots are created by capture runs; no list-header actions.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Snapshots are immutable; no row actions besides view.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Snapshots are immutable; no bulk actions.') + ->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; snapshots appear after baseline captures.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.'); + } + + public static function getEloquentQuery(): Builder + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + return parent::getEloquentQuery() + ->with('baselineProfile') + ->when( + $workspaceId !== null, + fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId), + ) + ->when( + $workspaceId === null, + fn (Builder $query): Builder => $query->whereRaw('1 = 0'), + ); + } + + public static function form(Schema $schema): Schema + { + return $schema; + } + + public static function table(Table $table): Table + { + return $table + ->defaultSort('captured_at', 'desc') + ->columns([ + TextColumn::make('id') + ->label('Snapshot') + ->formatStateUsing(static fn (?int $state): string => $state ? '#'.$state : '—') + ->sortable(), + TextColumn::make('baselineProfile.name') + ->label('Baseline') + ->wrap() + ->placeholder('—'), + TextColumn::make('captured_at') + ->label('Captured') + ->since() + ->sortable(), + TextColumn::make('fidelity_summary') + ->label('Fidelity') + ->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record)) + ->wrap(), + TextColumn::make('snapshot_state') + ->label('State') + ->badge() + ->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record)) + ->color(static fn (BaselineSnapshot $record): string => self::hasGaps($record) ? 'warning' : 'success'), + ]) + ->actions([ + ViewAction::make()->label('View'), + ]) + ->bulkActions([]); + } + + public static function infolist(Schema $schema): Schema + { + return $schema + ->schema([ + Section::make('Snapshot') + ->schema([ + TextEntry::make('id') + ->label('Snapshot') + ->formatStateUsing(static fn (?int $state): string => $state ? '#'.$state : '—'), + TextEntry::make('baselineProfile.name') + ->label('Baseline'), + TextEntry::make('captured_at') + ->label('Captured') + ->dateTime(), + TextEntry::make('snapshot_state') + ->label('State') + ->badge() + ->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record)) + ->color(static fn (BaselineSnapshot $record): string => self::hasGaps($record) ? 'warning' : 'success'), + TextEntry::make('fidelity_summary') + ->label('Fidelity') + ->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record)), + TextEntry::make('evidence_gaps') + ->label('Evidence gaps') + ->getStateUsing(static fn (BaselineSnapshot $record): int => self::gapsCount($record)), + TextEntry::make('snapshot_identity_hash') + ->label('Identity hash') + ->copyable() + ->columnSpanFull(), + ]) + ->columns(2) + ->columnSpanFull(), + Section::make('Summary') + ->schema([ + ViewEntry::make('summary_jsonb') + ->label('') + ->view('filament.infolists.entries.snapshot-json') + ->state(static fn (BaselineSnapshot $record): array => is_array($record->summary_jsonb) ? $record->summary_jsonb : []) + ->columnSpanFull(), + ]) + ->columnSpanFull(), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListBaselineSnapshots::route('/'), + 'view' => Pages\ViewBaselineSnapshot::route('/{record}'), + ]; + } + + private static function resolveWorkspace(): ?Workspace + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if ($workspaceId === null) { + return null; + } + + return Workspace::query()->whereKey($workspaceId)->first(); + } + + private static function summary(BaselineSnapshot $snapshot): array + { + return is_array($snapshot->summary_jsonb) ? $snapshot->summary_jsonb : []; + } + + private static function fidelityCounts(BaselineSnapshot $snapshot): array + { + $summary = self::summary($snapshot); + $counts = $summary['fidelity_counts'] ?? null; + $counts = is_array($counts) ? $counts : []; + + $content = $counts['content'] ?? 0; + $meta = $counts['meta'] ?? 0; + + return [ + 'content' => is_numeric($content) ? (int) $content : 0, + 'meta' => is_numeric($meta) ? (int) $meta : 0, + ]; + } + + private static function fidelitySummary(BaselineSnapshot $snapshot): string + { + $counts = self::fidelityCounts($snapshot); + + return sprintf('Content %d, Meta %d', (int) ($counts['content'] ?? 0), (int) ($counts['meta'] ?? 0)); + } + + private static function gapsCount(BaselineSnapshot $snapshot): int + { + $summary = self::summary($snapshot); + $gaps = $summary['gaps'] ?? null; + $gaps = is_array($gaps) ? $gaps : []; + + $count = $gaps['count'] ?? 0; + + return is_numeric($count) ? (int) $count : 0; + } + + private static function hasGaps(BaselineSnapshot $snapshot): bool + { + return self::gapsCount($snapshot) > 0; + } + + private static function stateLabel(BaselineSnapshot $snapshot): string + { + return self::hasGaps($snapshot) ? 'Captured with gaps' : 'Complete'; + } +} diff --git a/app/Filament/Resources/BaselineSnapshotResource/Pages/ListBaselineSnapshots.php b/app/Filament/Resources/BaselineSnapshotResource/Pages/ListBaselineSnapshots.php new file mode 100644 index 0000000..7efacb2 --- /dev/null +++ b/app/Filament/Resources/BaselineSnapshotResource/Pages/ListBaselineSnapshots.php @@ -0,0 +1,18 @@ + 'warning', default => 'gray', }), + TextEntry::make('baseline_compare_why_no_findings') + ->label('Why no findings') + ->getStateUsing(function (OperationRun $record): ?string { + $context = is_array($record->context) ? $record->context : []; + $code = data_get($context, 'baseline_compare.reason_code'); + $code = is_string($code) ? trim($code) : null; + $code = $code !== '' ? $code : null; + + if ($code === null) { + return null; + } + + $enum = BaselineCompareReasonCode::tryFrom($code); + $message = $enum?->message(); + + return ($message !== null ? $message.' (' : '').$code.($message !== null ? ')' : ''); + }) + ->visible(function (OperationRun $record): bool { + $context = is_array($record->context) ? $record->context : []; + $code = data_get($context, 'baseline_compare.reason_code'); + + return is_string($code) && trim($code) !== ''; + }) + ->columnSpanFull(), TextEntry::make('baseline_compare_uncovered_types') ->label('Uncovered types') ->getStateUsing(function (OperationRun $record): ?string { diff --git a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php index 4a09550..dc2b865 100644 --- a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php +++ b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php @@ -22,14 +22,13 @@ class ListPolicies extends ListRecords protected function getHeaderActions(): array { return [ - $this->makeSyncAction() - ->visible(fn (): bool => $this->getFilteredTableQuery()->exists()), + $this->makeSyncAction(), ]; } protected function getTableEmptyStateActions(): array { - return [$this->makeSyncAction('syncEmpty')]; + return [$this->makeSyncAction()]; } private function makeSyncAction(string $name = 'sync'): Actions\Action diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index 29091c9..7440bc0 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -20,6 +20,7 @@ use App\Support\Auth\Capabilities; use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeRenderer; +use App\Support\Baselines\PolicyVersionCapturePurpose; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\Rbac\UiEnforcement; @@ -825,10 +826,29 @@ public static function table(Table $table): Table public static function getEloquentQuery(): Builder { - $tenantId = Tenant::currentOrFail()->getKey(); + $tenant = Tenant::currentOrFail(); + $tenantId = $tenant->getKey(); + $user = auth()->user(); + + $resolver = app(CapabilityResolver::class); + $canSeeBaselinePurposeEvidence = $user instanceof User + && ( + $resolver->can($user, $tenant, Capabilities::TENANT_SYNC) + || $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW) + ); return parent::getEloquentQuery() ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) + ->when(! $canSeeBaselinePurposeEvidence, function (Builder $query): Builder { + return $query->where(function (Builder $query): void { + $query + ->whereNull('capture_purpose') + ->orWhereNotIn('capture_purpose', [ + PolicyVersionCapturePurpose::BaselineCapture->value, + PolicyVersionCapturePurpose::BaselineCompare->value, + ]); + }); + }) ->with('policy'); } diff --git a/app/Jobs/CaptureBaselineSnapshotJob.php b/app/Jobs/CaptureBaselineSnapshotJob.php index cb20f60..f8148f1 100644 --- a/app/Jobs/CaptureBaselineSnapshotJob.php +++ b/app/Jobs/CaptureBaselineSnapshotJob.php @@ -284,17 +284,29 @@ private function collectInventorySubjects( /** @var array $inventoryByKey */ $inventoryByKey = []; - $subjectsTotal = 0; /** @var array $gaps */ $gaps = []; + /** + * Ensure we only include unambiguous subjects when matching by subject_key (derived from display name). + * + * When multiple inventory items share the same "policy_type|subject_key" we cannot reliably map them + * across tenants, so we treat them as an evidence gap and exclude them from the snapshot. + * + * @var array $ambiguousKeys + */ + $ambiguousKeys = []; + + /** + * @var array $subjectKeyToInventoryKey + */ + $subjectKeyToInventoryKey = []; + $query->orderBy('policy_type') ->orderBy('external_id') - ->chunk(500, function ($inventoryItems) use (&$inventoryByKey, &$subjectsTotal, &$gaps): void { + ->chunk(500, function ($inventoryItems) use (&$inventoryByKey, &$gaps, &$ambiguousKeys, &$subjectKeyToInventoryKey): void { foreach ($inventoryItems as $inventoryItem) { - $subjectsTotal++; - $metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : []; $displayName = is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null; $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); @@ -305,18 +317,37 @@ private function collectInventorySubjects( continue; } + $policyType = (string) $inventoryItem->policy_type; + $logicalKey = $policyType.'|'.$subjectKey; + + if (array_key_exists($logicalKey, $ambiguousKeys)) { + continue; + } + + if (array_key_exists($logicalKey, $subjectKeyToInventoryKey)) { + $ambiguousKeys[$logicalKey] = true; + + $previousKey = $subjectKeyToInventoryKey[$logicalKey]; + unset($subjectKeyToInventoryKey[$logicalKey], $inventoryByKey[$previousKey]); + + $gaps['ambiguous_match'] = ($gaps['ambiguous_match'] ?? 0) + 1; + + continue; + } + $workspaceSafeId = BaselineSubjectKey::workspaceSafeSubjectExternalId( - policyType: (string) $inventoryItem->policy_type, + policyType: $policyType, subjectKey: $subjectKey, ); - $key = (string) $inventoryItem->policy_type.'|'.(string) $inventoryItem->external_id; + $key = $policyType.'|'.(string) $inventoryItem->external_id; + $subjectKeyToInventoryKey[$logicalKey] = $key; $inventoryByKey[$key] = [ 'tenant_subject_external_id' => (string) $inventoryItem->external_id, 'workspace_subject_external_id' => $workspaceSafeId, 'subject_key' => $subjectKey, - 'policy_type' => (string) $inventoryItem->policy_type, + 'policy_type' => $policyType, 'display_name' => $displayName, 'category' => is_string($inventoryItem->category) ? $inventoryItem->category : null, 'platform' => is_string($inventoryItem->platform) ? $inventoryItem->platform : null, @@ -335,7 +366,7 @@ private function collectInventorySubjects( )); return [ - 'subjects_total' => $subjectsTotal, + 'subjects_total' => count($subjects), 'subjects' => $subjects, 'inventory_by_key' => $inventoryByKey, 'gaps' => $gaps, diff --git a/app/Jobs/CompareBaselineToTenantJob.php b/app/Jobs/CompareBaselineToTenantJob.php index 5fc096c..e903bf1 100644 --- a/app/Jobs/CompareBaselineToTenantJob.php +++ b/app/Jobs/CompareBaselineToTenantJob.php @@ -25,6 +25,7 @@ use App\Services\OperationRunService; use App\Services\Settings\SettingsResolver; use App\Support\Baselines\BaselineCaptureMode; +use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\Baselines\BaselineFullContentRolloutGate; use App\Support\Baselines\BaselineScope; use App\Support\Baselines\BaselineSubjectKey; @@ -120,7 +121,43 @@ public function handle( : BaselineCaptureMode::Opportunistic; if ($captureMode === BaselineCaptureMode::FullContent) { - $rolloutGate->assertEnabled(); + try { + $rolloutGate->assertEnabled(); + } catch (RuntimeException) { + $this->auditStarted( + auditLogger: $auditLogger, + tenant: $tenant, + profile: $profile, + initiator: $initiator, + captureMode: $captureMode, + subjectsTotal: 0, + effectiveScope: $effectiveScope, + ); + + $effectiveTypeCount = count($effectiveTypes); + $gapCount = max(1, $effectiveTypeCount); + + $this->completeWithCoverageWarning( + operationRunService: $operationRunService, + auditLogger: $auditLogger, + tenant: $tenant, + profile: $profile, + initiator: $initiator, + inventorySyncRun: null, + coverageProof: false, + effectiveTypes: $effectiveTypes, + coveredTypes: [], + uncoveredTypes: $effectiveTypes, + errorsRecorded: $gapCount, + captureMode: $captureMode, + reasonCode: BaselineCompareReasonCode::RolloutDisabled, + evidenceGapsByReason: [ + BaselineCompareReasonCode::RolloutDisabled->value => $gapCount, + ], + ); + + return; + } } if ($effectiveTypes === []) { @@ -147,6 +184,8 @@ public function handle( uncoveredTypes: [], errorsRecorded: 1, captureMode: $captureMode, + reasonCode: BaselineCompareReasonCode::NoSubjectsInScope, + evidenceGapsByReason: [], ); return; @@ -414,6 +453,18 @@ public function handle( ? EvidenceProvenance::FidelityMeta : EvidenceProvenance::FidelityContent; + $reasonCode = null; + + if ($subjectsTotal === 0) { + $reasonCode = BaselineCompareReasonCode::NoSubjectsInScope; + } elseif (count($driftResults) === 0) { + $reasonCode = match (true) { + $uncoveredTypes !== [] => BaselineCompareReasonCode::CoverageUnproven, + $resumeToken !== null || $gapsCount > 0 => BaselineCompareReasonCode::EvidenceCaptureIncomplete, + default => BaselineCompareReasonCode::NoDriftDetected, + }; + } + $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; $updatedContext['baseline_compare'] = array_merge( is_array($updatedContext['baseline_compare'] ?? null) ? $updatedContext['baseline_compare'] : [], @@ -437,6 +488,7 @@ public function handle( ...$baselineCoverage, ], 'fidelity' => $overallFidelity, + 'reason_code' => $reasonCode?->value, ], ); $updatedContext['findings'] = array_merge( @@ -568,6 +620,8 @@ private function completeWithCoverageWarning( array $uncoveredTypes, int $errorsRecorded, BaselineCaptureMode $captureMode, + BaselineCompareReasonCode $reasonCode = BaselineCompareReasonCode::CoverageUnproven, + ?array $evidenceGapsByReason = null, ): void { $summaryCounts = [ 'total' => 0, @@ -599,8 +653,8 @@ private function completeWithCoverageWarning( 'throttled' => 0, ]; - $evidenceGapsByReason = [ - 'coverage_unproven' => max(1, $errorsRecorded), + $evidenceGapsByReason ??= [ + BaselineCompareReasonCode::CoverageUnproven->value => max(1, $errorsRecorded), ]; $updatedContext['baseline_compare'] = array_merge( @@ -615,6 +669,7 @@ private function completeWithCoverageWarning( ...$evidenceGapsByReason, ], 'resume_token' => null, + 'reason_code' => $reasonCode->value, 'coverage' => [ 'effective_types' => array_values($effectiveTypes), 'covered_types' => array_values($coveredTypes), diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 2682577..a769ec5 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -13,6 +13,7 @@ use App\Filament\Resources\AlertDestinationResource; use App\Filament\Resources\AlertRuleResource; use App\Filament\Resources\BaselineProfileResource; +use App\Filament\Resources\BaselineSnapshotResource; use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\PolicyResource; use App\Filament\Resources\ProviderConnectionResource; @@ -179,6 +180,7 @@ public function panel(Panel $panel): Panel AlertDeliveryResource::class, WorkspaceResource::class, BaselineProfileResource::class, + BaselineSnapshotResource::class, ]) ->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters') ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') diff --git a/app/Services/Baselines/BaselineContentCapturePhase.php b/app/Services/Baselines/BaselineContentCapturePhase.php index 9837377..871ddfe 100644 --- a/app/Services/Baselines/BaselineContentCapturePhase.php +++ b/app/Services/Baselines/BaselineContentCapturePhase.php @@ -9,6 +9,7 @@ use App\Services\Intune\PolicyCaptureOrchestrator; use App\Support\Baselines\BaselineEvidenceResumeToken; use App\Support\Baselines\PolicyVersionCapturePurpose; +use Throwable; final class BaselineContentCapturePhase { @@ -37,7 +38,11 @@ public function capture( ?int $baselineProfileId = null, ?string $createdBy = null, ): array { + $subjects = array_values($subjects); + $maxItemsPerRun = max(0, (int) ($budgets['max_items_per_run'] ?? 0)); + $maxConcurrency = max(1, (int) ($budgets['max_concurrency'] ?? 1)); + $maxRetries = max(0, (int) ($budgets['max_retries'] ?? 0)); $offset = 0; @@ -46,6 +51,10 @@ public function capture( $offset = is_numeric($state['offset'] ?? null) ? max(0, (int) $state['offset']) : 0; } + if ($offset >= count($subjects)) { + $offset = 0; + } + $remaining = array_slice($subjects, $offset); $batch = $maxItemsPerRun > 0 ? array_slice($remaining, 0, $maxItemsPerRun) : []; @@ -60,52 +69,104 @@ public function capture( /** @var array $gaps */ $gaps = []; - foreach ($batch as $subject) { - $policyType = trim((string) ($subject['policy_type'] ?? '')); - $externalId = trim((string) ($subject['subject_external_id'] ?? '')); + /** + * @var array $seen + */ + $seen = []; - if ($policyType === '' || $externalId === '') { - $gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1; - $stats['failed']++; + foreach (array_chunk($batch, $maxConcurrency) as $chunk) { + foreach ($chunk as $subject) { + $policyType = trim((string) ($subject['policy_type'] ?? '')); + $externalId = trim((string) ($subject['subject_external_id'] ?? '')); - continue; + if ($policyType === '' || $externalId === '') { + $gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1; + $stats['failed']++; + + continue; + } + + $subjectKey = $policyType.'|'.$externalId; + + if (isset($seen[$subjectKey])) { + $gaps['duplicate_subject'] = ($gaps['duplicate_subject'] ?? 0) + 1; + $stats['skipped']++; + + continue; + } + + $seen[$subjectKey] = true; + + $policy = Policy::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('policy_type', $policyType) + ->where('external_id', $externalId) + ->first(); + + if (! $policy instanceof Policy) { + $gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1; + $stats['failed']++; + + continue; + } + + $attempt = 0; + $result = null; + + while (true) { + try { + $result = $this->captureOrchestrator->capture( + policy: $policy, + tenant: $tenant, + includeAssignments: true, + includeScopeTags: true, + createdBy: $createdBy, + metadata: [ + 'capture_source' => 'baseline_evidence', + ], + capturePurpose: $purpose, + operationRunId: $operationRunId, + baselineProfileId: $baselineProfileId, + ); + } catch (Throwable $throwable) { + $result = [ + 'failure' => [ + 'reason' => $throwable->getMessage(), + 'status' => is_numeric($throwable->getCode()) ? (int) $throwable->getCode() : null, + ], + ]; + } + + if (! (is_array($result) && array_key_exists('failure', $result))) { + $stats['succeeded']++; + + break; + } + + $failure = is_array($result['failure'] ?? null) ? $result['failure'] : []; + $status = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null; + + $isThrottled = in_array($status, [429, 503], true); + + if ($isThrottled && $attempt < $maxRetries) { + $delayMs = $this->retryDelayMs($attempt); + usleep($delayMs * 1000); + $attempt++; + + continue; + } + + if ($isThrottled) { + $gaps['throttled'] = ($gaps['throttled'] ?? 0) + 1; + $stats['throttled']++; + } else { + $gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1; + $stats['failed']++; + } + + break; + } } - - $policy = Policy::query() - ->where('tenant_id', (int) $tenant->getKey()) - ->where('policy_type', $policyType) - ->where('external_id', $externalId) - ->first(); - - if (! $policy instanceof Policy) { - $gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1; - $stats['failed']++; - - continue; - } - - $result = $this->captureOrchestrator->capture( - policy: $policy, - tenant: $tenant, - includeAssignments: true, - includeScopeTags: true, - createdBy: $createdBy, - metadata: [ - 'capture_source' => 'baseline_evidence', - ], - capturePurpose: $purpose, - operationRunId: $operationRunId, - baselineProfileId: $baselineProfileId, - ); - - if (is_array($result) && array_key_exists('failure', $result)) { - $gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1; - $stats['failed']++; - - continue; - } - - $stats['succeeded']++; } $processed = $offset + count($batch); @@ -114,7 +175,13 @@ public function capture( if ($processed < count($subjects)) { $resumeTokenOut = BaselineEvidenceResumeToken::encode([ 'offset' => $processed, + 'total' => count($subjects), ]); + + $remainingCount = max(0, count($subjects) - $processed); + if ($remainingCount > 0) { + $gaps['budget_exhausted'] = ($gaps['budget_exhausted'] ?? 0) + $remainingCount; + } } ksort($gaps); @@ -125,5 +192,17 @@ public function capture( 'resume_token' => $resumeTokenOut, ]; } -} + private function retryDelayMs(int $attempt): int + { + $attempt = max(0, $attempt); + + $baseDelayMs = 500; + $maxDelayMs = 30_000; + + $delayMs = (int) min($maxDelayMs, $baseDelayMs * (2 ** $attempt)); + $jitterMs = random_int(0, 250); + + return $delayMs + $jitterMs; + } +} diff --git a/app/Services/Baselines/BaselineEvidenceCaptureResumeService.php b/app/Services/Baselines/BaselineEvidenceCaptureResumeService.php new file mode 100644 index 0000000..1bf9550 --- /dev/null +++ b/app/Services/Baselines/BaselineEvidenceCaptureResumeService.php @@ -0,0 +1,148 @@ +type); + + if (! in_array($runType, [OperationRunType::BaselineCapture->value, OperationRunType::BaselineCompare->value], true)) { + return ['ok' => false, 'reason_code' => 'baseline.resume.unsupported_run_type']; + } + + if ($priorRun->status !== OperationRunStatus::Completed->value) { + return ['ok' => false, 'reason_code' => 'baseline.resume.run_not_completed']; + } + + $tenantId = (int) ($priorRun->tenant_id ?? 0); + + if ($tenantId <= 0) { + return ['ok' => false, 'reason_code' => 'baseline.resume.missing_tenant']; + } + + $tenant = Tenant::query()->whereKey($tenantId)->first(); + + if (! $tenant instanceof Tenant) { + return ['ok' => false, 'reason_code' => 'baseline.resume.tenant_not_found']; + } + + $workspaceId = (int) ($tenant->workspace_id ?? 0); + + if ($workspaceId <= 0) { + return ['ok' => false, 'reason_code' => 'baseline.resume.missing_workspace']; + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return ['ok' => false, 'reason_code' => 'baseline.resume.workspace_not_found']; + } + + if (! $this->workspaceCapabilities->isMember($initiator, $workspace)) { + return ['ok' => false, 'reason_code' => 'baseline.resume.not_workspace_member']; + } + + if (! $this->workspaceCapabilities->can($initiator, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) { + return ['ok' => false, 'reason_code' => 'baseline.resume.forbidden']; + } + + $this->rolloutGate->assertEnabled(); + + $context = is_array($priorRun->context) ? $priorRun->context : []; + $profileId = (int) ($context['baseline_profile_id'] ?? 0); + + if ($profileId <= 0) { + return ['ok' => false, 'reason_code' => 'baseline.resume.missing_profile']; + } + + $resumeSection = $runType === OperationRunType::BaselineCapture->value ? 'baseline_capture' : 'baseline_compare'; + $resumeToken = data_get($context, "{$resumeSection}.resume_token"); + + if (! is_string($resumeToken) || trim($resumeToken) === '') { + return ['ok' => false, 'reason_code' => 'baseline.resume.missing_resume_token']; + } + + $newContext = []; + + foreach (['target_scope', 'baseline_profile_id', 'baseline_snapshot_id', 'source_tenant_id', 'effective_scope', 'capture_mode'] as $key) { + if (array_key_exists($key, $context)) { + $newContext[$key] = $context[$key]; + } + } + + $newContext['resume_from_operation_run_id'] = (int) $priorRun->getKey(); + + $newContext[$resumeSection] = [ + 'resume_token' => $resumeToken, + 'resume_from_operation_run_id' => (int) $priorRun->getKey(), + ]; + + $run = $this->runs->ensureRunWithIdentity( + tenant: $tenant, + type: $runType, + identityInputs: [ + 'baseline_profile_id' => $profileId, + ], + context: $newContext, + initiator: $initiator, + ); + + if ($run->wasRecentlyCreated) { + match ($runType) { + OperationRunType::BaselineCapture->value => CaptureBaselineSnapshotJob::dispatch($run), + OperationRunType::BaselineCompare->value => CompareBaselineToTenantJob::dispatch($run), + default => null, + }; + } + + $this->auditLogger->log( + tenant: $tenant, + action: 'baseline.evidence.resume.started', + context: [ + 'metadata' => [ + 'prior_operation_run_id' => (int) $priorRun->getKey(), + 'operation_run_id' => (int) $run->getKey(), + 'baseline_profile_id' => $profileId, + 'run_type' => $runType, + ], + ], + actorId: (int) $initiator->getKey(), + actorEmail: (string) $initiator->email, + actorName: (string) $initiator->name, + resourceType: 'operation_run', + resourceId: (string) $priorRun->getKey(), + ); + + return ['ok' => true, 'run' => $run]; + } +} diff --git a/app/Support/Baselines/BaselineCompareReasonCode.php b/app/Support/Baselines/BaselineCompareReasonCode.php index 1855ac0..fbbbef5 100644 --- a/app/Support/Baselines/BaselineCompareReasonCode.php +++ b/app/Support/Baselines/BaselineCompareReasonCode.php @@ -16,11 +16,10 @@ public function message(): string { return match ($this) { self::NoSubjectsInScope => 'No subjects were in scope for this comparison.', - self::CoverageUnproven => 'Coverage proof was not available, so missing-policy outcomes were suppressed.', + self::CoverageUnproven => 'Coverage proof was missing or incomplete, so some findings were suppressed for safety.', self::EvidenceCaptureIncomplete => 'Evidence capture was incomplete, so some drift evaluation may have been suppressed.', self::RolloutDisabled => 'Full-content baseline compare is currently disabled by rollout configuration.', self::NoDriftDetected => 'No drift was detected for in-scope subjects.', }; } } - diff --git a/app/Support/Baselines/BaselineCompareStats.php b/app/Support/Baselines/BaselineCompareStats.php index 3cbf473..dc9d1c9 100644 --- a/app/Support/Baselines/BaselineCompareStats.php +++ b/app/Support/Baselines/BaselineCompareStats.php @@ -7,8 +7,10 @@ use App\Models\BaselineProfile; use App\Models\BaselineTenantAssignment; use App\Models\Finding; +use App\Models\InventoryItem; use App\Models\OperationRun; use App\Models\Tenant; +use Illuminate\Support\Facades\Cache; final class BaselineCompareStats { @@ -23,12 +25,15 @@ private function __construct( public readonly ?string $profileName, public readonly ?int $profileId, public readonly ?int $snapshotId, + public readonly ?int $duplicateNamePoliciesCount, public readonly ?int $operationRunId, public readonly ?int $findingsCount, public readonly array $severityCounts, public readonly ?string $lastComparedHuman, public readonly ?string $lastComparedIso, public readonly ?string $failureReason, + public readonly ?string $reasonCode = null, + public readonly ?string $reasonMessage = null, public readonly ?string $coverageStatus = null, public readonly ?int $uncoveredTypesCount = null, public readonly array $uncoveredTypes = [], @@ -67,12 +72,23 @@ public static function forTenant(?Tenant $tenant): self $profileId = (int) $profile->getKey(); $snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null; + $profileScope = BaselineScope::fromJsonb( + is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null, + ); + $overrideScope = $assignment->override_scope_jsonb !== null + ? BaselineScope::fromJsonb(is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null) + : null; + $effectiveScope = BaselineScope::effective($profileScope, $overrideScope); + + $duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope); + if ($snapshotId === null) { return self::empty( 'no_snapshot', 'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.', profileName: $profileName, profileId: $profileId, + duplicateNamePoliciesCount: $duplicateNamePoliciesCount, ); } @@ -84,6 +100,7 @@ public static function forTenant(?Tenant $tenant): self [$coverageStatus, $uncoveredTypes, $fidelity] = self::coverageInfoForRun($latestRun); [$evidenceGapsCount, $evidenceGapsTopReasons] = self::evidenceGapSummaryForRun($latestRun); + [$reasonCode, $reasonMessage] = self::reasonInfoForRun($latestRun); // Active run (queued/running) if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) { @@ -93,12 +110,15 @@ public static function forTenant(?Tenant $tenant): self profileName: $profileName, profileId: $profileId, snapshotId: $snapshotId, + duplicateNamePoliciesCount: $duplicateNamePoliciesCount, operationRunId: (int) $latestRun->getKey(), findingsCount: null, severityCounts: [], lastComparedHuman: null, lastComparedIso: null, failureReason: null, + reasonCode: $reasonCode, + reasonMessage: $reasonMessage, coverageStatus: $coverageStatus, uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypes: $uncoveredTypes, @@ -121,12 +141,15 @@ public static function forTenant(?Tenant $tenant): self profileName: $profileName, profileId: $profileId, snapshotId: $snapshotId, + duplicateNamePoliciesCount: $duplicateNamePoliciesCount, operationRunId: (int) $latestRun->getKey(), findingsCount: null, severityCounts: [], lastComparedHuman: $latestRun->finished_at?->diffForHumans(), lastComparedIso: $latestRun->finished_at?->toIso8601String(), failureReason: (string) $failureReason, + reasonCode: $reasonCode, + reasonMessage: $reasonMessage, coverageStatus: $coverageStatus, uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypes: $uncoveredTypes, @@ -171,12 +194,15 @@ public static function forTenant(?Tenant $tenant): self profileName: $profileName, profileId: $profileId, snapshotId: $snapshotId, + duplicateNamePoliciesCount: $duplicateNamePoliciesCount, operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null, findingsCount: $totalFindings, severityCounts: $severityCounts, lastComparedHuman: $lastComparedHuman, lastComparedIso: $lastComparedIso, failureReason: null, + reasonCode: $reasonCode, + reasonMessage: $reasonMessage, coverageStatus: $coverageStatus, uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypes: $uncoveredTypes, @@ -195,12 +221,15 @@ public static function forTenant(?Tenant $tenant): self profileName: $profileName, profileId: $profileId, snapshotId: $snapshotId, + duplicateNamePoliciesCount: $duplicateNamePoliciesCount, operationRunId: (int) $latestRun->getKey(), findingsCount: 0, severityCounts: $severityCounts, lastComparedHuman: $lastComparedHuman, lastComparedIso: $lastComparedIso, failureReason: null, + reasonCode: $reasonCode, + reasonMessage: $reasonMessage, coverageStatus: $coverageStatus, uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypes: $uncoveredTypes, @@ -216,12 +245,15 @@ public static function forTenant(?Tenant $tenant): self profileName: $profileName, profileId: $profileId, snapshotId: $snapshotId, + duplicateNamePoliciesCount: $duplicateNamePoliciesCount, operationRunId: null, findingsCount: null, severityCounts: $severityCounts, lastComparedHuman: $lastComparedHuman, lastComparedIso: $lastComparedIso, failureReason: null, + reasonCode: $reasonCode, + reasonMessage: $reasonMessage, coverageStatus: $coverageStatus, uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypes: $uncoveredTypes, @@ -278,6 +310,7 @@ public static function forWidget(?Tenant $tenant): self profileName: (string) $profile->name, profileId: (int) $profile->getKey(), snapshotId: $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null, + duplicateNamePoliciesCount: null, operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null, findingsCount: $totalFindings, severityCounts: [ @@ -291,6 +324,64 @@ public static function forWidget(?Tenant $tenant): self ); } + private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope $effectiveScope): int + { + $policyTypes = $effectiveScope->allTypes(); + + if ($policyTypes === []) { + return 0; + } + + $compute = static function () use ($tenant, $policyTypes): int { + /** + * @var array $countsByKey + */ + $countsByKey = []; + + InventoryItem::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->whereIn('policy_type', $policyTypes) + ->whereNotNull('display_name') + ->select(['id', 'policy_type', 'display_name']) + ->orderBy('id') + ->chunkById(1_000, function ($inventoryItems) use (&$countsByKey): void { + foreach ($inventoryItems as $inventoryItem) { + $displayName = is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null; + $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); + + if ($subjectKey === null) { + continue; + } + + $logicalKey = (string) $inventoryItem->policy_type.'|'.$subjectKey; + $countsByKey[$logicalKey] = ($countsByKey[$logicalKey] ?? 0) + 1; + } + }); + + $duplicatePolicies = 0; + + foreach ($countsByKey as $count) { + if ($count > 1) { + $duplicatePolicies += $count; + } + } + + return $duplicatePolicies; + }; + + if (app()->environment('testing')) { + return $compute(); + } + + $cacheKey = sprintf( + 'baseline_compare:tenant:%d:duplicate_names:%s', + (int) $tenant->getKey(), + hash('sha256', implode('|', $policyTypes)), + ); + + return (int) Cache::remember($cacheKey, now()->addSeconds(60), $compute); + } + /** * @return array{0: ?string, 1: list, 2: ?string} */ @@ -335,6 +426,31 @@ private static function coverageInfoForRun(?OperationRun $run): array return [$coverageStatus, $uncoveredTypes, $fidelity]; } + /** + * @return array{0: ?string, 1: ?string} + */ + private static function reasonInfoForRun(?OperationRun $run): array + { + if (! $run instanceof OperationRun) { + return [null, null]; + } + + $context = is_array($run->context) ? $run->context : []; + $baselineCompare = $context['baseline_compare'] ?? null; + + if (! is_array($baselineCompare)) { + return [null, null]; + } + + $reasonCode = $baselineCompare['reason_code'] ?? null; + $reasonCode = is_string($reasonCode) ? trim($reasonCode) : null; + $reasonCode = $reasonCode !== '' ? $reasonCode : null; + + $enum = $reasonCode !== null ? BaselineCompareReasonCode::tryFrom($reasonCode) : null; + + return [$reasonCode, $enum?->message()]; + } + /** * @return array{0: ?int, 1: array} */ @@ -393,6 +509,7 @@ private static function empty( ?string $message, ?string $profileName = null, ?int $profileId = null, + ?int $duplicateNamePoliciesCount = null, ): self { return new self( state: $state, @@ -400,6 +517,7 @@ private static function empty( profileName: $profileName, profileId: $profileId, snapshotId: null, + duplicateNamePoliciesCount: $duplicateNamePoliciesCount, operationRunId: null, findingsCount: null, severityCounts: [], diff --git a/lang/en/baseline-compare.php b/lang/en/baseline-compare.php new file mode 100644 index 0000000..4c16cd9 --- /dev/null +++ b/lang/en/baseline-compare.php @@ -0,0 +1,81 @@ + 'Warning', + 'duplicate_warning_body_plural' => ':count policies in this tenant share the same display name. :app cannot match them to the baseline. Please rename the duplicates in the Microsoft Intune portal.', + 'duplicate_warning_body_singular' => ':count policy in this tenant shares the same display name. :app cannot match it to the baseline. Please rename the duplicate in the Microsoft Intune portal.', + + // Stats card labels + 'stat_assigned_baseline' => 'Assigned Baseline', + 'stat_total_findings' => 'Total Findings', + 'stat_last_compared' => 'Last Compared', + 'stat_last_compared_never' => 'Never', + 'stat_error' => 'Error', + + // Badges + 'badge_snapshot' => 'Snapshot #:id', + 'badge_coverage_ok' => 'Coverage: OK', + 'badge_coverage_warnings' => 'Coverage: Warnings', + 'badge_fidelity' => 'Fidelity: :level', + 'badge_evidence_gaps' => 'Evidence gaps: :count', + 'evidence_gaps_tooltip' => 'Top gaps: :summary', + + // Comparing state + 'comparing_indicator' => 'Comparing…', + + // Why-no-findings explanations + 'no_findings_all_clear' => 'All clear', + 'no_findings_coverage_warnings' => 'Coverage warnings', + 'no_findings_evidence_gaps' => 'Evidence gaps', + 'no_findings_default' => 'No findings', + + // Coverage warning banner + 'coverage_warning_title' => 'Comparison completed with warnings', + 'coverage_unproven_body' => 'Coverage proof was missing or unreadable for the last comparison run, so findings were suppressed for safety.', + 'coverage_incomplete_body' => 'Findings were skipped for :count policy :types due to incomplete coverage.', + 'coverage_uncovered_label' => 'Uncovered: :list', + + // Failed banner + 'failed_title' => 'Comparison Failed', + 'failed_body_default' => 'The last baseline comparison failed. Review the run details or retry.', + + // Critical drift banner + 'critical_drift_title' => 'Critical Drift Detected', + 'critical_drift_body' => 'The current tenant state deviates from baseline :profile. :count high-severity :findings require immediate attention.', + + // Empty states + 'empty_no_tenant' => 'No Tenant Selected', + 'empty_no_assignment' => 'No Baseline Assigned', + 'empty_no_snapshot' => 'No Snapshot Available', + + // Findings section + 'findings_description' => 'The tenant configuration drifted from the baseline profile.', + + // No drift + 'no_drift_title' => 'No Drift Detected', + 'no_drift_body' => 'The tenant configuration matches the baseline profile. Everything looks good.', + + // Coverage warnings (no findings) + 'coverage_warnings_title' => 'Coverage Warnings', + 'coverage_warnings_body' => 'The last comparison completed with warnings and produced no drift findings. Run Inventory Sync again to establish full coverage before interpreting results.', + + // Idle + 'idle_title' => 'Ready to Compare', + + // Buttons + 'button_view_run' => 'View run', + 'button_view_failed_run' => 'View failed run', + 'button_view_findings' => 'View all findings', + 'button_review_last_run' => 'Review last run', + +]; diff --git a/resources/views/filament/pages/baseline-compare-landing.blade.php b/resources/views/filament/pages/baseline-compare-landing.blade.php index 27458ee..86525d7 100644 --- a/resources/views/filament/pages/baseline-compare-landing.blade.php +++ b/resources/views/filament/pages/baseline-compare-landing.blade.php @@ -5,44 +5,40 @@ @endif @php - $hasCoverageWarnings = in_array(($coverageStatus ?? null), ['warning', 'unproven'], true); - $evidenceGapsCountValue = (int) ($evidenceGapsCount ?? 0); - $hasEvidenceGaps = $evidenceGapsCountValue > 0; - $hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps; - - $evidenceGapsSummary = null; - $evidenceGapsTooltip = null; - - if ($hasEvidenceGaps && is_array($evidenceGapsTopReasons ?? null) && $evidenceGapsTopReasons !== []) { - $parts = []; - - foreach (array_slice($evidenceGapsTopReasons, 0, 5, true) as $reason => $count) { - if (! is_string($reason) || $reason === '' || ! is_numeric($count)) { - continue; - } - - $parts[] = $reason.' ('.((int) $count).')'; - } - - if ($parts !== []) { - $evidenceGapsSummary = implode(', ', $parts); - $evidenceGapsTooltip = 'Top gaps: '.$evidenceGapsSummary; - } - } + $duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0); @endphp + @if ($duplicateNamePoliciesCountValue > 0) + + @endif + {{-- Row 1: Stats Overview --}} @if (in_array($state, ['ready', 'idle', 'comparing', 'failed']))
{{-- Stat: Assigned Baseline --}}
-
Assigned Baseline
+
{{ __('baseline-compare.stat_assigned_baseline') }}
{{ $profileName ?? '—' }}
@if ($snapshotId) - Snapshot #{{ $snapshotId }} + {{ __('baseline-compare.badge_snapshot', ['id' => $snapshotId]) }} @endif @@ -52,26 +48,26 @@ size="sm" class="w-fit" > - Coverage: {{ $coverageStatus === 'ok' ? 'OK' : 'Warnings' }} + {{ $coverageStatus === 'ok' ? __('baseline-compare.badge_coverage_ok') : __('baseline-compare.badge_coverage_warnings') }} @endif @if (filled($fidelity)) - Fidelity: {{ Str::title($fidelity) }} + {{ __('baseline-compare.badge_fidelity', ['level' => Str::title($fidelity)]) }} @endif @if ($hasEvidenceGaps) - Evidence gaps: {{ $evidenceGapsCountValue }} + {{ __('baseline-compare.badge_evidence_gaps', ['count' => $evidenceGapsCountValue]) }} @endif
@if ($hasEvidenceGaps && filled($evidenceGapsSummary))
- Top gaps: {{ $evidenceGapsSummary }} + {{ __('baseline-compare.evidence_gaps_tooltip', ['summary' => $evidenceGapsSummary]) }}
@endif
@@ -80,25 +76,21 @@ class="w-fit" {{-- Stat: Total Findings --}}
-
Total Findings
+
{{ __('baseline-compare.stat_total_findings') }}
@if ($state === 'failed') -
Error
+
{{ __('baseline-compare.stat_error') }}
@else -
+
{{ $findingsCount ?? 0 }}
@endif @if ($state === 'comparing')
- Comparing… + {{ __('baseline-compare.comparing_indicator') }}
- @elseif (($findingsCount ?? 0) === 0 && $state === 'ready' && ! $hasWarnings) - All clear - @elseif ($state === 'ready' && $hasCoverageWarnings) - Coverage warnings - @elseif ($state === 'ready' && $hasEvidenceGaps) - Evidence gaps + @elseif (($findingsCount ?? 0) === 0 && $state === 'ready') + {{ $whyNoFindingsMessage ?? $whyNoFindingsFallback }} @endif
@@ -106,13 +98,13 @@ class="w-fit" {{-- Stat: Last Compared --}}
-
Last Compared
+
{{ __('baseline-compare.stat_last_compared') }}
- {{ $lastComparedAt ?? 'Never' }} + {{ $lastComparedAt ?? __('baseline-compare.stat_last_compared_never') }}
@if ($this->getRunUrl()) - View run + {{ __('baseline-compare.button_view_run') }} @endif
@@ -122,23 +114,28 @@ class="w-fit" {{-- Coverage warnings banner --}} @if ($state === 'ready' && $hasCoverageWarnings) -
+