From f32e226f6fb19e21321a9c31f9462cd0cb72435c Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Tue, 3 Mar 2026 08:21:24 +0100 Subject: [PATCH] feat: baseline drift engine (spec 117) --- app/Filament/Resources/FindingResource.php | 75 ++++ .../PolicyResource/Pages/ListPolicies.php | 11 +- app/Jobs/CaptureBaselineSnapshotJob.php | 88 ++-- app/Jobs/CompareBaselineToTenantJob.php | 406 ++++++++++++++++-- .../CurrentStateEvidenceProvider.php | 25 ++ .../Baselines/CurrentStateHashResolver.php | 92 ++++ .../Evidence/ContentEvidenceProvider.php | 181 ++++++++ .../Baselines/Evidence/EvidenceProvenance.php | 70 +++ .../Evidence/MetaEvidenceProvider.php | 127 ++++++ .../Baselines/Evidence/ResolvedEvidence.php | 58 +++ ...dd_evidence_fidelity_to_findings_table.php | 50 +++ .../checklists/requirements.md | 36 ++ .../contracts/openapi.yaml | 82 ++++ specs/117-baseline-drift-engine/data-model.md | 134 ++++++ specs/117-baseline-drift-engine/plan.md | 137 ++++++ specs/117-baseline-drift-engine/quickstart.md | 35 ++ specs/117-baseline-drift-engine/research.md | 93 ++++ specs/117-baseline-drift-engine/spec.md | 260 +++++++++++ specs/117-baseline-drift-engine/tasks.md | 157 +++++++ .../CaptureBaselineContentTest.php | 112 +++++ .../CaptureBaselineMetaFallbackTest.php | 99 +++++ .../CompareContentEvidenceTest.php | 236 ++++++++++ .../CompareFidelityMismatchTest.php | 240 +++++++++++ .../FindingFidelityFilterTest.php | 32 ++ .../FindingFidelityTest.php | 242 +++++++++++ .../FindingProvenanceTest.php | 134 ++++++ .../PerformanceGuardTest.php | 43 ++ .../BaselineDriftEngine/ResolverTest.php | 141 ++++++ .../Filament/PolicySyncCtaPlacementTest.php | 61 +++ .../Guards/Spec116OneEngineGuardTest.php | 10 +- 30 files changed, 3413 insertions(+), 54 deletions(-) create mode 100644 app/Services/Baselines/CurrentStateEvidenceProvider.php create mode 100644 app/Services/Baselines/CurrentStateHashResolver.php create mode 100644 app/Services/Baselines/Evidence/ContentEvidenceProvider.php create mode 100644 app/Services/Baselines/Evidence/EvidenceProvenance.php create mode 100644 app/Services/Baselines/Evidence/MetaEvidenceProvider.php create mode 100644 app/Services/Baselines/Evidence/ResolvedEvidence.php create mode 100644 database/migrations/2026_03_02_000001_add_evidence_fidelity_to_findings_table.php create mode 100644 specs/117-baseline-drift-engine/checklists/requirements.md create mode 100644 specs/117-baseline-drift-engine/contracts/openapi.yaml create mode 100644 specs/117-baseline-drift-engine/data-model.md create mode 100644 specs/117-baseline-drift-engine/plan.md create mode 100644 specs/117-baseline-drift-engine/quickstart.md create mode 100644 specs/117-baseline-drift-engine/research.md create mode 100644 specs/117-baseline-drift-engine/spec.md create mode 100644 specs/117-baseline-drift-engine/tasks.md create mode 100644 tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php create mode 100644 tests/Feature/BaselineDriftEngine/CaptureBaselineMetaFallbackTest.php create mode 100644 tests/Feature/BaselineDriftEngine/CompareContentEvidenceTest.php create mode 100644 tests/Feature/BaselineDriftEngine/CompareFidelityMismatchTest.php create mode 100644 tests/Feature/BaselineDriftEngine/FindingFidelityFilterTest.php create mode 100644 tests/Feature/BaselineDriftEngine/FindingFidelityTest.php create mode 100644 tests/Feature/BaselineDriftEngine/FindingProvenanceTest.php create mode 100644 tests/Feature/BaselineDriftEngine/PerformanceGuardTest.php create mode 100644 tests/Feature/BaselineDriftEngine/ResolverTest.php create mode 100644 tests/Feature/Filament/PolicySyncCtaPlacementTest.php diff --git a/app/Filament/Resources/FindingResource.php b/app/Filament/Resources/FindingResource.php index 4fd2700..8363c8a 100644 --- a/app/Filament/Resources/FindingResource.php +++ b/app/Filament/Resources/FindingResource.php @@ -121,6 +121,15 @@ public static function infolist(Schema $schema): Schema Section::make('Finding') ->schema([ TextEntry::make('finding_type')->badge()->label('Type'), + TextEntry::make('evidence_fidelity') + ->label('Fidelity') + ->badge() + ->formatStateUsing(fn (?string $state): string => is_string($state) && $state !== '' ? $state : 'meta') + ->color(fn (?string $state): string => match ((string) $state) { + 'content' => 'success', + 'meta' => 'gray', + default => 'gray', + }), TextEntry::make('status') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus)) @@ -178,6 +187,56 @@ public static function infolist(Schema $schema): Schema ->columns(2) ->columnSpanFull(), + Section::make('Evidence') + ->schema([ + TextEntry::make('baseline_evidence_fidelity') + ->label('Baseline fidelity') + ->badge() + ->state(fn (Finding $record): ?string => Arr::get($record->evidence_jsonb ?? [], 'baseline.provenance.fidelity')) + ->color(fn (?string $state): string => match ((string) $state) { + 'content' => 'success', + 'meta' => 'gray', + default => 'gray', + }) + ->placeholder('—'), + TextEntry::make('baseline_evidence_source') + ->label('Baseline source') + ->state(fn (Finding $record): ?string => Arr::get($record->evidence_jsonb ?? [], 'baseline.provenance.source')) + ->placeholder('—'), + TextEntry::make('baseline_evidence_observed_at') + ->label('Baseline observed at') + ->state(fn (Finding $record): ?string => Arr::get($record->evidence_jsonb ?? [], 'baseline.provenance.observed_at')) + ->placeholder('—') + ->copyable(), + + TextEntry::make('current_evidence_fidelity') + ->label('Current fidelity') + ->badge() + ->state(fn (Finding $record): ?string => Arr::get($record->evidence_jsonb ?? [], 'current.provenance.fidelity')) + ->color(fn (?string $state): string => match ((string) $state) { + 'content' => 'success', + 'meta' => 'gray', + default => 'gray', + }) + ->placeholder('—'), + TextEntry::make('current_evidence_source') + ->label('Current source') + ->state(fn (Finding $record): ?string => Arr::get($record->evidence_jsonb ?? [], 'current.provenance.source')) + ->placeholder('—'), + TextEntry::make('current_evidence_observed_at') + ->label('Current observed at') + ->state(fn (Finding $record): ?string => Arr::get($record->evidence_jsonb ?? [], 'current.provenance.observed_at')) + ->placeholder('—') + ->copyable(), + ]) + ->columns(3) + ->visible(function (Finding $record): bool { + $evidence = is_array($record->evidence_jsonb) ? $record->evidence_jsonb : []; + + return Arr::has($evidence, 'baseline.provenance') || Arr::has($evidence, 'current.provenance'); + }) + ->columnSpanFull(), + Section::make('Diff') ->visible(fn (Finding $record): bool => $record->finding_type === Finding::FINDING_TYPE_DRIFT) ->schema([ @@ -303,6 +362,16 @@ public static function table(Table $table): Table ->color(BadgeRenderer::color(BadgeDomain::FindingSeverity)) ->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)), + Tables\Columns\TextColumn::make('evidence_fidelity') + ->label('Fidelity') + ->badge() + ->formatStateUsing(fn (?string $state): string => is_string($state) && $state !== '' ? $state : 'meta') + ->color(fn (?string $state): string => match ((string) $state) { + 'content' => 'success', + 'meta' => 'gray', + default => 'gray', + }) + ->sortable(), Tables\Columns\TextColumn::make('subject_display_name')->label('Subject')->placeholder('—'), Tables\Columns\TextColumn::make('subject_type')->label('Subject type')->searchable(), Tables\Columns\TextColumn::make('due_at') @@ -364,6 +433,12 @@ public static function table(Table $table): Table Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES => 'Entra admin roles', ]) ->label('Type'), + Tables\Filters\SelectFilter::make('evidence_fidelity') + ->label('Fidelity') + ->options([ + 'content' => 'Content', + 'meta' => 'Meta', + ]), Tables\Filters\Filter::make('scope_key') ->form([ TextInput::make('scope_key') diff --git a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php index be30a43..4a09550 100644 --- a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php +++ b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php @@ -21,18 +21,21 @@ class ListPolicies extends ListRecords protected function getHeaderActions(): array { - return [$this->makeSyncAction()]; + return [ + $this->makeSyncAction() + ->visible(fn (): bool => $this->getFilteredTableQuery()->exists()), + ]; } protected function getTableEmptyStateActions(): array { - return [$this->makeSyncAction()]; + return [$this->makeSyncAction('syncEmpty')]; } - private function makeSyncAction(): Actions\Action + private function makeSyncAction(string $name = 'sync'): Actions\Action { return UiEnforcement::forAction( - Actions\Action::make('sync') + Actions\Action::make($name) ->label('Sync from Intune') ->icon('heroicon-o-arrow-path') ->color('primary') diff --git a/app/Jobs/CaptureBaselineSnapshotJob.php b/app/Jobs/CaptureBaselineSnapshotJob.php index ef75345..685b459 100644 --- a/app/Jobs/CaptureBaselineSnapshotJob.php +++ b/app/Jobs/CaptureBaselineSnapshotJob.php @@ -11,6 +11,8 @@ use App\Models\Tenant; use App\Models\User; 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; @@ -50,7 +52,10 @@ public function handle( InventoryMetaContract $metaContract, AuditLogger $auditLogger, OperationRunService $operationRunService, + ?CurrentStateHashResolver $hashResolver = null, ): void { + $hashResolver ??= app(CurrentStateHashResolver::class); + if (! $this->operationRun instanceof OperationRun) { $this->fail(new RuntimeException('OperationRun context is required for CaptureBaselineSnapshotJob.')); @@ -81,7 +86,7 @@ public function handle( $this->auditStarted($auditLogger, $sourceTenant, $profile, $initiator); - $snapshotItems = $this->collectSnapshotItems($sourceTenant, $effectiveScope, $identity, $metaContract); + $snapshotItems = $this->collectSnapshotItems($sourceTenant, $effectiveScope, $metaContract, $hashResolver); $identityHash = $identity->computeIdentity($snapshotItems); @@ -129,19 +134,29 @@ public function handle( private function collectSnapshotItems( Tenant $sourceTenant, BaselineScope $scope, - BaselineSnapshotIdentity $identity, InventoryMetaContract $metaContract, + CurrentStateHashResolver $hashResolver, ): array { $query = InventoryItem::query() ->where('tenant_id', $sourceTenant->getKey()); $query->whereIn('policy_type', $scope->allTypes()); - $items = []; + /** + * @var array + * }> + */ + $inventoryByKey = []; $query->orderBy('policy_type') ->orderBy('external_id') - ->chunk(500, function ($inventoryItems) use (&$items, $identity, $metaContract): void { + ->chunk(500, function ($inventoryItems) use (&$inventoryByKey, $metaContract): void { foreach ($inventoryItems as $inventoryItem) { $metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : []; $contract = $metaContract->build( @@ -150,33 +165,58 @@ private function collectSnapshotItems( metaJsonb: $metaJsonb, ); - $baselineHash = $identity->hashItemContent( - policyType: (string) $inventoryItem->policy_type, - subjectExternalId: (string) $inventoryItem->external_id, - metaJsonb: $metaJsonb, - ); + $key = (string) $inventoryItem->policy_type.'|'.(string) $inventoryItem->external_id; - $items[] = [ - 'subject_type' => 'policy', + $inventoryByKey[$key] = [ 'subject_external_id' => (string) $inventoryItem->external_id, 'policy_type' => (string) $inventoryItem->policy_type, - 'baseline_hash' => $baselineHash, - 'meta_jsonb' => [ - 'display_name' => $inventoryItem->display_name, - 'category' => $inventoryItem->category, - 'platform' => $inventoryItem->platform, - 'meta_contract' => $contract, - 'fidelity' => 'meta', - 'source' => 'inventory', - 'observed_at' => $inventoryItem->last_seen_at?->toIso8601String(), - 'observed_operation_run_id' => is_numeric($inventoryItem->last_seen_operation_run_id) - ? (int) $inventoryItem->last_seen_operation_run_id - : null, - ], + '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, + )); + + $resolvedEvidence = $hashResolver->resolveForSubjects( + tenant: $sourceTenant, + subjects: $subjects, + since: null, + latestInventorySyncRunId: null, + ); + + $items = []; + + foreach ($inventoryByKey as $key => $inventoryItem) { + $evidence = $resolvedEvidence[$key] ?? null; + + if (! $evidence instanceof ResolvedEvidence) { + continue; + } + + $items[] = [ + 'subject_type' => 'policy', + 'subject_external_id' => (string) $inventoryItem['subject_external_id'], + '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(), + ], + ]; + } + return $items; } diff --git a/app/Jobs/CompareBaselineToTenantJob.php b/app/Jobs/CompareBaselineToTenantJob.php index da6b35c..5bc273c 100644 --- a/app/Jobs/CompareBaselineToTenantJob.php +++ b/app/Jobs/CompareBaselineToTenantJob.php @@ -6,6 +6,7 @@ use App\Jobs\Middleware\TrackOperationRun; use App\Models\BaselineProfile; +use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshotItem; use App\Models\Finding; use App\Models\InventoryItem; @@ -15,6 +16,10 @@ use App\Models\Workspace; use App\Services\Baselines\BaselineAutoCloseService; use App\Services\Baselines\BaselineSnapshotIdentity; +use App\Services\Baselines\CurrentStateHashResolver; +use App\Services\Baselines\Evidence\EvidenceProvenance; +use App\Services\Baselines\Evidence\MetaEvidenceProvider; +use App\Services\Baselines\Evidence\ResolvedEvidence; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; use App\Services\Settings\SettingsResolver; @@ -57,9 +62,13 @@ public function handle( OperationRunService $operationRunService, ?SettingsResolver $settingsResolver = null, ?BaselineAutoCloseService $baselineAutoCloseService = null, + ?CurrentStateHashResolver $hashResolver = null, + ?MetaEvidenceProvider $metaEvidenceProvider = null, ): void { $settingsResolver ??= app(SettingsResolver::class); $baselineAutoCloseService ??= app(BaselineAutoCloseService::class); + $hashResolver ??= app(CurrentStateHashResolver::class); + $metaEvidenceProvider ??= app(MetaEvidenceProvider::class); if (! $this->operationRun instanceof OperationRun) { $this->fail(new RuntimeException('OperationRun context is required for CompareBaselineToTenantJob.')); @@ -161,14 +170,60 @@ public function handle( return; } - $baselineItems = $this->loadBaselineItems($snapshotId, $coveredTypes); - $currentItems = $this->loadCurrentInventory($tenant, $coveredTypes, $snapshotIdentity, (int) $inventorySyncRun->getKey()); + $snapshot = BaselineSnapshot::query() + ->where('workspace_id', (int) $profile->workspace_id) + ->where('baseline_profile_id', (int) $profile->getKey()) + ->whereKey($snapshotId) + ->first(['id', 'captured_at']); - $driftResults = $this->computeDrift( + if (! $snapshot instanceof BaselineSnapshot) { + throw new RuntimeException("BaselineSnapshot #{$snapshotId} not found."); + } + + $since = $snapshot->captured_at instanceof \DateTimeInterface + ? CarbonImmutable::instance($snapshot->captured_at) + : null; + + $baselineItems = $this->loadBaselineItems($snapshotId, $coveredTypes); + $currentItems = $this->loadCurrentInventory($tenant, $coveredTypes, (int) $inventorySyncRun->getKey()); + + $subjects = array_values(array_map( + static fn (array $item): array => [ + 'policy_type' => (string) $item['policy_type'], + 'subject_external_id' => (string) $item['subject_external_id'], + ], + $currentItems, + )); + + $resolvedCurrentEvidence = $hashResolver->resolveForSubjects( + tenant: $tenant, + subjects: $subjects, + since: $since, + latestInventorySyncRunId: (int) $inventorySyncRun->getKey(), + ); + + $resolvedCurrentMetaEvidence = $metaEvidenceProvider->resolve( + tenant: $tenant, + subjects: $subjects, + since: $since, + latestInventorySyncRunId: (int) $inventorySyncRun->getKey(), + ); + + $resolvedEffectiveCurrentEvidence = $this->resolveEffectiveCurrentEvidence( + baselineItems: $baselineItems, + currentItems: $currentItems, + resolvedBestEvidence: $resolvedCurrentEvidence, + resolvedMetaEvidence: $resolvedCurrentMetaEvidence, + ); + + $computeResult = $this->computeDrift( $baselineItems, $currentItems, + $resolvedEffectiveCurrentEvidence, $this->resolveSeverityMapping($workspace, $settingsResolver), ); + $driftResults = $computeResult['drift']; + $evidenceGaps = $computeResult['evidence_gaps']; $upsertResult = $this->upsertFindings( $tenant, @@ -222,18 +277,31 @@ public function handle( ); } + $coverageBreakdown = $this->summarizeCurrentEvidenceCoverage($currentItems, $resolvedCurrentEvidence); + $baselineCoverage = $this->summarizeBaselineEvidenceCoverage($baselineItems); + + $overallFidelity = ($baselineCoverage['baseline_meta'] ?? 0) > 0 + || ($coverageBreakdown['resolved_meta'] ?? 0) > 0 + || ($evidenceGaps['missing_current'] ?? 0) > 0 + ? EvidenceProvenance::FidelityMeta + : EvidenceProvenance::FidelityContent; + $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; $updatedContext['baseline_compare'] = array_merge( is_array($updatedContext['baseline_compare'] ?? null) ? $updatedContext['baseline_compare'] : [], [ 'inventory_sync_run_id' => (int) $inventorySyncRun->getKey(), + 'since' => $since?->toIso8601String(), 'coverage' => [ 'effective_types' => $effectiveTypes, 'covered_types' => $coveredTypes, 'uncovered_types' => $uncoveredTypes, 'proof' => true, + ...$coverageBreakdown, + ...$baselineCoverage, ], - 'fidelity' => 'meta', + 'fidelity' => $overallFidelity, + 'evidence_gaps' => $evidenceGaps, ], ); $updatedContext['findings'] = array_merge( @@ -253,6 +321,64 @@ public function handle( $this->auditCompleted($auditLogger, $tenant, $profile, $initiator, $summaryCounts); } + /** + * Baseline hashes depend on which evidence fidelity was available at capture time (content vs meta). + * + * Current state evidence is therefore selected to be comparable to the baseline hash: + * - If baseline evidence fidelity is meta: force meta evidence for current (inventory meta contract). + * - If baseline evidence fidelity is content: require current content evidence (since-rule); otherwise treat as a gap. + * + * @param array}> $baselineItems + * @param array}> $currentItems + * @param array $resolvedBestEvidence + * @param array $resolvedMetaEvidence + * @return array + */ + private function resolveEffectiveCurrentEvidence( + array $baselineItems, + array $currentItems, + array $resolvedBestEvidence, + array $resolvedMetaEvidence, + ): array { + /** + * @var array + */ + $baselineFidelityByKey = []; + + foreach ($baselineItems as $key => $baselineItem) { + $provenance = $this->baselineProvenanceFromMetaJsonb($baselineItem['meta_jsonb'] ?? []); + $baselineFidelityByKey[$key] = (string) ($provenance['fidelity'] ?? EvidenceProvenance::FidelityMeta); + } + + $effective = []; + + foreach ($currentItems as $key => $currentItem) { + if (array_key_exists($key, $baselineItems)) { + $baselineFidelity = $baselineFidelityByKey[$key] ?? EvidenceProvenance::FidelityMeta; + + if ($baselineFidelity === EvidenceProvenance::FidelityMeta) { + $effective[$key] = $resolvedMetaEvidence[$key] ?? null; + + continue; + } + + $best = $resolvedBestEvidence[$key] ?? null; + + if ($best instanceof ResolvedEvidence && $best->fidelity === EvidenceProvenance::FidelityContent) { + $effective[$key] = $best; + } else { + $effective[$key] = null; + } + + continue; + } + + $effective[$key] = $resolvedBestEvidence[$key] ?? null; + } + + return $effective; + } + private function completeWithCoverageWarning( OperationRunService $operationRunService, AuditLogger $auditLogger, @@ -297,8 +423,19 @@ private function completeWithCoverageWarning( 'covered_types' => array_values($coveredTypes), 'uncovered_types' => array_values($uncoveredTypes), 'proof' => $coverageProof, + 'subjects_total' => 0, + 'resolved_total' => 0, + 'resolved_content' => 0, + 'resolved_meta' => 0, + 'policy_types_content' => [], + 'policy_types_meta_only' => [], ], 'fidelity' => 'meta', + 'evidence_gaps' => [ + 'missing_baseline' => 0, + 'missing_current' => 0, + 'missing_both' => 0, + ], ], ); $updatedContext['findings'] = array_merge( @@ -358,12 +495,11 @@ private function loadBaselineItems(int $snapshotId, array $policyTypes): array /** * Load current inventory items keyed by "policy_type|external_id". * - * @return array}> + * @return array}> */ private function loadCurrentInventory( Tenant $tenant, array $policyTypes, - BaselineSnapshotIdentity $snapshotIdentity, ?int $latestInventorySyncRunId = null, ): array { $query = InventoryItem::query() @@ -383,20 +519,12 @@ private function loadCurrentInventory( $query->orderBy('policy_type') ->orderBy('external_id') - ->chunk(500, function ($inventoryItems) use (&$items, $snapshotIdentity): void { + ->chunk(500, function ($inventoryItems) use (&$items): void { foreach ($inventoryItems as $inventoryItem) { - $metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : []; - $currentHash = $snapshotIdentity->hashItemContent( - policyType: (string) $inventoryItem->policy_type, - subjectExternalId: (string) $inventoryItem->external_id, - metaJsonb: $metaJsonb, - ); - $key = $inventoryItem->policy_type.'|'.$inventoryItem->external_id; $items[$key] = [ 'subject_external_id' => (string) $inventoryItem->external_id, 'policy_type' => (string) $inventoryItem->policy_type, - 'current_hash' => $currentHash, 'meta_jsonb' => [ 'display_name' => $inventoryItem->display_name, 'category' => $inventoryItem->category, @@ -426,13 +554,15 @@ private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun * Compare baseline items vs current inventory and produce drift results. * * @param array}> $baselineItems - * @param array}> $currentItems + * @param array}> $currentItems + * @param array $resolvedCurrentEvidence * @param array $severityMapping - * @return array}> + * @return array{drift: array}>, evidence_gaps: array{missing_baseline: int, missing_current: int, missing_both: int}} */ - private function computeDrift(array $baselineItems, array $currentItems, array $severityMapping): array + private function computeDrift(array $baselineItems, array $currentItems, array $resolvedCurrentEvidence, array $severityMapping): array { $drift = []; + $missingCurrentEvidence = 0; foreach ($baselineItems as $key => $baselineItem) { if (! array_key_exists($key, $currentItems)) { @@ -442,6 +572,7 @@ private function computeDrift(array $baselineItems, array $currentItems, array $ 'subject_type' => $baselineItem['subject_type'], 'subject_external_id' => $baselineItem['subject_external_id'], 'policy_type' => $baselineItem['policy_type'], + 'evidence_fidelity' => EvidenceProvenance::FidelityMeta, 'baseline_hash' => $baselineItem['baseline_hash'], 'current_hash' => '', 'evidence' => [ @@ -454,23 +585,42 @@ private function computeDrift(array $baselineItems, array $currentItems, array $ continue; } - $currentItem = $currentItems[$key]; + $currentEvidence = $resolvedCurrentEvidence[$key] ?? null; + + if (! $currentEvidence instanceof ResolvedEvidence) { + $missingCurrentEvidence++; + + continue; + } + + if ($baselineItem['baseline_hash'] !== $currentEvidence->hash) { + $baselineProvenance = $this->baselineProvenanceFromMetaJsonb($baselineItem['meta_jsonb']); + $baselineFidelity = (string) ($baselineProvenance['fidelity'] ?? EvidenceProvenance::FidelityMeta); + $evidenceFidelity = EvidenceProvenance::weakerFidelity($baselineFidelity, $currentEvidence->fidelity); - if ($baselineItem['baseline_hash'] !== $currentItem['current_hash']) { $drift[] = [ 'change_type' => 'different_version', 'severity' => $this->severityForChangeType($severityMapping, 'different_version'), 'subject_type' => $baselineItem['subject_type'], 'subject_external_id' => $baselineItem['subject_external_id'], 'policy_type' => $baselineItem['policy_type'], + 'evidence_fidelity' => $evidenceFidelity, 'baseline_hash' => $baselineItem['baseline_hash'], - 'current_hash' => $currentItem['current_hash'], + 'current_hash' => $currentEvidence->hash, 'evidence' => [ 'change_type' => 'different_version', 'policy_type' => $baselineItem['policy_type'], 'display_name' => $baselineItem['meta_jsonb']['display_name'] ?? null, 'baseline_hash' => $baselineItem['baseline_hash'], - 'current_hash' => $currentItem['current_hash'], + 'current_hash' => $currentEvidence->hash, + 'baseline' => [ + 'hash' => $baselineItem['baseline_hash'], + 'provenance' => $baselineProvenance, + ], + 'current' => [ + 'hash' => $currentEvidence->hash, + 'provenance' => $currentEvidence->provenance(), + ], ], ]; } @@ -478,14 +628,23 @@ private function computeDrift(array $baselineItems, array $currentItems, array $ foreach ($currentItems as $key => $currentItem) { if (! array_key_exists($key, $baselineItems)) { + $currentEvidence = $resolvedCurrentEvidence[$key] ?? null; + + if (! $currentEvidence instanceof ResolvedEvidence) { + $missingCurrentEvidence++; + + continue; + } + $drift[] = [ 'change_type' => 'unexpected_policy', 'severity' => $this->severityForChangeType($severityMapping, 'unexpected_policy'), 'subject_type' => 'policy', 'subject_external_id' => $currentItem['subject_external_id'], 'policy_type' => $currentItem['policy_type'], + 'evidence_fidelity' => EvidenceProvenance::FidelityMeta, 'baseline_hash' => '', - 'current_hash' => $currentItem['current_hash'], + 'current_hash' => $currentEvidence->hash, 'evidence' => [ 'change_type' => 'unexpected_policy', 'policy_type' => $currentItem['policy_type'], @@ -495,7 +654,205 @@ private function computeDrift(array $baselineItems, array $currentItems, array $ } } - return $drift; + return [ + 'drift' => $drift, + 'evidence_gaps' => [ + 'missing_baseline' => 0, + 'missing_current' => $missingCurrentEvidence, + 'missing_both' => 0, + ], + ]; + } + + /** + * @param array}> $currentItems + * @param array $resolvedCurrentEvidence + * @return array{ + * subjects_total: int, + * resolved_total: int, + * resolved_content: int, + * resolved_meta: int, + * policy_types_content: list, + * policy_types_meta_only: list + * } + */ + private function summarizeCurrentEvidenceCoverage(array $currentItems, array $resolvedCurrentEvidence): array + { + $subjectsTotal = count($currentItems); + $resolvedContent = 0; + $resolvedMeta = 0; + + /** + * @var array + */ + $resolvedByType = []; + + foreach ($currentItems as $key => $item) { + $type = (string) ($item['policy_type'] ?? ''); + + if ($type === '') { + continue; + } + + $resolvedByType[$type] ??= ['resolved_content' => 0, 'resolved_meta' => 0]; + + $evidence = $resolvedCurrentEvidence[$key] ?? null; + + if (! $evidence instanceof ResolvedEvidence) { + continue; + } + + if ($evidence->fidelity === EvidenceProvenance::FidelityContent) { + $resolvedContent++; + $resolvedByType[$type]['resolved_content']++; + } else { + $resolvedMeta++; + $resolvedByType[$type]['resolved_meta']++; + } + } + + $policyTypesContent = []; + $policyTypesMetaOnly = []; + + foreach ($resolvedByType as $policyType => $counts) { + if ($counts['resolved_content'] > 0) { + $policyTypesContent[] = $policyType; + + continue; + } + + if ($counts['resolved_meta'] > 0) { + $policyTypesMetaOnly[] = $policyType; + } + } + + sort($policyTypesContent, SORT_STRING); + sort($policyTypesMetaOnly, SORT_STRING); + + return [ + 'subjects_total' => $subjectsTotal, + 'resolved_total' => $resolvedContent + $resolvedMeta, + 'resolved_content' => $resolvedContent, + 'resolved_meta' => $resolvedMeta, + 'policy_types_content' => $policyTypesContent, + 'policy_types_meta_only' => $policyTypesMetaOnly, + ]; + } + + /** + * @param array}> $baselineItems + * @return array{ + * baseline_total: int, + * baseline_content: int, + * baseline_meta: int, + * baseline_policy_types_content: list, + * baseline_policy_types_meta_only: list + * } + */ + private function summarizeBaselineEvidenceCoverage(array $baselineItems): array + { + $baselineTotal = count($baselineItems); + $baselineContent = 0; + $baselineMeta = 0; + + /** + * @var array + */ + $countsByType = []; + + foreach ($baselineItems as $key => $item) { + $type = (string) ($item['policy_type'] ?? ''); + + if ($type === '') { + continue; + } + + $countsByType[$type] ??= ['baseline_content' => 0, 'baseline_meta' => 0]; + + $provenance = $this->baselineProvenanceFromMetaJsonb($item['meta_jsonb'] ?? []); + $fidelity = (string) ($provenance['fidelity'] ?? EvidenceProvenance::FidelityMeta); + + if ($fidelity === EvidenceProvenance::FidelityContent) { + $baselineContent++; + $countsByType[$type]['baseline_content']++; + } else { + $baselineMeta++; + $countsByType[$type]['baseline_meta']++; + } + } + + $policyTypesContent = []; + $policyTypesMetaOnly = []; + + foreach ($countsByType as $policyType => $counts) { + if (($counts['baseline_content'] ?? 0) > 0) { + $policyTypesContent[] = $policyType; + + continue; + } + + if (($counts['baseline_meta'] ?? 0) > 0) { + $policyTypesMetaOnly[] = $policyType; + } + } + + sort($policyTypesContent, SORT_STRING); + sort($policyTypesMetaOnly, SORT_STRING); + + return [ + 'baseline_total' => $baselineTotal, + 'baseline_content' => $baselineContent, + 'baseline_meta' => $baselineMeta, + 'baseline_policy_types_content' => $policyTypesContent, + 'baseline_policy_types_meta_only' => $policyTypesMetaOnly, + ]; + } + + /** + * @param array $metaJsonb + * @return array{fidelity: string, source: string, observed_at: ?string, observed_operation_run_id: ?int} + */ + private function baselineProvenanceFromMetaJsonb(array $metaJsonb): array + { + $evidence = $metaJsonb; + + if (is_array($metaJsonb['evidence'] ?? null)) { + $evidence = $metaJsonb['evidence']; + } + + $fidelity = $evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta; + $fidelity = is_string($fidelity) ? strtolower(trim($fidelity)) : EvidenceProvenance::FidelityMeta; + if (! EvidenceProvenance::isValidFidelity($fidelity)) { + $fidelity = EvidenceProvenance::FidelityMeta; + } + + $source = $evidence['source'] ?? EvidenceProvenance::SourceInventory; + $source = is_string($source) ? strtolower(trim($source)) : EvidenceProvenance::SourceInventory; + if (! EvidenceProvenance::isValidSource($source)) { + $source = EvidenceProvenance::SourceInventory; + } + + $observedAt = $evidence['observed_at'] ?? null; + $observedAt = is_string($observedAt) ? trim($observedAt) : null; + + $observedAtCarbon = null; + if (is_string($observedAt) && $observedAt !== '') { + try { + $observedAtCarbon = CarbonImmutable::parse($observedAt); + } catch (\Throwable) { + $observedAtCarbon = null; + } + } + + $observedOperationRunId = $evidence['observed_operation_run_id'] ?? null; + $observedOperationRunId = is_numeric($observedOperationRunId) ? (int) $observedOperationRunId : null; + + return EvidenceProvenance::build( + fidelity: $fidelity, + source: $source, + observedAt: $observedAtCarbon, + observedOperationRunId: $observedOperationRunId, + ); } /** @@ -560,6 +917,7 @@ private function upsertFindings( 'fingerprint' => $fingerprint, 'recurrence_key' => $recurrenceKey, 'evidence_jsonb' => $driftItem['evidence'], + 'evidence_fidelity' => $driftItem['evidence_fidelity'] ?? EvidenceProvenance::FidelityMeta, 'baseline_operation_run_id' => null, 'current_operation_run_id' => (int) $this->operationRun->getKey(), ]); diff --git a/app/Services/Baselines/CurrentStateEvidenceProvider.php b/app/Services/Baselines/CurrentStateEvidenceProvider.php new file mode 100644 index 0000000..1367708 --- /dev/null +++ b/app/Services/Baselines/CurrentStateEvidenceProvider.php @@ -0,0 +1,25 @@ + $subjects + * @return array keyed by "policy_type|subject_external_id" + */ + public function resolve( + Tenant $tenant, + array $subjects, + ?CarbonImmutable $since = null, + ?int $latestInventorySyncRunId = null, + ): array; +} diff --git a/app/Services/Baselines/CurrentStateHashResolver.php b/app/Services/Baselines/CurrentStateHashResolver.php new file mode 100644 index 0000000..584dda1 --- /dev/null +++ b/app/Services/Baselines/CurrentStateHashResolver.php @@ -0,0 +1,92 @@ + + */ + private readonly array $providers; + + public function __construct( + ContentEvidenceProvider $contentEvidenceProvider, + MetaEvidenceProvider $metaEvidenceProvider, + ) { + $this->providers = [ + $contentEvidenceProvider, + $metaEvidenceProvider, + ]; + } + + /** + * Resolve best-available current-state evidence per subject using the ordered provider chain. + * + * First-non-null wins. + * + * @param list $subjects + * @return array keyed by "policy_type|subject_external_id" + */ + public function resolveForSubjects( + Tenant $tenant, + array $subjects, + ?CarbonImmutable $since = null, + ?int $latestInventorySyncRunId = null, + ): array { + $results = []; + $unresolved = []; + + foreach ($subjects as $subject) { + $policyType = trim((string) ($subject['policy_type'] ?? '')); + $externalId = trim((string) ($subject['subject_external_id'] ?? '')); + + if ($policyType === '' || $externalId === '') { + continue; + } + + $key = $policyType.'|'.$externalId; + + if (array_key_exists($key, $results)) { + continue; + } + + $results[$key] = null; + $unresolved[$key] = [ + 'policy_type' => $policyType, + 'subject_external_id' => $externalId, + ]; + } + + foreach ($this->providers as $provider) { + if ($unresolved === []) { + break; + } + + $resolved = $provider->resolve( + tenant: $tenant, + subjects: array_values($unresolved), + since: $since, + latestInventorySyncRunId: $latestInventorySyncRunId, + ); + + foreach ($resolved as $key => $evidence) { + if (! array_key_exists($key, $unresolved)) { + continue; + } + + $results[$key] = $evidence; + unset($unresolved[$key]); + } + } + + return $results; + } +} diff --git a/app/Services/Baselines/Evidence/ContentEvidenceProvider.php b/app/Services/Baselines/Evidence/ContentEvidenceProvider.php new file mode 100644 index 0000000..a25af76 --- /dev/null +++ b/app/Services/Baselines/Evidence/ContentEvidenceProvider.php @@ -0,0 +1,181 @@ +requestedKeys($subjects); + + if ($requestedKeys === []) { + return []; + } + + $policyTypes = array_values(array_unique(array_map( + static fn (array $subject): string => trim((string) ($subject['policy_type'] ?? '')), + $subjects, + ))); + $policyTypes = array_values(array_filter($policyTypes, static fn (string $value): bool => $value !== '')); + + $externalIds = array_values(array_unique(array_map( + static fn (array $subject): string => trim((string) ($subject['subject_external_id'] ?? '')), + $subjects, + ))); + $externalIds = array_values(array_filter($externalIds, static fn (string $value): bool => $value !== '')); + + if ($policyTypes === [] || $externalIds === []) { + return []; + } + + $policies = Policy::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->whereIn('policy_type', $policyTypes) + ->whereIn('external_id', $externalIds) + ->get(['id', 'policy_type', 'external_id']); + + /** @var Collection $policies */ + $policyIdToKey = []; + $policyIds = []; + + foreach ($policies as $policy) { + if (! $policy instanceof Policy) { + continue; + } + + $key = (string) $policy->policy_type.'|'.(string) $policy->external_id; + + if (! array_key_exists($key, $requestedKeys)) { + continue; + } + + $policyIdToKey[(int) $policy->getKey()] = $key; + $policyIds[] = (int) $policy->getKey(); + } + + if ($policyIds === []) { + return []; + } + + $baseQuery = DB::table('policy_versions') + ->select([ + 'policy_versions.id', + 'policy_versions.policy_id', + 'policy_versions.policy_type', + 'policy_versions.platform', + 'policy_versions.captured_at', + 'policy_versions.snapshot', + 'policy_versions.version_number', + ]) + ->selectRaw('ROW_NUMBER() OVER (PARTITION BY policy_id ORDER BY captured_at DESC, version_number DESC, id DESC) as rn') + ->where('tenant_id', (int) $tenant->getKey()) + ->whereIn('policy_id', $policyIds) + ->whereNull('deleted_at'); + + if ($since instanceof CarbonImmutable) { + $baseQuery->where('captured_at', '>=', $since->toDateTimeString()); + } + + $versions = DB::query() + ->fromSub($baseQuery, 'ranked_policy_versions') + ->where('rn', 1) + ->get(); + + $resolved = []; + + foreach ($versions as $version) { + $policyId = is_numeric($version->policy_id ?? null) ? (int) $version->policy_id : null; + $key = $policyId !== null ? ($policyIdToKey[$policyId] ?? null) : null; + + if (! is_string($key) || $key === '' || ! array_key_exists($key, $requestedKeys)) { + continue; + } + + $policyType = is_string($version->policy_type ?? null) ? (string) $version->policy_type : ''; + $subjectExternalId = (string) ($requestedKeys[$key] ?? ''); + + if ($policyType === '' || $subjectExternalId === '') { + continue; + } + + $snapshot = $version->snapshot ?? null; + $snapshot = is_array($snapshot) ? $snapshot : (is_string($snapshot) ? json_decode($snapshot, true) : null); + $snapshot = is_array($snapshot) ? $snapshot : []; + + $platform = is_string($version->platform ?? null) ? (string) $version->platform : null; + + $normalized = $this->settingsNormalizer->normalizeForDiff( + snapshot: $snapshot, + policyType: $policyType, + platform: $platform, + ); + + $hash = $this->hasher->hashNormalized($normalized); + + $observedAt = is_string($version->captured_at ?? null) ? CarbonImmutable::parse((string) $version->captured_at) : null; + $policyVersionId = is_numeric($version->id ?? null) ? (int) $version->id : null; + + $resolved[$key] = new ResolvedEvidence( + policyType: $policyType, + subjectExternalId: $subjectExternalId, + hash: $hash, + fidelity: EvidenceProvenance::FidelityContent, + source: EvidenceProvenance::SourcePolicyVersion, + observedAt: $observedAt, + observedOperationRunId: null, + meta: [ + 'policy_version_id' => $policyVersionId, + ], + ); + } + + return $resolved; + } + + /** + * @param list $subjects + * @return array + */ + private function requestedKeys(array $subjects): array + { + $keys = []; + + foreach ($subjects as $subject) { + $policyType = trim((string) ($subject['policy_type'] ?? '')); + $externalId = trim((string) ($subject['subject_external_id'] ?? '')); + + if ($policyType === '' || $externalId === '') { + continue; + } + + $keys[$policyType.'|'.$externalId] = $externalId; + } + + return $keys; + } +} diff --git a/app/Services/Baselines/Evidence/EvidenceProvenance.php b/app/Services/Baselines/Evidence/EvidenceProvenance.php new file mode 100644 index 0000000..787e259 --- /dev/null +++ b/app/Services/Baselines/Evidence/EvidenceProvenance.php @@ -0,0 +1,70 @@ + $fidelity, + self::KeySource => $source, + self::KeyObservedAt => $observedAt?->toIso8601String(), + self::KeyObservedOperationRunId => $observedOperationRunId, + ]; + } + + public static function weakerFidelity(string $baselineFidelity, string $currentFidelity): string + { + $baselineFidelity = strtolower(trim($baselineFidelity)); + $currentFidelity = strtolower(trim($currentFidelity)); + + if ($baselineFidelity === self::FidelityMeta || $currentFidelity === self::FidelityMeta) { + return self::FidelityMeta; + } + + return self::FidelityContent; + } + + public static function isValidFidelity(?string $fidelity): bool + { + return in_array($fidelity, [self::FidelityContent, self::FidelityMeta], true); + } + + public static function isValidSource(?string $source): bool + { + return in_array($source, [self::SourcePolicyVersion, self::SourceInventory], true); + } +} diff --git a/app/Services/Baselines/Evidence/MetaEvidenceProvider.php b/app/Services/Baselines/Evidence/MetaEvidenceProvider.php new file mode 100644 index 0000000..09ef072 --- /dev/null +++ b/app/Services/Baselines/Evidence/MetaEvidenceProvider.php @@ -0,0 +1,127 @@ +requestedKeys($subjects); + + if ($requestedKeys === []) { + return []; + } + + $policyTypes = array_values(array_unique(array_map( + static fn (array $subject): string => trim((string) ($subject['policy_type'] ?? '')), + $subjects, + ))); + $policyTypes = array_values(array_filter($policyTypes, static fn (string $value): bool => $value !== '')); + + $externalIds = array_values(array_unique(array_map( + static fn (array $subject): string => trim((string) ($subject['subject_external_id'] ?? '')), + $subjects, + ))); + $externalIds = array_values(array_filter($externalIds, static fn (string $value): bool => $value !== '')); + + if ($policyTypes === [] || $externalIds === []) { + return []; + } + + $query = InventoryItem::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->whereIn('policy_type', $policyTypes) + ->whereIn('external_id', $externalIds); + + if (is_int($latestInventorySyncRunId) && $latestInventorySyncRunId > 0) { + $query->where('last_seen_operation_run_id', $latestInventorySyncRunId); + } + + /** @var Collection $inventoryItems */ + $inventoryItems = $query->get(); + + $resolved = []; + + foreach ($inventoryItems as $item) { + if (! $item instanceof InventoryItem) { + continue; + } + + $key = (string) $item->policy_type.'|'.(string) $item->external_id; + + if (! array_key_exists($key, $requestedKeys)) { + continue; + } + + $metaJsonb = is_array($item->meta_jsonb) ? $item->meta_jsonb : []; + + $hash = $this->snapshotIdentity->hashItemContent( + policyType: (string) $item->policy_type, + subjectExternalId: (string) $item->external_id, + metaJsonb: $metaJsonb, + ); + + $observedAt = $item->last_seen_at ? CarbonImmutable::instance($item->last_seen_at) : null; + $observedOperationRunId = is_numeric($item->last_seen_operation_run_id) + ? (int) $item->last_seen_operation_run_id + : null; + + $resolved[$key] = new ResolvedEvidence( + policyType: (string) $item->policy_type, + subjectExternalId: (string) $item->external_id, + hash: $hash, + fidelity: EvidenceProvenance::FidelityMeta, + source: EvidenceProvenance::SourceInventory, + observedAt: $observedAt, + observedOperationRunId: $observedOperationRunId, + meta: [], + ); + } + + return $resolved; + } + + /** + * @param list $subjects + * @return array + */ + private function requestedKeys(array $subjects): array + { + $keys = []; + + foreach ($subjects as $subject) { + $policyType = trim((string) ($subject['policy_type'] ?? '')); + $externalId = trim((string) ($subject['subject_external_id'] ?? '')); + + if ($policyType === '' || $externalId === '') { + continue; + } + + $keys[$policyType.'|'.$externalId] = true; + } + + return $keys; + } +} diff --git a/app/Services/Baselines/Evidence/ResolvedEvidence.php b/app/Services/Baselines/Evidence/ResolvedEvidence.php new file mode 100644 index 0000000..5350592 --- /dev/null +++ b/app/Services/Baselines/Evidence/ResolvedEvidence.php @@ -0,0 +1,58 @@ + $meta + */ + public function __construct( + public readonly string $policyType, + public readonly string $subjectExternalId, + public readonly string $hash, + public readonly string $fidelity, + public readonly string $source, + public readonly ?CarbonImmutable $observedAt, + public readonly ?int $observedOperationRunId = null, + public readonly array $meta = [], + ) {} + + public function key(): string + { + return $this->policyType.'|'.$this->subjectExternalId; + } + + /** + * @return array{ + * fidelity: string, + * source: string, + * observed_at: ?string, + * observed_operation_run_id: ?int + * } + */ + public function provenance(): array + { + return EvidenceProvenance::build( + fidelity: $this->fidelity, + source: $this->source, + observedAt: $this->observedAt, + observedOperationRunId: $this->observedOperationRunId, + ); + } + + /** + * @return array{hash: string, provenance: array} + */ + public function toFindingSideEvidence(): array + { + return [ + 'hash' => $this->hash, + 'provenance' => $this->provenance(), + ]; + } +} diff --git a/database/migrations/2026_03_02_000001_add_evidence_fidelity_to_findings_table.php b/database/migrations/2026_03_02_000001_add_evidence_fidelity_to_findings_table.php new file mode 100644 index 0000000..f427d93 --- /dev/null +++ b/database/migrations/2026_03_02_000001_add_evidence_fidelity_to_findings_table.php @@ -0,0 +1,50 @@ +string('evidence_fidelity')->nullable()->after('evidence_jsonb'); + } + }); + + if (Schema::hasColumn('findings', 'evidence_fidelity')) { + DB::table('findings') + ->whereNull('evidence_fidelity') + ->update(['evidence_fidelity' => 'meta']); + } + + Schema::table('findings', function (Blueprint $table) { + if (! Schema::hasColumn('findings', 'evidence_fidelity')) { + return; + } + + $table->index(['tenant_id', 'evidence_fidelity'], 'findings_tenant_evidence_fidelity_idx'); + }); + } + + public function down(): void + { + if (! Schema::hasTable('findings')) { + return; + } + + Schema::table('findings', function (Blueprint $table) { + if (Schema::hasColumn('findings', 'evidence_fidelity')) { + $table->dropIndex('findings_tenant_evidence_fidelity_idx'); + $table->dropColumn('evidence_fidelity'); + } + }); + } +}; diff --git a/specs/117-baseline-drift-engine/checklists/requirements.md b/specs/117-baseline-drift-engine/checklists/requirements.md new file mode 100644 index 0000000..8ac6fc8 --- /dev/null +++ b/specs/117-baseline-drift-engine/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Spec 117 — Golden Master Baseline Drift + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-02 +**Feature**: [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 + +## Notes + +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan` + +- Remaining technical terms (e.g., “hash”, “provider chain”, “fidelity”) are treated as product-domain vocabulary required to specify deterministic drift behavior, not as framework/API implementation instructions. diff --git a/specs/117-baseline-drift-engine/contracts/openapi.yaml b/specs/117-baseline-drift-engine/contracts/openapi.yaml new file mode 100644 index 0000000..b81753f --- /dev/null +++ b/specs/117-baseline-drift-engine/contracts/openapi.yaml @@ -0,0 +1,82 @@ +openapi: 3.0.3 +info: + title: TenantPilot — Spec 117 Baseline Drift Engine + version: 0.1.0 + description: | + This contract documents existing Filament panel routes and Monitoring surfaces involved + in baseline capture/compare and drift findings. Spec 117 does not add new HTTP APIs. + +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/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/117-baseline-drift-engine/data-model.md b/specs/117-baseline-drift-engine/data-model.md new file mode 100644 index 0000000..a82d682 --- /dev/null +++ b/specs/117-baseline-drift-engine/data-model.md @@ -0,0 +1,134 @@ +# Data Model — Spec 117 Baseline Drift Engine + +This document describes the data shapes required to implement deep settings drift via a provider chain and to satisfy provenance requirements (baseline + current). + +## Entities (existing) + +### `baseline_snapshots` + +- Purpose: immutable reference snapshot for a baseline capture. +- Key fields (known from repo): + - `id` + - `captured_at` (timestamp; the “since” reference) + - `baseline_profile_id` (profile reference) + +### `baseline_snapshot_items` + +- Purpose: per-subject snapshot item, stored without tenant identifiers. +- Fields (known from repo): + - `baseline_snapshot_id` + - `subject_type` + - `subject_id` + - `baseline_hash` (currently meta contract hash) + - `meta_jsonb` (currently holds provenance-like info) + +### `operation_runs` + +- Purpose: operational lifecycle for queued capture/compare. +- Contract: summary counts are numeric-only and key-whitelisted; extended detail goes in `context`. + +### `findings` + +- Purpose: drift findings produced by compare. +- Current: uses `evidence_jsonb` for drift evidence shape. + +## Proposed changes + +### 1) Findings: add `evidence_fidelity` + +**Add column**: `findings.evidence_fidelity` (string) +- Allowed values: `content`, `meta` +- Index: `index_findings_evidence_fidelity` (and/or composite with tenant/status if common) + +**Why**: supports fast filtering and stable semantics, while provenance remains in JSON. + +### 2) Evidence JSON shape: include provenance for both sides + +Store under `findings.evidence_jsonb` (existing column) with a stable top-level shape: + +```json +{ + "change_type": "created|updated|deleted|unchanged", + "baseline": { + "hash": "...", + "provenance": { + "fidelity": "content|meta", + "source": "policy_version|inventory", + "observed_at": "2026-03-02T10:11:12Z", + "observed_operation_run_id": "uuid-or-int-or-null" + } + }, + "current": { + "hash": "...", + "provenance": { + "fidelity": "content|meta", + "source": "policy_version|inventory", + "observed_at": "2026-03-02T10:11:12Z", + "observed_operation_run_id": "uuid-or-int-or-null" + } + } +} +``` + +Notes: +- `source` is intentionally constrained to the two v1.5 sources. +- `observed_operation_run_id` is optional; include when available for traceability. + +### 3) Baseline snapshot item provenance + +Baseline capture should persist provenance for the baseline-side evidence: + +- Continue storing `baseline_hash` on `baseline_snapshot_items`. +- Store baseline-side provenance in `baseline_snapshot_items.meta_jsonb` (existing) in a stable structure: + +```json +{ + "evidence": { + "fidelity": "content|meta", + "source": "policy_version|inventory", + "observed_at": "...", + "observed_operation_run_id": "..." + } +} +``` + +Notes: +- This does not add columns to snapshot items (keeps schema minimal). +- Snapshot items remain tenant-identifier-free. + +### 4) Operation run context for compare coverage + +Store compare coverage and evidence gaps in `operation_runs.context`: + +```json +{ + "baseline_compare": { + "since": "...baseline captured_at...", + "coverage": { + "subjects_total": 500, + "resolved_total": 480, + "resolved_content": 120, + "resolved_meta": 360 + }, + "evidence_gaps": { + "missing_baseline": 0, + "missing_current": 20, + "missing_both": 0 + } + } +} +``` + +Notes: +- Keep this out of `summary_counts` due to key restrictions. + +## Validation rules + +- `evidence_fidelity` must be either `content` or `meta`. +- Findings must include both `baseline.provenance` and `current.provenance`. +- When no evidence exists for a subject (per spec), record evidence gap in run context and do not create a finding. + +## Migration strategy + +- Add a single migration to add `evidence_fidelity` to `findings` + backfill existing rows to `meta`. +- Keep backward compatibility for older findings by defaulting missing JSON paths to `meta`/`inventory` at render time (until backfill completes). diff --git a/specs/117-baseline-drift-engine/plan.md b/specs/117-baseline-drift-engine/plan.md new file mode 100644 index 0000000..acb862f --- /dev/null +++ b/specs/117-baseline-drift-engine/plan.md @@ -0,0 +1,137 @@ +# Implementation Plan: Golden Master Baseline Drift — Deep Settings Drift via Provider Chain + +**Branch**: `117-baseline-drift-engine` | **Date**: 2026-03-02 | **Spec**: [specs/117-baseline-drift-engine/spec.md](specs/117-baseline-drift-engine/spec.md) + +## Summary + +Implement a single, batch-oriented “current state hash resolver” for Golden Master baseline capture + compare. + +- For each tenant subject, resolve best available evidence via provider chain (content via `PolicyVersion` since baseline snapshot captured time; else meta via Inventory meta contract). +- Compare baseline snapshot item hash vs current resolved hash to emit drift findings. +- Findings store + display baseline and current evidence provenance (fidelity, source, observed timestamp for each side) and add a fidelity badge + filter. +- Compare remains read-only (no new external calls during compare) and continues to run via `OperationRun`. + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Filament v5, Livewire v4, Pest v4 +**Storage**: PostgreSQL (Sail) +**Testing**: Pest (`vendor/bin/sail artisan test --compact`) +**Target Platform**: Docker containers (Laravel Sail) +**Project Type**: Web application (Laravel + Filament) +**Performance Goals**: Baseline scope ~500 subjects resolves + compares within ~2 minutes (staging target from spec) +**Constraints**: +- v1.5 compare MUST NOT initiate any upstream/Graph calls; DB-only + existing stored evidence only. +- Batch resolution (no per-subject query loops; chunk + set-based queries). +- Baseline snapshot items MUST NOT persist tenant identifiers. +**Scale/Scope**: Per-run: 0–500+ subjects; per-tenant: multiple policy types; mixed evidence fidelity expected. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: PASS — “meta evidence” comes from `inventory_items` (last observed); snapshot items remain immutable standards. +- Read/write separation: PASS — compare/capture are operational runs; no new write surfaces; findings are operational artifacts. +- Graph contract path: PASS — v1.5 compare uses only local DB evidence (no Graph calls). +- Deterministic capabilities: PASS — no new capabilities introduced. +- RBAC-UX / workspace+tenant isolation: PASS — use existing tenant-context Filament routes; non-members 404; members lacking capability 403. +- Run observability: PASS — both capture + compare are queued `OperationRun`s using `OperationRunService`. +- Ops-UX 3-surface feedback: PASS — existing run-start surfaces already enqueue; run detail is canonical. +- Ops-UX summary counts: PASS — any new detail stays in `context`; summary keys remain from `OperationSummaryKeys::all()`. +- Badge semantics: PASS — new badges/filters will follow existing UI patterns (no ad-hoc status mappings). +- Filament action surface contract: PASS — modifying Finding list UI adds filter + badge only; no new destructive actions. + +## Project Structure + +### Documentation (this feature) + +```text +specs/117-baseline-drift-engine/ +├── spec.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +└── contracts/ + └── openapi.yaml +``` + +Next: generate `specs/117-baseline-drift-engine/tasks.md` via `/speckit.tasks`. + +### Source Code (repository root) + +```text +app/ +├── Jobs/ +│ ├── CaptureBaselineSnapshotJob.php +│ └── CompareBaselineToTenantJob.php +├── Models/ +│ ├── BaselineSnapshot.php +│ ├── BaselineSnapshotItem.php +│ ├── InventoryItem.php +│ ├── Policy.php +│ ├── PolicyVersion.php +│ └── Finding.php +├── Services/ +│ ├── Baselines/ +│ │ ├── BaselineCompareService.php +│ │ ├── BaselineSnapshotIdentity.php +│ │ └── (new) CurrentStateHashResolver + providers +│ └── Drift/ +│ ├── DriftHasher.php +│ └── Normalizers/SettingsNormalizer.php +└── Filament/ + ├── Pages/BaselineCompareLanding.php + └── Resources/FindingResource.php + +database/migrations/ +└── (new) add evidence_fidelity to findings (required for filtering) + +tests/ +└── Feature/ (new/updated Pest tests for resolver + compare + UI filter) +``` + +**Structure Decision**: Implement as an incremental change within existing Baselines/Findings domains (no new modules). + +## Complexity Tracking + +No constitution violations are required for v1.5. (Table intentionally empty.) + +## Phase 0 — Research (output: research.md) + +Goals: +- Confirm current baseline capture/compare logic and identify the precise extension points. +- Decide where fidelity/provenance should be stored (JSONB vs columns) to support filtering. +- Confirm real route URIs/names for contracts (Filament + Monitoring). + +Deliverable: [specs/117-baseline-drift-engine/research.md](specs/117-baseline-drift-engine/research.md) + +## Phase 1 — Design (output: data-model.md + contracts/* + quickstart.md) + +Deliverables: +- Data model changes and JSON shapes: [specs/117-baseline-drift-engine/data-model.md](specs/117-baseline-drift-engine/data-model.md) +- Endpoint reference contract (no new APIs; documented existing routes): [specs/117-baseline-drift-engine/contracts/openapi.yaml](specs/117-baseline-drift-engine/contracts/openapi.yaml) +- Developer quickstart: [specs/117-baseline-drift-engine/quickstart.md](specs/117-baseline-drift-engine/quickstart.md) + +Post-design constitution re-check: PASS (see Phase 1 notes in research/design outputs). + +## Phase 2 — Implementation Planning (high-level) + +1) Implement `CurrentStateHashResolver` (provider chain + batch resolution). +2) Update baseline capture to store best available hash (content if present, else meta) + provenance. +3) Update baseline compare to: + - use resolver with `since = baseline_snapshots.captured_at` + - record evidence gaps in run context + - emit findings with baseline+current provenance +4) Update Findings UI: + - show fidelity badge + - add fidelity filter (content/meta) + - display baseline+current provenance fields +5) Add migration for `findings.evidence_fidelity` (required for fast, stable fidelity filtering). +6) Add Pest tests (resolver behavior, compare integration, UI filter query behavior). +7) Run `vendor/bin/sail bin pint --dirty --format agent` and focused tests. + +## Scope note (v2.0) + +- v2.0 “full content capture mode for Golden Master” is explicitly out of scope for v1.5 implementation tasks. +- v1.5 remains opportunistic: compare is DB-only and consumes only existing stored evidence. diff --git a/specs/117-baseline-drift-engine/quickstart.md b/specs/117-baseline-drift-engine/quickstart.md new file mode 100644 index 0000000..a52fb9e --- /dev/null +++ b/specs/117-baseline-drift-engine/quickstart.md @@ -0,0 +1,35 @@ +# Quickstart — Spec 117 Baseline Drift Engine + +## 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) Capture a baseline snapshot via the existing Baseline UI. +2) Trigger “Compare baseline to tenant” via the existing compare landing page. +3) Open Monitoring → the `OperationRun` for the compare: + - Verify `context.baseline_compare.coverage` and `context.baseline_compare.evidence_gaps` are populated. +4) Open Findings: + - Verify each finding shows a fidelity badge (`content` or `meta`). + - Verify provenance is shown for both baseline and current evidence: fidelity, source, observed timestamp. + - Verify filtering by fidelity works. + +## Developer notes + +- v1.5 compare must not fetch anything upstream. Evidence sources are strictly: + - `PolicyVersion` (content) since baseline snapshot `captured_at` + - Inventory meta contract (meta) +- When neither side has evidence for a subject, no finding should be created; the compare run should record an evidence gap. + +## 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/117-baseline-drift-engine/research.md b/specs/117-baseline-drift-engine/research.md new file mode 100644 index 0000000..845a632 --- /dev/null +++ b/specs/117-baseline-drift-engine/research.md @@ -0,0 +1,93 @@ +# Research — Spec 117 Baseline Drift Engine (v1.5) + +This document resolves planning unknowns for implementing `specs/117-baseline-drift-engine/spec.md` in the existing Laravel + Filament codebase. + +## Decision 1 — Provider chain for evidence + +**Decision**: Implement a batch-capable resolver (service) that selects evidence per subject via a provider chain: + +1) **Content evidence**: `PolicyVersion.snapshot` (normalized) captured **since** baseline snapshot captured time. +2) **Meta evidence**: Inventory meta contract hash (existing `BaselineSnapshotIdentity::hashItemContent(...)`). +3) **No evidence**: return `null` and record evidence gap (no finding emitted). + +**Rationale**: +- Matches spec’s “since” rule: baseline snapshot `captured_at` is the temporal reference. +- Enables v1.5 requirement: compare is read-only and must not fetch upstream. +- Batch resolution avoids N+1 DB queries. + +**Alternatives considered**: +- Per-subject resolving inside compare job (rejected: N+1 + harder to test). +- Always meta-only (rejected: violates “deep settings drift” requirement). + +## Decision 2 — Fidelity calculation + +**Decision**: Compute finding fidelity as the weaker-of the two sides: + +- `content` is stronger than `meta`. +- If either side is `meta`, overall fidelity is `meta`. + +**Rationale**: +- Matches clarified spec rule. +- Easy to implement and consistent with UX badge/filter semantics. + +**Alternatives considered**: +- “best-of” fidelity (rejected: misleading; would claim content-level confidence when one side is meta-only). + +## Decision 3 — Provenance storage (both sides) + +**Decision**: Store provenance for **both baseline and current evidence** on each finding: + +- `baseline`: `{ fidelity, source, observed_at, observed_operation_run_id? }` +- `current`: `{ fidelity, source, observed_at, observed_operation_run_id? }` + +**Rationale**: +- Required by accepted clarification (Q4). +- Enables UI to show the “why” behind confidence. + +**Alternatives considered**: +- Store a single combined provenance blob (rejected: loses per-side data). + +## Decision 4 — Fidelity filter implementation (JSONB vs column) + +**Decision**: Add a dedicated `findings.evidence_fidelity` column (enum-like string: `content|meta`) and keep full provenance in `evidence_jsonb`. + +**Rationale**: +- Filtering on a simple indexed column is clean, fast, and predictable. +- Avoids complex JSONB query conditions and reduces coupling to evidence JSON structure. + +**Alternatives considered**: +- JSONB filter over `evidence_jsonb->>'fidelity'` (rejected: harder indexing, more brittle). + +## Decision 5 — Coverage and evidence-gap reporting location + +**Decision**: Put coverage breakdown and evidence-gap counts in `operation_runs.context` under `baseline_compare.coverage` and `baseline_compare.evidence_gaps`. + +**Rationale**: +- `OperationRun.summary_counts` is restricted to numeric keys from `OperationSummaryKeys`. +- Coverage details are operational diagnostics, not a general-purpose KPI. + +**Alternatives considered**: +- Add new summary keys (rejected: violates key whitelist / contract). + +## Decision 6 — No new HTTP APIs + +**Decision**: No new controllers/endpoints. Changes are: + +- queued job behavior + persistence (findings + run context) +- Filament UI (Finding list filters + columns/details) + +**Rationale**: +- The app is Filament-first; current functionality is already represented by panel routes. + +**Alternatives considered**: +- Add REST endpoints for compare (rejected: not needed for v1.5). + +## Notes on current codebase (facts observed) + +- Baseline capture stores meta contract hash into `baseline_snapshot_items.baseline_hash` and provenance-like fields in `meta_jsonb`. +- Baseline compare recomputes current meta hash and currently hardcodes run context fidelity to `meta`. +- Findings UI lacks fidelity filtering today. + +## Open Questions + +None blocking Phase 1 design. Any remaining unknowns are implementation details that will be validated with focused tests. diff --git a/specs/117-baseline-drift-engine/spec.md b/specs/117-baseline-drift-engine/spec.md new file mode 100644 index 0000000..6cf9ce4 --- /dev/null +++ b/specs/117-baseline-drift-engine/spec.md @@ -0,0 +1,260 @@ +# Feature Specification: Golden Master Baseline Drift — Deep Settings Drift via Provider Chain + +**Feature Branch**: `117-baseline-drift-engine` +**Created**: 2026-03-02 +**Status**: Draft (ready for implementation) +**Input**: User description: "Spec 117 — Golden Master Baseline Drift: Deep Settings-Drift via PolicyVersion Provider Chain" + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace (baseline definition + capture) + tenant (baseline compare monitoring) +- **Primary Routes**: + - Workspace (admin): Baseline Profiles (capture baseline snapshot) + - Tenant-context (admin): Baseline Compare runs (compare now, run detail) and Drift Findings landing +- **Data Ownership**: + - Workspace-owned: baseline profiles, baseline snapshots, baseline snapshot items + - Tenant-scoped (within a workspace): compare runs and drift findings produced by compare + - Baseline snapshots are workspace-owned standards captured from a chosen tenant, but snapshot items MUST NOT persist tenant identifiers. +- **RBAC**: no new RBAC surfaces; uses existing baseline + findings capabilities + - Workspace Baselines: + - `workspace_baselines.view` + - `workspace_baselines.manage` + - Tenant Compare + Findings: + - `tenant.sync` + - `tenant_findings.view` + - Tenant access is required for tenant-context surfaces, in addition to workspace membership + +For canonical-view specs: not applicable (this is not a canonical-view feature). + +## Clarifications + +### Session 2026-03-02 + +- Q: Which baseline timestamp is the reference for the “since” rule? → A: Baseline snapshot captured time. +- Q: What should v1.5 do when neither content nor meta evidence exists for a subject? → A: Skip the subject and record it in run coverage/warnings (no drift finding for it). +- Q: If baseline and current have different fidelity, what fidelity should the finding badge/filter show? → A: Overall fidelity = the weaker of baseline/current. +- Q: Should a finding store/show provenance for both baseline and current evidence? → A: Yes, store/show both baseline and current evidence (each with fidelity, source, observed timestamp). + +## Problem Statement + +Golden Master baseline compare currently relies on a “meta-only” drift signal for many policy types. Changes that only affect policy settings (but do not materially change meta fields) frequently produce no drift finding, which makes the feature unreliable and undermines operator trust. + +At the same time, the system already has a proven deep drift mechanism in other workflows, based on captured full policy content and a deterministic normalization + hashing pipeline. + +This spec upgrades Golden Master baseline drift to use that same evidence layer whenever available, without introducing a second compare logic path. + +## Goals (v1.5) + +- Detect settings-level drift in Golden Master compares when suitable captured policy content exists. +- Keep compare read-only against existing evidence (no additional external data fetches during compare). +- Allow mixed fidelity (some types/content have deep evidence, others are meta-only), but make it transparent in findings and run detail. +- Guarantee “one engine”: compare does not contain per-type special-casing or duplicate hashing logic. + +## Non-Goals (v1.5) + +- No “always fetch full content” during compare. +- No new enrichment pipeline that captures full content as part of inventory. +- No unification of backup and Golden Master workflows; only the evidence/hash layer is shared. + +## Architecture Decision + +**ADR-117-01: Separate workflows, shared evidence layer** + +- Backup/versioning and Golden Master baseline drift remain separate workflows (different triggers and scoping). +- Golden Master drift consumes a shared evidence layer that can provide the best available “current state” hash for a subject. +- Evidence is resolved via a provider chain in strict precedence order. + +## Definitions + +- **Subject key**: a policy identity independent of tenant, identified by policy type + external identifier. +- **Tenant subject**: a subject key within a tenant context, identified by tenant + policy type + external identifier. +- **Policy content version**: an immutable captured representation of a tenant subject’s policy content, with an observation timestamp. +- **Fidelity**: + - **content**: drift signal derived from canonicalized policy content (deep / semantic) + - **meta**: drift signal derived from a stable “inventory meta contract” (structural / signal-based) +- **Provider chain**: an ordered resolver that returns the first available policy state evidence for a tenant subject. + +## Assumptions + +- For v1.5, content-fidelity evidence becomes available opportunistically (e.g., because a backup or other capture workflow already ran). +- The existing normalization + hashing pipeline for content fidelity is the canonical source of truth for deep drift. +- Baseline capture and baseline compare already use observable run records; this spec extends run context and findings evidence details. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Deep settings drift appears when content evidence exists (Priority: P1) + +As an operator, I want Golden Master compares to detect settings-level drift when deep policy content evidence is available, so I can trust that “no drift” actually means the settings match. + +**Why this priority**: Without settings drift, Golden Master is not reliable for configuration governance. + +**Independent Test**: Can be tested by capturing a baseline snapshot, ensuring a policy content version exists for the same subject after the baseline timestamp, then running compare and asserting a drift finding is produced when settings differ. + +**Acceptance Scenarios**: + +1. **Given** a baseline snapshot item for a subject and a newer content evidence record for the tenant subject, **When** compare runs, **Then** the system uses content fidelity for current-state hashing and produces a “different version” finding when settings differ. +2. **Given** a baseline snapshot item and a newer content evidence record where settings match, **When** compare runs, **Then** the system produces no “different version” finding and records content fidelity in run coverage context. + +--- + +### User Story 2 - Mixed fidelity is transparent and interpretable (Priority: P1) + +As an operator, I want each drift finding to clearly communicate how strong the drift signal is (content vs meta) and what evidence source was used, so I can interpret and prioritize findings correctly. + +**Why this priority**: Mixed fidelity is acceptable only when it is obvious to the operator. + +**Independent Test**: Can be tested by running compare where some subjects have content evidence and others do not, then asserting that findings include fidelity and source and the UI can filter by fidelity. + +**Acceptance Scenarios**: + +1. **Given** a compare run with a mix of subjects where some have content evidence and others only meta evidence, **When** I view drift findings, **Then** each finding shows a fidelity badge and displays baseline + current evidence provenance (fidelity, source, observed timestamp), and I can filter findings by fidelity. +2. **Given** a compare run with mixed fidelity, **When** I open run detail, **Then** I see a coverage breakdown that distinguishes content-covered types vs meta-only types. + +--- + +### User Story 3 - Baseline capture uses best available evidence (Priority: P2) + +As a workspace admin, I want baseline capture to store the strongest available hash for each snapshot item at capture time (content if available, otherwise meta), so baseline comparisons become more reliable without extra steps. + +**Why this priority**: Improves trust and reduces “meta-only baseline” limitations without introducing extra costs. + +**Independent Test**: Can be tested by capturing a baseline when content evidence exists for some subjects and not others, then asserting that snapshot items store fidelity/source accordingly. + +**Acceptance Scenarios**: + +1. **Given** baseline capture where content evidence exists for some in-scope subjects, **When** capture completes, **Then** those snapshot items record fidelity=`content` and a content evidence source indicator. +2. **Given** baseline capture where no content evidence exists for a subject, **When** capture completes, **Then** the snapshot item falls back to meta fidelity and records the meta source indicator. + +### Edge Cases + +- Content evidence exists for a tenant subject but is older than the baseline snapshot timestamp: it must not be used for “current state” when it would produce a temporally incorrect compare. +- Content evidence exists but cannot be normalized deterministically (unexpected shape): compare must fall back to meta fidelity and record a warning/evidence note. +- Coverage for a policy type is unproven for the compare run: findings for that type remain suppressed as per baseline drift coverage rules, regardless of fidelity. +- No evidence exists for a subject from any provider: the subject is skipped, and the run records an evidence-gap warning/coverage entry (no drift finding for that subject). +- Large scopes: evidence resolution is performed in batches (avoid per-subject lookups) so compare runtime scales predictably. + +## Requirements *(mandatory)* + +This feature reuses long-running baseline capture/compare runs and extends what evidence is used for hashing. + +### Constitution alignment (required) + +- v1.5 compare does not initiate any new external data fetches; it only consumes existing stored evidence. +- Findings and run context become more audit-friendly by persisting fidelity + source metadata. + +### Operational UX Contract (Ops-UX) + +- Capture and compare runs continue to use observable run identity and outcomes. +- Any new summary counters remain numeric-only and use existing canonical keys (additional detail stays in run context). +- Mixed fidelity coverage is recorded in run context for operator interpretation. + +### Authorization Contract (RBAC-UX) + +- No new authorization planes are introduced. +- Existing semantics apply: + - Non-member / not entitled to workspace or tenant scope: deny-as-not-found behavior + - Member but missing capability: forbidden behavior +- UI visibility does not replace server-side enforcement. + +### Functional Requirements + +#### v1.5 — Opportunistic deep drift (no new upstream calls during compare) + +- **FR-117v15-01 Current hash resolution layer**: The system MUST provide a single resolution layer that can return a deterministic “current state” hash for a tenant subject, including: + - the deterministic hash + - the fidelity level (content or meta) + - a human-readable source/provenance indicator + - the timestamp of when the evidence was observed + - If no evidence exists for a tenant subject, the resolver returns `null`. + +- **FR-117v15-01b Null evidence handling**: When the resolver returns null for a subject, compare and capture MUST: + - skip drift evaluation for that subject + - record the subject as an evidence gap in the run’s coverage/warnings context + - not create a drift finding for that subject + +- **FR-117v15-02 Provider chain precedence**: The system MUST resolve current-state hashes using an ordered provider chain: + 1) content evidence provider (latest content version since a reference time) + 2) meta evidence provider (inventory meta contract) + First non-null wins. + +- **FR-117v15-03 Content evidence since-rule**: For compares, the resolver MUST treat the baseline snapshot timestamp as the reference time (`since`) to avoid using content evidence older than the baseline. +- **FR-117v15-03a Timestamp definition**: “Baseline snapshot timestamp” refers to the baseline snapshot captured time. + +- **FR-117v15-04 Baseline compare integration (no legacy hashing)**: Baseline compare MUST NOT perform direct meta hashing logic. It MUST: + - read baseline snapshot item hashes + fidelity/source + - resolve current-state hashes exclusively via the provider chain + - compute drift by comparing baseline hash vs current hash + - attach evidence fields to findings (at minimum: fidelity, source, and observed timestamp) + +- **FR-117v15-04b Finding provenance (both sides)**: When a drift finding is emitted, it MUST record provenance for both sides: + - baseline evidence: fidelity, source, and observed timestamp (as recorded on the baseline snapshot item) + - current evidence: fidelity, source, and observed timestamp (as resolved for the tenant subject) + +- **FR-117v15-04a Finding fidelity semantics (mixed evidence)**: When a drift finding is emitted, the finding MUST expose an overall fidelity value suitable for badges and filtering. + - If either baseline evidence or current evidence is meta fidelity, the overall fidelity MUST be meta. + - Only when both sides are content fidelity may the overall fidelity be content. + +- **FR-117v15-05 Baseline capture opportunistic fidelity**: Baseline capture MUST attempt to store snapshot item hashes via the same provider chain: + - If content evidence exists at capture time, store content fidelity hash for the baseline item. + - Otherwise store meta fidelity hash. + - Snapshot items MUST persist the fidelity and source as audit properties. + +- **FR-117v15-06 Coverage breakdown (content vs meta)**: Compare run context MUST include a coverage breakdown that distinguishes: + - policy types with content evidence coverage + - policy types that are meta-only + - policy types uncovered (as per existing coverage guard rules) + +- **FR-117v15-07 UX: fidelity transparency**: Drift findings UI MUST: + - show a fidelity badge per finding (content = high confidence, meta = structural only) + - allow filtering findings by fidelity + - show baseline + current evidence provenance per finding (fidelity, source, observed timestamp for each side) + - show run detail coverage breakdown (content vs meta) + +- **FR-117v15-07a Fidelity filter values**: The findings fidelity filter MUST support exactly two values in v1.5: content and meta (no separate “mixed” value). + +- **FR-117v15-08 Performance guard**: Evidence resolution MUST be batch-oriented to avoid per-subject query behavior as scope size increases. + +- **FR-117v15-09 Evidence source tracking on snapshot items**: Baseline snapshot items MUST persist a source indicator (string) for the hash used, and MUST default it consistently for meta-only items (e.g., an explicit versioned meta source). + +#### v2.0 (optional) — Full content capture mode for Golden Master + +- **FR-117v2-01 Capture mode**: Baseline profiles MUST support a capture mode concept with at least: + - meta-only + - opportunistic (v1.5 default) + - full content (opt-in) + +- **FR-117v2-02 Targeted content capture**: When full content is enabled, the system MUST be able to capture content evidence for in-scope tenant subjects missing “fresh enough” content evidence. + +- **FR-117v2-03 Quota/budget safety**: Full content capture MUST be resumable and quota-aware, and MUST record completion/skips/throttling indicators in run context. + +- **FR-117v2-04 Provider chain extension point**: The provider chain MAY gain additional providers between content and meta as evidence sources evolve, without changing compare semantics. + +## UI Action Matrix *(mandatory when Filament is changed)* + +This spec changes how findings/run detail surfaces present evidence (badges/filters/coverage breakdown). No new destructive actions are introduced. + +| 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Drift Findings landing | admin tenant-context | none (existing) | open finding / run detail (existing) | none (existing) | none | existing CTA | none | n/a | no new | Add fidelity badge + fidelity filter; no new mutations | +| Compare run detail | admin tenant-context | none (existing) | open findings (existing) | none (existing) | none | n/a | none | n/a | no new | Add coverage breakdown: content vs meta vs uncovered | +| Baseline capture surfaces | admin workspace | existing capture start | open snapshot detail (existing) | none | none | existing CTA | none | existing | yes (existing) | Capture stores fidelity/source per snapshot item | + +### Key Entities *(include if feature involves data)* + +- **Current State Hash Resolver**: Ordered resolver that returns the best available deterministic hash for a tenant subject, plus fidelity/source/observed_at. +- **Content Evidence Record**: Immutable captured policy content version with timestamps and subject identity. +- **Meta Evidence Record**: Stable “inventory meta contract” payload used for structural hashing. +- **Baseline Snapshot Item**: Workspace-owned baseline item for a subject key, storing baseline hash + fidelity/source audit fields. +- **Drift Finding Evidence**: Finding fields that capture the evidence fidelity and provenance used to compute the drift signal. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-117-01 Settings drift detection**: When content evidence exists for a changed subject, a compare run produces a drift finding for settings-only changes with a success rate of at least 95% in controlled tests. + - **Controlled tests definition (CI)**: a deterministic fixture matrix executed via Pest that includes at least 20 settings-only change cases (across at least 3 policy types) where content evidence exists and is newer than the baseline captured time. + - **Pass criteria (CI)**: at least 19/20 cases produce the expected drift outcome (finding emitted when different; no finding when equal). +- **SC-117-02 Transparency**: 100% of “different version” findings display baseline + current evidence provenance (fidelity, source, observed timestamp for each side), and operators can filter findings by fidelity. +- **SC-117-03 Compare cost containment (v1.5)**: Compare runs complete without initiating any new upstream data fetches. +- **SC-117-04 Performance**: For a baseline scope of 500 subjects, evidence resolution and compare complete within an agreed operational time budget (target: ≤ 2 minutes in a typical staging environment). + - **Guardrail (CI)**: resolver evidence lookups are batch-oriented and remain set-based (no per-subject query loops). A performance guard test enforces an upper bound on query count for resolving a representative batch. \ No newline at end of file diff --git a/specs/117-baseline-drift-engine/tasks.md b/specs/117-baseline-drift-engine/tasks.md new file mode 100644 index 0000000..57594c2 --- /dev/null +++ b/specs/117-baseline-drift-engine/tasks.md @@ -0,0 +1,157 @@ +--- + +description: "Task list for implementing Spec 117 (Baseline Drift Engine)" +--- + +# Tasks: Golden Master Baseline Drift — Deep Settings Drift via Provider Chain + +**Input**: Design documents from `/specs/117-baseline-drift-engine/` +**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/, quickstart.md + +**Tests**: REQUIRED (Pest) — runtime behavior + persistence + Filament UI changes + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Ensure local/dev environment can run/verify this feature end-to-end. + +- [x] T001 Confirm Sail + queue runner workflow in quickstart.md at specs/117-baseline-drift-engine/quickstart.md +- [x] T002 Create Baseline drift engine test suite skeleton in tests/Feature/BaselineDriftEngine/ResolverTest.php + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core building blocks that MUST be complete before any user story work. + +- [x] T003 Define a stable evidence/provenance JSON shape constants/helpers in app/Services/Baselines (new file app/Services/Baselines/Evidence/EvidenceProvenance.php) +- [x] T004 Implement provider-chain interfaces + DTO for resolved evidence in app/Services/Baselines (new files app/Services/Baselines/CurrentStateHashResolver.php and app/Services/Baselines/Evidence/ResolvedEvidence.php) +- [x] T005 [P] Implement content evidence provider (PolicyVersion since-rule) in app/Services/Baselines/Evidence/ContentEvidenceProvider.php +- [x] T006 [P] Implement meta evidence provider (Inventory meta contract) in app/Services/Baselines/Evidence/MetaEvidenceProvider.php +- [x] T007 Implement batch-oriented resolution orchestration (first-non-null wins) in app/Services/Baselines/CurrentStateHashResolver.php +- [x] T008 Add resolver precedence + since-rule tests in tests/Feature/BaselineDriftEngine/ResolverTest.php + +**Checkpoint**: Resolver exists, is batch-capable, and is test-covered. + +--- + +## Phase 3: User Story 1 — Deep settings drift appears when content evidence exists (Priority: P1) 🎯 MVP + +**Goal**: Compare uses content evidence (PolicyVersion) when available since baseline captured time, producing correct drift findings. + +**Independent Test**: Create baseline snapshot item + newer content evidence; run compare job; assert finding created for changed content and not created for equal content. + +- [x] T009 [US1] Add factories for baseline snapshots/items and policy versions in database/factories/BaselineSnapshotFactory.php, database/factories/BaselineSnapshotItemFactory.php, database/factories/PolicyVersionFactory.php +- [x] T010 [US1] Extend CompareBaselineToTenantJob to resolve current hashes via CurrentStateHashResolver (no direct meta hashing) in app/Jobs/CompareBaselineToTenantJob.php +- [x] T011 [US1] Ensure compare uses baseline snapshot captured_at as `since` for content evidence provider in app/Jobs/CompareBaselineToTenantJob.php +- [x] T012 [US1] Implement null-evidence handling: skip subject + record evidence gap in run context (no finding) in app/Jobs/CompareBaselineToTenantJob.php +- [x] T013 [US1] Add integration test: content evidence produces finding when different in tests/Feature/BaselineDriftEngine/CompareContentEvidenceTest.php +- [x] T014 [US1] Add integration test: content evidence produces no finding when equal in tests/Feature/BaselineDriftEngine/CompareContentEvidenceTest.php + +**Note (SC-117-01)**: Expand the integration coverage to a deterministic fixture matrix (at least 20 settings-only change cases across at least 3 policy types) so “≥95% success rate” is measurable in CI. + +**Checkpoint**: US1 is shippable on its own (deep drift works when content exists; compare remains DB-only). + +--- + +## Phase 4: User Story 2 — Mixed fidelity is transparent and interpretable (Priority: P1) + +**Goal**: Findings show fidelity badge + provenance (baseline + current) and can be filtered by fidelity; compare run detail gets coverage breakdown (content vs meta vs gaps). + +**Independent Test**: Run compare with a mixed set of subjects (some content, some meta, some missing) and assert fidelity/provenance stored and filter works. + +### Data & persistence + +- [x] T015 [US2] Add migration for findings.evidence_fidelity + index + backfill to meta in database/migrations/2026_03_02_000001_add_evidence_fidelity_to_findings_table.php +- [x] T016 [US2] Update Finding creation to persist evidence JSON with baseline+current provenance and set evidence_fidelity (weaker-of) in app/Jobs/CompareBaselineToTenantJob.php +- [x] T017 [US2] Store compare coverage breakdown + evidence gaps in operation_runs.context (not summary_counts) in app/Jobs/CompareBaselineToTenantJob.php + +### Filament UI + +- [x] T018 [US2] Add fidelity badge column (content/meta) to Finding list table in app/Filament/Resources/FindingResource.php +- [x] T019 [US2] Add fidelity filter with exactly two values (content, meta) to FindingResource table filters in app/Filament/Resources/FindingResource.php +- [x] T020 [US2] Update Finding view/record display to show baseline+current provenance fields from evidence_jsonb in app/Filament/Resources/FindingResource.php + +### Tests + +- [x] T021 [P] [US2] Add DB assertion tests for evidence JSON provenance (both sides) in tests/Feature/BaselineDriftEngine/FindingProvenanceTest.php +- [x] T022 [P] [US2] Add tests for weaker-of fidelity semantics (meta dominates) in tests/Feature/BaselineDriftEngine/FindingFidelityTest.php +- [x] T023 [P] [US2] Add tests for fidelity filter query behavior (content vs meta) in tests/Feature/BaselineDriftEngine/FindingFidelityFilterTest.php +- [x] T032 [US2] Prevent cross-fidelity hash comparisons (meta vs content) and add mismatch coverage tests in app/Jobs/CompareBaselineToTenantJob.php and tests/Feature/BaselineDriftEngine/CompareFidelityMismatchTest.php + +**Checkpoint**: Mixed fidelity is visible, filterable, and provenance is complete. + +--- + +## Phase 5: User Story 3 — Baseline capture uses best available evidence (Priority: P2) + +**Goal**: Baseline capture stores the strongest available hash per snapshot item (content if present, else meta) and stores provenance on the snapshot item. + +**Independent Test**: Run capture job with mixed evidence availability and assert snapshot items record expected hash + provenance. + +- [x] T024 [US3] Update CaptureBaselineSnapshotJob to resolve baseline hashes via CurrentStateHashResolver (provider chain) in app/Jobs/CaptureBaselineSnapshotJob.php +- [x] T025 [US3] Store baseline-side provenance (fidelity/source/observed_at) in baseline_snapshot_items.meta_jsonb in app/Jobs/CaptureBaselineSnapshotJob.php +- [x] T026 [P] [US3] Add test: capture stores content fidelity when available in tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php +- [x] T027 [P] [US3] Add test: capture falls back to meta fidelity when content missing in tests/Feature/BaselineDriftEngine/CaptureBaselineMetaFallbackTest.php + +**Checkpoint**: Baseline capture produces stronger baseline items without extra workflows. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Guardrails, performance checks, and verification. + +- [x] T028 Add batch-performance guard tests to prevent per-subject query loops in app/Services/Baselines/CurrentStateHashResolver.php and tests/Feature/BaselineDriftEngine/PerformanceGuardTest.php + +**Note (SC-117-04)**: Performance guard should enforce a fixed query upper bound for resolving a representative batch (e.g., 500 subjects resolved in ≤ 20 queries, excluding factory/seed setup), proving set-based batch behavior. +- [x] T029 [P] Verify compare remains read-only/no upstream calls by scanning CompareBaselineToTenantJob for Graph client usage in app/Jobs/CompareBaselineToTenantJob.php +- [x] T030 [P] Run formatter and focused tests per quickstart.md: `vendor/bin/sail bin pint --dirty --format agent` and `vendor/bin/sail artisan test --compact --filter=Baseline` (document in specs/117-baseline-drift-engine/quickstart.md if updates needed) +- [x] T031 Verify OperationRun lifecycle transitions remain service-owned (no direct status/outcome updates) when editing jobs in app/Jobs/CompareBaselineToTenantJob.php and app/Jobs/CaptureBaselineSnapshotJob.php + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: Can start immediately. +- **Foundational (Phase 2)**: Depends on Phase 1; BLOCKS all user stories. +- **US1 (Phase 3)**: Depends on Phase 2. +- **US2 (Phase 4)**: Depends on US1 (needs compare emitting findings + evidence) and Phase 2. +- **US3 (Phase 5)**: Depends on Phase 2 (reuses resolver). +- **Polish (Phase 6)**: Depends on completing at least US1+US2; US3 optional. + +### User Story completion order (dependency graph) + +- US1 → US2 +- US3 is independent after Foundational + +--- + +## Parallel execution examples + +### Foundational parallel work + +- [P] T005 (ContentEvidenceProvider) and [P] T006 (MetaEvidenceProvider) can be implemented in parallel. +- T008 resolver tests can be written while providers are being implemented (same file as T002; coordinate to avoid merge conflicts). + +### US1 parallel work + +- T013 and T014 live in the same test file; implement sequentially or split into separate files if you want true parallel work. + +### US2 parallel work + +- [P] T021–T023 tests can be written in parallel with UI tasks T018–T020. + +--- + +## Implementation strategy (MVP first) + +1) Phase 1–2 (resolver + providers) ✔︎ +2) Phase 3 (US1) — ship deep drift in compare where content exists +3) Phase 4 (US2) — add fidelity/provenance UX + filtering + run coverage breakdown +4) Phase 5 (US3) — upgrade capture to best available evidence + +## Explicit out-of-scope (v1.5) + +- v2.0 optional requirements in specs/117-baseline-drift-engine/spec.md are intentionally not implemented by this tasks list. diff --git a/tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php b/tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php new file mode 100644 index 0000000..8cd46d2 --- /dev/null +++ b/tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php @@ -0,0 +1,112 @@ +active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'policy-capture-content', + 'platform' => 'windows', + 'display_name' => 'Policy Capture Content', + ]); + + $inventory = 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_META', + 'scope_tag_ids' => [], + 'assignment_target_count' => 1, + ], + 'last_seen_at' => now()->subHour(), + ]); + + $snapshotPayload = [ + 'settings' => [ + ['displayName' => 'SettingX', 'value' => 1], + ], + ]; + + $capturedAt = now(); + + 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' => $capturedAt, + 'snapshot' => $snapshotPayload, + ]); + + $expectedContentHash = app(DriftHasher::class)->hashNormalized( + app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'), + ); + + $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(); + + $item = BaselineSnapshotItem::query() + ->where('baseline_snapshot_id', (int) $snapshot->getKey()) + ->where('subject_external_id', (string) $policy->external_id) + ->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/CaptureBaselineMetaFallbackTest.php b/tests/Feature/BaselineDriftEngine/CaptureBaselineMetaFallbackTest.php new file mode 100644 index 0000000..2e5895d --- /dev/null +++ b/tests/Feature/BaselineDriftEngine/CaptureBaselineMetaFallbackTest.php @@ -0,0 +1,99 @@ +active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'policy-capture-meta', + 'platform' => 'windows', + 'display_name' => 'Policy Capture Meta', + ]); + + $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(), + ]); + + $inventory = 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_META_ONLY', + 'scope_tag_ids' => [], + 'assignment_target_count' => 1, + ], + 'last_seen_operation_run_id' => (int) $lastSeenRun->getKey(), + 'last_seen_at' => now()->subHour(), + ]); + + $expectedMetaHash = app(BaselineSnapshotIdentity::class)->hashItemContent( + policyType: (string) $inventory->policy_type, + subjectExternalId: (string) $inventory->external_id, + metaJsonb: is_array($inventory->meta_jsonb) ? $inventory->meta_jsonb : [], + ); + + $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(); + + $item = BaselineSnapshotItem::query() + ->where('baseline_snapshot_id', (int) $snapshot->getKey()) + ->where('subject_external_id', (string) $policy->external_id) + ->sole(); + + expect($item->baseline_hash)->toBe($expectedMetaHash); + + $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()); +}); diff --git a/tests/Feature/BaselineDriftEngine/CompareContentEvidenceTest.php b/tests/Feature/BaselineDriftEngine/CompareContentEvidenceTest.php new file mode 100644 index 0000000..6f5e53b --- /dev/null +++ b/tests/Feature/BaselineDriftEngine/CompareContentEvidenceTest.php @@ -0,0 +1,236 @@ +active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ]); + + $baselineCapturedAt = CarbonImmutable::parse('2026-03-01 00:00:00'); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + 'captured_at' => $baselineCapturedAt, + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'policy-a', + 'platform' => 'windows', + 'display_name' => 'Policy A', + ]); + + $baselineSnapshot = [ + 'settings' => [ + ['displayName' => 'SettingA', 'value' => 1], + ], + ]; + + $baselineHash = app(DriftHasher::class)->hashNormalized( + app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshot, 'deviceConfiguration', 'windows'), + ); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => (string) $policy->external_id, + 'policy_type' => (string) $policy->policy_type, + 'baseline_hash' => $baselineHash, + 'meta_jsonb' => [ + 'display_name' => 'Policy A', + 'evidence' => [ + 'fidelity' => 'content', + 'source' => 'policy_version', + 'observed_at' => $baselineCapturedAt->toIso8601String(), + 'observed_operation_run_id' => null, + ], + ], + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + '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' => 'W/"same-etag"', + 'scope_tag_ids' => [], + 'assignment_target_count' => 1, + ], + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + 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' => $baselineCapturedAt->addHour(), + 'snapshot' => [ + 'settings' => [ + ['displayName' => 'SettingA', 'value' => 2], + ], + ], + ]); + + $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, + ); + + $finding = Finding::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('subject_external_id', (string) $policy->external_id) + ->first(); + + expect($finding)->toBeInstanceOf(Finding::class); + expect($finding?->evidence_jsonb['current']['provenance']['fidelity'] ?? null)->toBe('content'); + expect($finding?->evidence_jsonb['baseline']['provenance']['fidelity'] ?? null)->toBe('content'); +}); + +it('Baseline compare uses content evidence and produces no finding when content is equal', function () { + [$user, $tenant] = createUserWithTenant(); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ]); + + $baselineCapturedAt = CarbonImmutable::parse('2026-03-01 00:00:00'); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + 'captured_at' => $baselineCapturedAt, + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'policy-b', + 'platform' => 'windows', + 'display_name' => 'Policy B', + ]); + + $snapshotPayload = [ + 'settings' => [ + ['displayName' => 'SettingB', 'value' => 1], + ], + ]; + + $baselineHash = app(DriftHasher::class)->hashNormalized( + app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'), + ); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => (string) $policy->external_id, + 'policy_type' => (string) $policy->policy_type, + 'baseline_hash' => $baselineHash, + 'meta_jsonb' => [ + 'display_name' => 'Policy B', + 'evidence' => [ + 'fidelity' => 'content', + 'source' => 'policy_version', + 'observed_at' => $baselineCapturedAt->toIso8601String(), + 'observed_operation_run_id' => null, + ], + ], + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + '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' => 'W/"same-etag"', + 'scope_tag_ids' => [], + 'assignment_target_count' => 1, + ], + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + 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' => $baselineCapturedAt->addHour(), + 'snapshot' => $snapshotPayload, + ]); + + $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, + ); + + expect(Finding::query()->where('tenant_id', (int) $tenant->getKey())->count())->toBe(0); +}); diff --git a/tests/Feature/BaselineDriftEngine/CompareFidelityMismatchTest.php b/tests/Feature/BaselineDriftEngine/CompareFidelityMismatchTest.php new file mode 100644 index 0000000..43ef3f6 --- /dev/null +++ b/tests/Feature/BaselineDriftEngine/CompareFidelityMismatchTest.php @@ -0,0 +1,240 @@ +active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ]); + + $baselineCapturedAt = CarbonImmutable::parse('2026-03-01 00:00:00'); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + 'captured_at' => $baselineCapturedAt, + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'policy-meta', + 'platform' => 'windows', + 'display_name' => 'Policy Meta', + ]); + + $inventory = InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + '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' => 'W/"meta-etag"', + 'scope_tag_ids' => [], + 'assignment_target_count' => 1, + ], + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + $baselineMetaHash = app(BaselineSnapshotIdentity::class)->hashItemContent( + policyType: (string) $inventory->policy_type, + subjectExternalId: (string) $inventory->external_id, + metaJsonb: is_array($inventory->meta_jsonb) ? $inventory->meta_jsonb : [], + ); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => (string) $policy->external_id, + 'policy_type' => (string) $policy->policy_type, + 'baseline_hash' => $baselineMetaHash, + 'meta_jsonb' => [ + 'display_name' => 'Policy Meta', + 'evidence' => [ + 'fidelity' => 'meta', + 'source' => 'inventory', + 'observed_at' => $baselineCapturedAt->toIso8601String(), + 'observed_operation_run_id' => null, + ], + ], + ]); + + 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' => $baselineCapturedAt->addHour(), + 'snapshot' => [ + 'settings' => [ + ['displayName' => 'SettingA', 'value' => 2], + ], + ], + ]); + + $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, + ); + + expect(Finding::query()->where('tenant_id', (int) $tenant->getKey())->count())->toBe(0); + + $run->refresh(); + $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.baseline_meta'))->toBe(1); +}); + +it('Baseline compare records a gap when baseline is content but current content evidence is too old', function () { + [$user, $tenant] = createUserWithTenant(); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ]); + + $baselineCapturedAt = CarbonImmutable::parse('2026-03-01 00:00:00'); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + 'captured_at' => $baselineCapturedAt, + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'policy-content', + 'platform' => 'windows', + 'display_name' => 'Policy Content', + ]); + + $snapshotPayload = [ + 'settings' => [ + ['displayName' => 'SettingB', 'value' => 1], + ], + ]; + + $baselineContentHash = app(DriftHasher::class)->hashNormalized( + app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'), + ); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => (string) $policy->external_id, + 'policy_type' => (string) $policy->policy_type, + 'baseline_hash' => $baselineContentHash, + 'meta_jsonb' => [ + 'display_name' => 'Policy Content', + 'evidence' => [ + 'fidelity' => 'content', + 'source' => 'policy_version', + 'observed_at' => $baselineCapturedAt->toIso8601String(), + 'observed_operation_run_id' => null, + ], + ], + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + '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' => 'W/"meta-etag-content"', + 'scope_tag_ids' => [], + 'assignment_target_count' => 1, + ], + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + 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' => $baselineCapturedAt->subHour(), + 'snapshot' => $snapshotPayload, + ]); + + $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, + ); + + expect(Finding::query()->where('tenant_id', (int) $tenant->getKey())->count())->toBe(0); + + $run->refresh(); + $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.baseline_content'))->toBe(1); + expect(data_get($context, 'baseline_compare.evidence_gaps.missing_current'))->toBe(1); +}); diff --git a/tests/Feature/BaselineDriftEngine/FindingFidelityFilterTest.php b/tests/Feature/BaselineDriftEngine/FindingFidelityFilterTest.php new file mode 100644 index 0000000..7af1952 --- /dev/null +++ b/tests/Feature/BaselineDriftEngine/FindingFidelityFilterTest.php @@ -0,0 +1,32 @@ +actingAs($user); + Filament::setTenant($tenant, true); + + $matching = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + 'evidence_fidelity' => $fidelityToFilter, + ]); + + $nonMatching = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + 'evidence_fidelity' => $otherFidelity, + ]); + + Livewire::test(ListFindings::class) + ->filterTable('evidence_fidelity', $fidelityToFilter) + ->assertCanSeeTableRecords([$matching]) + ->assertCanNotSeeTableRecords([$nonMatching]); +})->with([ + ['content', 'meta'], + ['meta', 'content'], +]); diff --git a/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php b/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php new file mode 100644 index 0000000..93e3b68 --- /dev/null +++ b/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php @@ -0,0 +1,242 @@ +active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ]); + + $baselineCapturedAt = CarbonImmutable::parse('2026-03-01 00:00:00'); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + 'captured_at' => $baselineCapturedAt, + ]); + + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'policy-fidelity-content', + 'platform' => 'windows', + 'display_name' => 'Policy Fidelity Content', + ]); + + $baselineSnapshotPayload = [ + 'settings' => [ + ['displayName' => 'SettingA', 'value' => 1], + ], + ]; + + $baselineHash = app(DriftHasher::class)->hashNormalized( + app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshotPayload, 'deviceConfiguration', 'windows'), + ); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => (string) $policy->external_id, + 'policy_type' => (string) $policy->policy_type, + 'baseline_hash' => $baselineHash, + 'meta_jsonb' => [ + 'display_name' => (string) $policy->display_name, + 'evidence' => [ + 'fidelity' => 'content', + 'source' => 'policy_version', + 'observed_at' => $baselineCapturedAt->toIso8601String(), + 'observed_operation_run_id' => null, + ], + ], + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + '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' => 'W/"same-etag"', + 'scope_tag_ids' => [], + 'assignment_target_count' => 1, + ], + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + 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' => $baselineCapturedAt->addHour(), + 'snapshot' => [ + 'settings' => [ + ['displayName' => 'SettingA', 'value' => 2], + ], + ], + ]); + + $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'], 'foundation_types' => []], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($compareRun))->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $finding = Finding::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('subject_external_id', (string) $policy->external_id) + ->sole(); + + expect($finding->evidence_fidelity)->toBe('content'); +}); + +it('Baseline finding fidelity is meta when baseline evidence is meta (even if current content exists)', function () { + [$user, $tenant] = createUserWithTenant(); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ]); + + $baselineCapturedAt = CarbonImmutable::parse('2026-03-02 00:00:00'); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + 'captured_at' => $baselineCapturedAt, + ]); + + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'policy-fidelity-meta', + 'platform' => 'windows', + 'display_name' => 'Policy Fidelity Meta', + ]); + + $baselineMetaJsonb = [ + 'odata_type' => '#microsoft.graph.deviceConfiguration', + 'etag' => 'W/"E_BASELINE"', + 'scope_tag_ids' => [], + 'assignment_target_count' => 1, + ]; + + $baselineHash = app(BaselineSnapshotIdentity::class)->hashItemContent( + policyType: (string) $policy->policy_type, + subjectExternalId: (string) $policy->external_id, + metaJsonb: $baselineMetaJsonb, + ); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => (string) $policy->external_id, + 'policy_type' => (string) $policy->policy_type, + 'baseline_hash' => $baselineHash, + 'meta_jsonb' => [ + 'display_name' => (string) $policy->display_name, + 'evidence' => [ + 'fidelity' => 'meta', + 'source' => 'inventory', + 'observed_at' => $baselineCapturedAt->toIso8601String(), + 'observed_operation_run_id' => null, + ], + ], + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + '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' => 'W/"E_CURRENT"', + 'scope_tag_ids' => [], + 'assignment_target_count' => 1, + ], + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + 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' => $baselineCapturedAt->addHour(), + 'snapshot' => [ + 'settings' => [ + ['displayName' => 'SettingA', 'value' => 99], + ], + ], + ]); + + $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'], 'foundation_types' => []], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($compareRun))->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $finding = Finding::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('subject_external_id', (string) $policy->external_id) + ->sole(); + + expect($finding->evidence_fidelity)->toBe('meta'); +}); diff --git a/tests/Feature/BaselineDriftEngine/FindingProvenanceTest.php b/tests/Feature/BaselineDriftEngine/FindingProvenanceTest.php new file mode 100644 index 0000000..62d0132 --- /dev/null +++ b/tests/Feature/BaselineDriftEngine/FindingProvenanceTest.php @@ -0,0 +1,134 @@ +active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ]); + + $baselineCapturedAt = CarbonImmutable::parse('2026-03-01 00:00:00'); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + 'captured_at' => $baselineCapturedAt, + ]); + + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'policy-provenance', + 'platform' => 'windows', + 'display_name' => 'Policy Provenance', + ]); + + $baselineSnapshotPayload = [ + 'settings' => [ + ['displayName' => 'SettingA', 'value' => 1], + ], + ]; + + $baselineHash = app(DriftHasher::class)->hashNormalized( + app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshotPayload, 'deviceConfiguration', 'windows'), + ); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => (string) $policy->external_id, + 'policy_type' => (string) $policy->policy_type, + 'baseline_hash' => $baselineHash, + 'meta_jsonb' => [ + 'display_name' => 'Policy Provenance', + 'evidence' => [ + 'fidelity' => 'content', + 'source' => 'policy_version', + 'observed_at' => $baselineCapturedAt->toIso8601String(), + 'observed_operation_run_id' => null, + ], + ], + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + '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' => 'W/"same-etag"', + 'scope_tag_ids' => [], + 'assignment_target_count' => 1, + ], + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + 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' => $baselineCapturedAt->addHour(), + 'snapshot' => [ + 'settings' => [ + ['displayName' => 'SettingA', 'value' => 2], + ], + ], + ]); + + $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'], 'foundation_types' => []], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($compareRun))->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $finding = Finding::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('subject_external_id', (string) $policy->external_id) + ->sole(); + + expect(data_get($finding->evidence_jsonb, 'baseline.provenance.fidelity'))->toBe('content'); + expect(data_get($finding->evidence_jsonb, 'baseline.provenance.source'))->toBe('policy_version'); + expect(data_get($finding->evidence_jsonb, 'baseline.provenance.observed_at'))->not->toBeNull(); + + expect(data_get($finding->evidence_jsonb, 'current.provenance.fidelity'))->toBe('content'); + expect(data_get($finding->evidence_jsonb, 'current.provenance.source'))->toBe('policy_version'); + expect(data_get($finding->evidence_jsonb, 'current.provenance.observed_at'))->not->toBeNull(); +}); diff --git a/tests/Feature/BaselineDriftEngine/PerformanceGuardTest.php b/tests/Feature/BaselineDriftEngine/PerformanceGuardTest.php new file mode 100644 index 0000000..f75f9d7 --- /dev/null +++ b/tests/Feature/BaselineDriftEngine/PerformanceGuardTest.php @@ -0,0 +1,43 @@ +count(500) + ->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => [ + 'odata_type' => '#microsoft.graph.deviceConfiguration', + 'etag' => 'E', + 'scope_tag_ids' => [], + 'assignment_target_count' => 1, + ], + 'last_seen_operation_run_id' => null, + 'last_seen_at' => now(), + ]); + + $subjects = $inventoryItems->map(static fn (InventoryItem $item): array => [ + 'policy_type' => (string) $item->policy_type, + 'subject_external_id' => (string) $item->external_id, + ])->all(); + + DB::enableQueryLog(); + DB::flushQueryLog(); + + app(CurrentStateHashResolver::class)->resolveForSubjects( + tenant: $tenant, + subjects: $subjects, + since: null, + latestInventorySyncRunId: null, + ); + + $queryCount = count(DB::getQueryLog()); + + expect($queryCount)->toBeLessThanOrEqual(20); +}); diff --git a/tests/Feature/BaselineDriftEngine/ResolverTest.php b/tests/Feature/BaselineDriftEngine/ResolverTest.php new file mode 100644 index 0000000..b74d2e8 --- /dev/null +++ b/tests/Feature/BaselineDriftEngine/ResolverTest.php @@ -0,0 +1,141 @@ +create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => 'settingsCatalogPolicy', + 'external_id' => 'policy-a', + 'platform' => 'windows10', + ]); + + $policyVersion = 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' => CarbonImmutable::parse('2026-03-01 10:00:00'), + 'snapshot' => [ + 'settings' => [ + ['name' => 'settingA', 'value' => 1], + ], + ], + ]); + + $inventory = InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => (string) $policy->policy_type, + 'external_id' => (string) $policy->external_id, + 'meta_jsonb' => [ + 'odata_type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'etag' => 'W/"meta-etag"', + 'scope_tag_ids' => [], + 'assignment_target_count' => 1, + ], + 'last_seen_at' => CarbonImmutable::parse('2026-03-01 11:00:00'), + 'last_seen_operation_run_id' => null, + ]); + + $expectedContentHash = app(DriftHasher::class)->hashNormalized( + app(SettingsNormalizer::class)->normalizeForDiff( + is_array($policyVersion->snapshot) ? $policyVersion->snapshot : [], + (string) $policyVersion->policy_type, + is_string($policyVersion->platform) ? $policyVersion->platform : null, + ), + ); + + $expectedMetaHash = app(BaselineSnapshotIdentity::class)->hashItemContent( + policyType: (string) $inventory->policy_type, + subjectExternalId: (string) $inventory->external_id, + metaJsonb: is_array($inventory->meta_jsonb) ? $inventory->meta_jsonb : [], + ); + + $resolver = app(CurrentStateHashResolver::class); + $result = $resolver->resolveForSubjects( + tenant: $tenant, + subjects: [ + ['policy_type' => (string) $policy->policy_type, 'subject_external_id' => (string) $policy->external_id], + ], + since: null, + latestInventorySyncRunId: null, + ); + + expect($result)->toHaveKey((string) $policy->policy_type.'|'.(string) $policy->external_id); + + $evidence = $result[(string) $policy->policy_type.'|'.(string) $policy->external_id]; + expect($evidence)->not->toBeNull(); + expect($evidence?->hash)->toBe($expectedContentHash); + expect($evidence?->hash)->not->toBe($expectedMetaHash); + expect($evidence?->provenance()['fidelity'])->toBe('content'); + expect($evidence?->provenance()['source'])->toBe('policy_version'); +}); + +it('Baseline resolver obeys since rule and falls back to meta evidence when content is too old', function () { + [, $tenant] = createUserWithTenant(); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => 'settingsCatalogPolicy', + 'external_id' => 'policy-b', + 'platform' => 'windows10', + ]); + + 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' => CarbonImmutable::parse('2026-03-01 10:00:00'), + 'snapshot' => [ + 'settings' => [ + ['name' => 'settingB', 'value' => 2], + ], + ], + ]); + + $inventory = InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => (string) $policy->policy_type, + 'external_id' => (string) $policy->external_id, + 'meta_jsonb' => [ + 'odata_type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'etag' => 'W/"meta-etag-b"', + 'scope_tag_ids' => [], + 'assignment_target_count' => 1, + ], + 'last_seen_at' => CarbonImmutable::parse('2026-03-02 10:00:00'), + 'last_seen_operation_run_id' => null, + ]); + + $expectedMetaHash = app(BaselineSnapshotIdentity::class)->hashItemContent( + policyType: (string) $inventory->policy_type, + subjectExternalId: (string) $inventory->external_id, + metaJsonb: is_array($inventory->meta_jsonb) ? $inventory->meta_jsonb : [], + ); + + $resolver = app(CurrentStateHashResolver::class); + $result = $resolver->resolveForSubjects( + tenant: $tenant, + subjects: [ + ['policy_type' => (string) $policy->policy_type, 'subject_external_id' => (string) $policy->external_id], + ], + since: CarbonImmutable::parse('2026-03-02 00:00:00'), + latestInventorySyncRunId: null, + ); + + $evidence = $result[(string) $policy->policy_type.'|'.(string) $policy->external_id] ?? null; + expect($evidence)->not->toBeNull(); + expect($evidence?->hash)->toBe($expectedMetaHash); + expect($evidence?->provenance()['fidelity'])->toBe('meta'); + expect($evidence?->provenance()['source'])->toBe('inventory'); +}); diff --git a/tests/Feature/Filament/PolicySyncCtaPlacementTest.php b/tests/Feature/Filament/PolicySyncCtaPlacementTest.php new file mode 100644 index 0000000..4f140f1 --- /dev/null +++ b/tests/Feature/Filament/PolicySyncCtaPlacementTest.php @@ -0,0 +1,61 @@ +instance(); + $instance->cacheInteractsWithHeaderActions(); + + foreach ($instance->getCachedHeaderActions() as $action) { + if ($action instanceof Action && $action->getName() === $name) { + return $action; + } + } + + return null; +} + +it('shows sync only in empty state when policies table is empty', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::test(ListPolicies::class) + ->assertTableEmptyStateActionsExistInOrder(['syncEmpty']); + + $headerSync = getPolicySyncHeaderAction($component, 'sync'); + expect($headerSync)->not->toBeNull(); + expect($headerSync?->isVisible())->toBeFalse(); +}); + +it('shows sync only in header when policies table is not empty', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'ignored_at' => null, + ]); + + $component = Livewire::test(ListPolicies::class); + + $headerSync = getPolicySyncHeaderAction($component, 'sync'); + expect($headerSync)->not->toBeNull(); + expect($headerSync?->isVisible())->toBeTrue(); +}); diff --git a/tests/Feature/Guards/Spec116OneEngineGuardTest.php b/tests/Feature/Guards/Spec116OneEngineGuardTest.php index 0473f28..984cce6 100644 --- a/tests/Feature/Guards/Spec116OneEngineGuardTest.php +++ b/tests/Feature/Guards/Spec116OneEngineGuardTest.php @@ -5,17 +5,23 @@ it('keeps baseline capture/compare hashing on the InventoryMetaContract engine', function (): void { $compareJob = file_get_contents(base_path('app/Jobs/CompareBaselineToTenantJob.php')); expect($compareJob)->toBeString(); - expect($compareJob)->toContain('hashItemContent'); + expect($compareJob)->toContain('CurrentStateHashResolver'); expect($compareJob)->not->toContain('->fingerprint('); expect($compareJob)->not->toContain('::fingerprint('); $captureJob = file_get_contents(base_path('app/Jobs/CaptureBaselineSnapshotJob.php')); expect($captureJob)->toBeString(); expect($captureJob)->toContain('InventoryMetaContract'); - expect($captureJob)->toContain('hashItemContent'); + expect($captureJob)->toContain('CurrentStateHashResolver'); expect($captureJob)->not->toContain('->fingerprint('); expect($captureJob)->not->toContain('::fingerprint('); + $metaProvider = file_get_contents(base_path('app/Services/Baselines/Evidence/MetaEvidenceProvider.php')); + expect($metaProvider)->toBeString(); + expect($metaProvider)->toContain('hashItemContent'); + expect($metaProvider)->not->toContain('->fingerprint('); + expect($metaProvider)->not->toContain('::fingerprint('); + $identity = file_get_contents(base_path('app/Services/Baselines/BaselineSnapshotIdentity.php')); expect($identity)->toBeString(); expect($identity)->toContain('InventoryMetaContract');