operationRun = $run; } /** * @return array */ public function middleware(): array { return [new TrackOperationRun]; } public function handle( DriftHasher $driftHasher, BaselineSnapshotIdentity $snapshotIdentity, AuditLogger $auditLogger, OperationRunService $operationRunService, ): void { 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."); } $initiator = $this->operationRun->user_id ? User::query()->find($this->operationRun->user_id) : null; $effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null); $scopeKey = 'baseline_profile:' . $profile->getKey(); $this->auditStarted($auditLogger, $tenant, $profile, $initiator); $baselineItems = $this->loadBaselineItems($snapshotId); $currentItems = $this->loadCurrentInventory($tenant, $effectiveScope, $snapshotIdentity); $driftResults = $this->computeDrift($baselineItems, $currentItems); $upsertedCount = $this->upsertFindings( $driftHasher, $tenant, $profile, $scopeKey, $driftResults, ); $severityBreakdown = $this->countBySeverity($driftResults); $summaryCounts = [ 'total' => count($driftResults), 'processed' => count($driftResults), 'succeeded' => $upsertedCount, 'failed' => count($driftResults) - $upsertedCount, 'high' => $severityBreakdown[Finding::SEVERITY_HIGH] ?? 0, 'medium' => $severityBreakdown[Finding::SEVERITY_MEDIUM] ?? 0, 'low' => $severityBreakdown[Finding::SEVERITY_LOW] ?? 0, ]; $operationRunService->updateRun( $this->operationRun, status: OperationRunStatus::Completed->value, outcome: OperationRunOutcome::Succeeded->value, summaryCounts: $summaryCounts, ); $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; $updatedContext['result'] = [ 'findings_total' => count($driftResults), 'findings_upserted' => $upsertedCount, 'severity_breakdown' => $severityBreakdown, ]; $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 { $items = []; BaselineSnapshotItem::query() ->where('baseline_snapshot_id', $snapshotId) ->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, BaselineScope $scope, BaselineSnapshotIdentity $snapshotIdentity, ): array { $query = InventoryItem::query() ->where('tenant_id', $tenant->getKey()); if (! $scope->isEmpty()) { $query->whereIn('policy_type', $scope->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($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; } /** * Compare baseline items vs current inventory and produce drift results. * * @param array}> $baselineItems * @param array}> $currentItems * @return array}> */ private function computeDrift(array $baselineItems, array $currentItems): array { $drift = []; foreach ($baselineItems as $key => $baselineItem) { if (! array_key_exists($key, $currentItems)) { $drift[] = [ 'change_type' => 'missing_policy', 'severity' => Finding::SEVERITY_HIGH, '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' => Finding::SEVERITY_MEDIUM, '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' => Finding::SEVERITY_LOW, '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 */ private function upsertFindings( DriftHasher $driftHasher, Tenant $tenant, BaselineProfile $profile, string $scopeKey, array $driftResults, ): int { $upsertedCount = 0; $tenantId = (int) $tenant->getKey(); foreach ($driftResults as $driftItem) { $fingerprint = $driftHasher->fingerprint( tenantId: $tenantId, scopeKey: $scopeKey, subjectType: $driftItem['subject_type'], subjectExternalId: $driftItem['subject_external_id'], changeType: $driftItem['change_type'], baselineHash: $driftItem['baseline_hash'], currentHash: $driftItem['current_hash'], ); Finding::query()->updateOrCreate( [ 'tenant_id' => $tenantId, 'fingerprint' => $fingerprint, ], [ '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'], 'status' => Finding::STATUS_NEW, 'evidence_jsonb' => $driftItem['evidence'], 'baseline_operation_run_id' => null, 'current_operation_run_id' => (int) $this->operationRun->getKey(), ], ); $upsertedCount++; } return $upsertedCount; } /** * @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; } 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(), ); } }