operationRun = $run; } /** * @return array */ public function middleware(): array { return [new TrackOperationRun]; } public function handle( BaselineSnapshotIdentity $snapshotIdentity, AuditLogger $auditLogger, OperationRunService $operationRunService, ?SettingsResolver $settingsResolver = null, ?BaselineAutoCloseService $baselineAutoCloseService = null, ): void { $settingsResolver ??= app(SettingsResolver::class); $baselineAutoCloseService ??= app(BaselineAutoCloseService::class); if (! $this->operationRun instanceof OperationRun) { $this->fail(new RuntimeException('OperationRun context is required for CompareBaselineToTenantJob.')); return; } $context = is_array($this->operationRun->context) ? $this->operationRun->context : []; $profileId = (int) ($context['baseline_profile_id'] ?? 0); $snapshotId = (int) ($context['baseline_snapshot_id'] ?? 0); $profile = BaselineProfile::query()->find($profileId); if (! $profile instanceof BaselineProfile) { throw new RuntimeException("BaselineProfile #{$profileId} not found."); } $tenant = Tenant::query()->find($this->operationRun->tenant_id); if (! $tenant instanceof Tenant) { throw new RuntimeException("Tenant #{$this->operationRun->tenant_id} not found."); } $workspace = Workspace::query()->whereKey((int) $tenant->workspace_id)->first(); if (! $workspace instanceof Workspace) { throw new RuntimeException("Workspace #{$tenant->workspace_id} not found."); } $initiator = $this->operationRun->user_id ? User::query()->find($this->operationRun->user_id) : null; $effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null); $effectiveTypes = $effectiveScope->allTypes(); $scopeKey = 'baseline_profile:'.$profile->getKey(); $this->auditStarted($auditLogger, $tenant, $profile, $initiator); if ($effectiveTypes === []) { $this->completeWithCoverageWarning( operationRunService: $operationRunService, auditLogger: $auditLogger, tenant: $tenant, profile: $profile, initiator: $initiator, inventorySyncRun: null, coverageProof: false, effectiveTypes: [], coveredTypes: [], uncoveredTypes: [], errorsRecorded: 1, ); return; } $inventorySyncRun = $this->resolveLatestInventorySyncRun($tenant); $coverage = $inventorySyncRun instanceof OperationRun ? InventoryCoverage::fromContext($inventorySyncRun->context) : null; if (! $inventorySyncRun instanceof OperationRun || ! $coverage instanceof InventoryCoverage) { $this->completeWithCoverageWarning( operationRunService: $operationRunService, auditLogger: $auditLogger, tenant: $tenant, profile: $profile, initiator: $initiator, inventorySyncRun: $inventorySyncRun, coverageProof: false, effectiveTypes: $effectiveTypes, coveredTypes: [], uncoveredTypes: $effectiveTypes, errorsRecorded: count($effectiveTypes), ); return; } $coveredTypes = array_values(array_intersect($effectiveTypes, $coverage->coveredTypes())); $uncoveredTypes = array_values(array_diff($effectiveTypes, $coveredTypes)); if ($coveredTypes === []) { $this->completeWithCoverageWarning( operationRunService: $operationRunService, auditLogger: $auditLogger, tenant: $tenant, profile: $profile, initiator: $initiator, inventorySyncRun: $inventorySyncRun, coverageProof: true, effectiveTypes: $effectiveTypes, coveredTypes: [], uncoveredTypes: $effectiveTypes, errorsRecorded: count($effectiveTypes), ); return; } $baselineItems = $this->loadBaselineItems($snapshotId, $coveredTypes); $currentItems = $this->loadCurrentInventory($tenant, $coveredTypes, $snapshotIdentity, (int) $inventorySyncRun->getKey()); $driftResults = $this->computeDrift( $baselineItems, $currentItems, $this->resolveSeverityMapping($workspace, $settingsResolver), ); $upsertResult = $this->upsertFindings( $tenant, $profile, $snapshotId, $scopeKey, $driftResults, ); $severityBreakdown = $this->countBySeverity($driftResults); $countsByChangeType = $this->countByChangeType($driftResults); $summaryCounts = [ 'total' => count($driftResults), 'processed' => count($driftResults), 'succeeded' => (int) $upsertResult['processed_count'], 'failed' => 0, 'errors_recorded' => count($uncoveredTypes), 'high' => $severityBreakdown[Finding::SEVERITY_HIGH] ?? 0, 'medium' => $severityBreakdown[Finding::SEVERITY_MEDIUM] ?? 0, 'low' => $severityBreakdown[Finding::SEVERITY_LOW] ?? 0, 'findings_created' => (int) $upsertResult['created_count'], 'findings_reopened' => (int) $upsertResult['reopened_count'], 'findings_unchanged' => (int) $upsertResult['unchanged_count'], ]; $operationRunService->updateRun( $this->operationRun, status: OperationRunStatus::Completed->value, outcome: $uncoveredTypes !== [] ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value, summaryCounts: $summaryCounts, ); $resolvedCount = 0; if ($baselineAutoCloseService->shouldAutoClose($tenant, $this->operationRun)) { $resolvedCount = $baselineAutoCloseService->resolveStaleFindings( tenant: $tenant, baselineProfileId: (int) $profile->getKey(), seenFingerprints: $upsertResult['seen_fingerprints'], currentOperationRunId: (int) $this->operationRun->getKey(), ); $summaryCounts['findings_resolved'] = $resolvedCount; $operationRunService->updateRun( $this->operationRun, status: OperationRunStatus::Completed->value, outcome: OperationRunOutcome::Succeeded->value, summaryCounts: $summaryCounts, ); } $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(), 'coverage' => [ 'effective_types' => $effectiveTypes, 'covered_types' => $coveredTypes, 'uncovered_types' => $uncoveredTypes, 'proof' => true, ], 'fidelity' => 'meta', ], ); $updatedContext['findings'] = array_merge( is_array($updatedContext['findings'] ?? null) ? $updatedContext['findings'] : [], [ 'counts_by_change_type' => $countsByChangeType, ], ); $updatedContext['result'] = [ 'findings_total' => count($driftResults), 'findings_upserted' => (int) $upsertResult['processed_count'], 'findings_resolved' => $resolvedCount, 'severity_breakdown' => $severityBreakdown, ]; $this->operationRun->update(['context' => $updatedContext]); $this->auditCompleted($auditLogger, $tenant, $profile, $initiator, $summaryCounts); } private function completeWithCoverageWarning( OperationRunService $operationRunService, AuditLogger $auditLogger, Tenant $tenant, BaselineProfile $profile, ?User $initiator, ?OperationRun $inventorySyncRun, bool $coverageProof, array $effectiveTypes, array $coveredTypes, array $uncoveredTypes, int $errorsRecorded, ): void { $summaryCounts = [ 'total' => 0, 'processed' => 0, 'succeeded' => 0, 'failed' => 0, 'errors_recorded' => max(1, $errorsRecorded), 'high' => 0, 'medium' => 0, 'low' => 0, 'findings_created' => 0, 'findings_reopened' => 0, 'findings_unchanged' => 0, ]; $operationRunService->updateRun( $this->operationRun, status: OperationRunStatus::Completed->value, outcome: OperationRunOutcome::PartiallySucceeded->value, summaryCounts: $summaryCounts, ); $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' => $inventorySyncRun instanceof OperationRun ? (int) $inventorySyncRun->getKey() : null, 'coverage' => [ 'effective_types' => array_values($effectiveTypes), 'covered_types' => array_values($coveredTypes), 'uncovered_types' => array_values($uncoveredTypes), 'proof' => $coverageProof, ], 'fidelity' => 'meta', ], ); $updatedContext['findings'] = array_merge( is_array($updatedContext['findings'] ?? null) ? $updatedContext['findings'] : [], [ 'counts_by_change_type' => [], ], ); $updatedContext['result'] = [ 'findings_total' => 0, 'findings_upserted' => 0, 'findings_resolved' => 0, 'severity_breakdown' => [], ]; $this->operationRun->update(['context' => $updatedContext]); $this->auditCompleted($auditLogger, $tenant, $profile, $initiator, $summaryCounts); } /** * Load baseline snapshot items keyed by "policy_type|subject_external_id". * * @return array}> */ private function loadBaselineItems(int $snapshotId, array $policyTypes): array { $items = []; if ($policyTypes === []) { return $items; } $query = BaselineSnapshotItem::query() ->where('baseline_snapshot_id', $snapshotId); $query->whereIn('policy_type', $policyTypes); $query ->orderBy('id') ->chunk(500, function ($snapshotItems) use (&$items): void { foreach ($snapshotItems as $item) { $key = $item->policy_type.'|'.$item->subject_external_id; $items[$key] = [ 'subject_type' => (string) $item->subject_type, 'subject_external_id' => (string) $item->subject_external_id, 'policy_type' => (string) $item->policy_type, 'baseline_hash' => (string) $item->baseline_hash, 'meta_jsonb' => is_array($item->meta_jsonb) ? $item->meta_jsonb : [], ]; } }); return $items; } /** * Load current inventory items keyed by "policy_type|external_id". * * @return array}> */ private function loadCurrentInventory( Tenant $tenant, array $policyTypes, BaselineSnapshotIdentity $snapshotIdentity, ?int $latestInventorySyncRunId = null, ): array { $query = InventoryItem::query() ->where('tenant_id', $tenant->getKey()); if (is_int($latestInventorySyncRunId) && $latestInventorySyncRunId > 0) { $query->where('last_seen_operation_run_id', $latestInventorySyncRunId); } if ($policyTypes === []) { return []; } $query->whereIn('policy_type', $policyTypes); $items = []; $query->orderBy('policy_type') ->orderBy('external_id') ->chunk(500, function ($inventoryItems) use (&$items, $snapshotIdentity): 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, 'platform' => $inventoryItem->platform, ], ]; } }); return $items; } private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun { $run = OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('type', OperationRunType::InventorySync->value) ->where('status', OperationRunStatus::Completed->value) ->orderByDesc('completed_at') ->orderByDesc('id') ->first(); return $run instanceof OperationRun ? $run : null; } /** * Compare baseline items vs current inventory and produce drift results. * * @param array}> $baselineItems * @param array}> $currentItems * @param array $severityMapping * @return array}> */ private function computeDrift(array $baselineItems, array $currentItems, array $severityMapping): array { $drift = []; foreach ($baselineItems as $key => $baselineItem) { if (! array_key_exists($key, $currentItems)) { $drift[] = [ 'change_type' => 'missing_policy', 'severity' => $this->severityForChangeType($severityMapping, 'missing_policy'), 'subject_type' => $baselineItem['subject_type'], 'subject_external_id' => $baselineItem['subject_external_id'], 'policy_type' => $baselineItem['policy_type'], 'baseline_hash' => $baselineItem['baseline_hash'], 'current_hash' => '', 'evidence' => [ 'change_type' => 'missing_policy', 'policy_type' => $baselineItem['policy_type'], 'display_name' => $baselineItem['meta_jsonb']['display_name'] ?? null, ], ]; continue; } $currentItem = $currentItems[$key]; 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'], 'baseline_hash' => $baselineItem['baseline_hash'], 'current_hash' => $currentItem['current_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'], ], ]; } } foreach ($currentItems as $key => $currentItem) { if (! array_key_exists($key, $baselineItems)) { $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'], 'baseline_hash' => '', 'current_hash' => $currentItem['current_hash'], 'evidence' => [ 'change_type' => 'unexpected_policy', 'policy_type' => $currentItem['policy_type'], 'display_name' => $currentItem['meta_jsonb']['display_name'] ?? null, ], ]; } } return $drift; } /** * Upsert drift findings using stable fingerprints. * * @param array}> $driftResults * @return array{processed_count: int, created_count: int, reopened_count: int, unchanged_count: int, seen_fingerprints: array} */ private function upsertFindings( Tenant $tenant, BaselineProfile $profile, int $baselineSnapshotId, string $scopeKey, array $driftResults, ): array { $tenantId = (int) $tenant->getKey(); $observedAt = CarbonImmutable::now(); $processedCount = 0; $createdCount = 0; $reopenedCount = 0; $unchangedCount = 0; $seenFingerprints = []; foreach ($driftResults as $driftItem) { $recurrenceKey = $this->recurrenceKey( tenantId: $tenantId, baselineSnapshotId: $baselineSnapshotId, policyType: $driftItem['policy_type'], subjectExternalId: $driftItem['subject_external_id'], changeType: $driftItem['change_type'], ); $fingerprint = $recurrenceKey; $seenFingerprints[] = $fingerprint; $finding = Finding::query() ->where('tenant_id', $tenantId) ->where('fingerprint', $fingerprint) ->first(); $isNewFinding = ! $finding instanceof Finding; if ($isNewFinding) { $finding = new Finding; } else { $this->observeFinding( finding: $finding, observedAt: $observedAt, currentOperationRunId: (int) $this->operationRun->getKey(), ); } $finding->forceFill([ 'tenant_id' => $tenantId, 'finding_type' => Finding::FINDING_TYPE_DRIFT, 'source' => 'baseline.compare', 'scope_key' => $scopeKey, 'subject_type' => $driftItem['subject_type'], 'subject_external_id' => $driftItem['subject_external_id'], 'severity' => $driftItem['severity'], 'fingerprint' => $fingerprint, 'recurrence_key' => $recurrenceKey, 'evidence_jsonb' => $driftItem['evidence'], 'baseline_operation_run_id' => null, 'current_operation_run_id' => (int) $this->operationRun->getKey(), ]); if ($isNewFinding) { $finding->forceFill([ 'status' => Finding::STATUS_NEW, 'reopened_at' => null, 'resolved_at' => null, 'resolved_reason' => null, 'acknowledged_at' => null, 'acknowledged_by_user_id' => null, 'first_seen_at' => $observedAt, 'last_seen_at' => $observedAt, 'times_seen' => 1, ]); $createdCount++; } elseif (Finding::isTerminalStatus($finding->status)) { $finding->forceFill([ 'status' => Finding::STATUS_REOPENED, 'reopened_at' => now(), 'resolved_at' => null, 'resolved_reason' => null, 'closed_at' => null, 'closed_reason' => null, 'closed_by_user_id' => null, ]); $reopenedCount++; } else { $unchangedCount++; } $finding->save(); $processedCount++; } return [ 'processed_count' => $processedCount, 'created_count' => $createdCount, 'reopened_count' => $reopenedCount, 'unchanged_count' => $unchangedCount, 'seen_fingerprints' => array_values(array_unique($seenFingerprints)), ]; } private function observeFinding(Finding $finding, CarbonImmutable $observedAt, int $currentOperationRunId): void { if ($finding->first_seen_at === null) { $finding->first_seen_at = $observedAt; } if ($finding->last_seen_at === null || $observedAt->greaterThan(CarbonImmutable::instance($finding->last_seen_at))) { $finding->last_seen_at = $observedAt; } $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; if ((int) ($finding->current_operation_run_id ?? 0) !== $currentOperationRunId) { $finding->times_seen = max(0, $timesSeen) + 1; } elseif ($timesSeen < 1) { $finding->times_seen = 1; } } /** * Stable identity for baseline-compare findings, scoped to a baseline snapshot. */ private function recurrenceKey( int $tenantId, int $baselineSnapshotId, string $policyType, string $subjectExternalId, string $changeType, ): string { $parts = [ (string) $tenantId, (string) $baselineSnapshotId, $this->normalizeKeyPart($policyType), $this->normalizeKeyPart($subjectExternalId), $this->normalizeKeyPart($changeType), ]; return hash('sha256', implode('|', $parts)); } private function normalizeKeyPart(string $value): string { return trim(mb_strtolower($value)); } /** * @param array $driftResults * @return array */ private function countByChangeType(array $driftResults): array { $counts = []; foreach ($driftResults as $item) { $changeType = (string) ($item['change_type'] ?? ''); if ($changeType === '') { continue; } $counts[$changeType] = ($counts[$changeType] ?? 0) + 1; } ksort($counts); return $counts; } /** * @param array $driftResults * @return array */ private function countBySeverity(array $driftResults): array { $counts = []; foreach ($driftResults as $item) { $severity = $item['severity']; $counts[$severity] = ($counts[$severity] ?? 0) + 1; } return $counts; } /** * @return array */ private function resolveSeverityMapping(Workspace $workspace, SettingsResolver $settingsResolver): array { try { $mapping = $settingsResolver->resolveValue( workspace: $workspace, domain: 'baseline', key: 'severity_mapping', ); } catch (\InvalidArgumentException) { // Settings keys are registry-backed; if this key is missing (e.g. during rollout), // fall back to built-in defaults rather than failing the entire compare run. return []; } return is_array($mapping) ? $mapping : []; } /** * @param array $severityMapping */ private function severityForChangeType(array $severityMapping, string $changeType): string { $severity = $severityMapping[$changeType] ?? null; if (! is_string($severity) || $severity === '') { return match ($changeType) { 'missing_policy' => Finding::SEVERITY_HIGH, 'different_version' => Finding::SEVERITY_MEDIUM, default => Finding::SEVERITY_LOW, }; } return $severity; } private function auditStarted( AuditLogger $auditLogger, Tenant $tenant, BaselineProfile $profile, ?User $initiator, ): void { $auditLogger->log( tenant: $tenant, action: 'baseline.compare.started', context: [ 'metadata' => [ 'operation_run_id' => (int) $this->operationRun->getKey(), 'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_name' => (string) $profile->name, ], ], actorId: $initiator?->id, actorEmail: $initiator?->email, actorName: $initiator?->name, resourceType: 'baseline_profile', resourceId: (string) $profile->getKey(), ); } private function auditCompleted( AuditLogger $auditLogger, Tenant $tenant, BaselineProfile $profile, ?User $initiator, array $summaryCounts, ): void { $auditLogger->log( tenant: $tenant, action: 'baseline.compare.completed', context: [ 'metadata' => [ 'operation_run_id' => (int) $this->operationRun->getKey(), 'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_name' => (string) $profile->name, 'findings_total' => $summaryCounts['total'] ?? 0, 'high' => $summaryCounts['high'] ?? 0, 'medium' => $summaryCounts['medium'] ?? 0, 'low' => $summaryCounts['low'] ?? 0, ], ], actorId: $initiator?->id, actorEmail: $initiator?->email, actorName: $initiator?->name, resourceType: 'operation_run', resourceId: (string) $this->operationRun->getKey(), ); } }