StoredReport::REPORT_TYPE_PERMISSION_POSTURE, 'entra_admin_roles' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES, ]; public function __construct( private readonly EnvironmentReviewReadinessGate $readinessGate, private readonly ReviewPackService $reviewPacks, ) {} /** * @return array{ * fingerprint:string, * has_publication_blockers:bool, * can_publish:bool, * can_return_to_publication:bool, * missing_report_dimensions:list, * evidence_incomplete:bool, * evidence_state:string, * report_dimension_states:array, * review_requires_refresh:bool, * review_status:string, * review_completeness_state:string, * publication_blockers:list, * guidance_state:string, * readiness:array, * guidance:array, * has_ready_export:bool, * current_export_review_pack_id:?int, * review_pack_current:bool, * current_evidence_snapshot_id:?int, * review_operation_run_id:?int, * scope:array * } */ public function evaluate(EnvironmentReview $review): array { $review->loadMissing([ 'tenant', 'sections', 'evidenceSnapshot.items', 'operationRun', 'currentExportReviewPack.operationRun', ]); $readiness = ReviewPackOutputResolutionGuidance::readinessForReview($review); $guidance = ReviewPackOutputResolutionGuidance::fromReadiness($readiness); $snapshot = $review->evidenceSnapshot; $pack = $review->currentExportReviewPack; $publicationBlockers = $this->publicationBlockers($review); $canPublish = $this->readinessGate->canPublish($review); $latestReports = $this->latestRequiredReports($review); $staleReportDimensions = $snapshot instanceof EvidenceSnapshot ? $this->staleReportDimensions($snapshot, $latestReports) : []; $reportDimensionStates = $this->reportDimensionStates($snapshot, $latestReports); $missingReportDimensions = $this->missingReportDimensions($reportDimensionStates); $snapshotMissingCurrentReportReferences = $this->snapshotMissingCurrentReportReferences($reportDimensionStates); $evidenceState = $snapshot instanceof EvidenceSnapshot ? (string) $snapshot->completeness_state : EvidenceCompletenessState::Missing->value; $evidenceIncomplete = $snapshot === null || $evidenceState !== EvidenceCompletenessState::Complete->value || (int) data_get($snapshot->summary, 'missing_dimensions', 0) > 0 || (int) data_get($snapshot->summary, 'stale_dimensions', 0) > 0 || $staleReportDimensions !== [] || $snapshotMissingCurrentReportReferences !== []; $reviewStatus = (string) $review->status; $reviewCompleteness = (string) $review->completeness_state; $hasReadyExport = (bool) ($readiness['has_ready_export'] ?? false); $reviewPackCurrent = $this->reviewPackMatchesCurrentOutput($review, $pack); $canReturnToPublication = $canPublish && $reviewStatus === EnvironmentReviewStatus::Ready->value; $reviewRequiresRefresh = $evidenceIncomplete || ! $canReturnToPublication || $reviewCompleteness !== EnvironmentReviewCompletenessState::Complete->value; $hasCurrentReadyExport = $hasReadyExport && $reviewPackCurrent && ! $reviewRequiresRefresh && $publicationBlockers === []; $payload = [ 'review_id' => (int) $review->getKey(), 'review_status' => $reviewStatus, 'review_completeness_state' => $reviewCompleteness, 'review_fingerprint' => (string) $review->fingerprint, 'evidence_snapshot_id' => $snapshot instanceof EvidenceSnapshot ? (int) $snapshot->getKey() : null, 'evidence_fingerprint' => $snapshot instanceof EvidenceSnapshot ? (string) $snapshot->fingerprint : null, 'evidence_state' => $evidenceState, 'evidence_generated_at' => $snapshot?->generated_at?->toJSON(), 'report_dimension_states' => $reportDimensionStates, 'section_states' => $this->sectionStates($review), 'publication_blockers' => $publicationBlockers, 'readiness_state' => (string) ($readiness['readiness_state'] ?? ''), 'guidance_state' => (string) ($guidance['state'] ?? ''), 'has_ready_export' => $hasCurrentReadyExport, 'review_pack_id' => $pack instanceof ReviewPack ? (int) $pack->getKey() : null, 'review_pack_status' => $pack instanceof ReviewPack ? (string) $pack->status : null, 'review_pack_fingerprint' => $pack instanceof ReviewPack ? (string) $pack->fingerprint : null, 'review_pack_current' => $reviewPackCurrent, ]; return [ 'fingerprint' => hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)), 'has_publication_blockers' => ! $canPublish || $publicationBlockers !== [], 'can_publish' => $canPublish, 'can_return_to_publication' => $canReturnToPublication, 'missing_report_dimensions' => $missingReportDimensions, 'evidence_incomplete' => $evidenceIncomplete, 'evidence_state' => $evidenceState, 'report_dimension_states' => $reportDimensionStates, 'review_requires_refresh' => $reviewRequiresRefresh, 'review_status' => $reviewStatus, 'review_completeness_state' => $reviewCompleteness, 'publication_blockers' => $publicationBlockers, 'guidance_state' => (string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN), 'readiness' => $readiness, 'guidance' => $guidance, 'has_ready_export' => $hasCurrentReadyExport, 'current_export_review_pack_id' => $pack instanceof ReviewPack ? (int) $pack->getKey() : null, 'review_pack_current' => $reviewPackCurrent, 'current_evidence_snapshot_id' => $snapshot instanceof EvidenceSnapshot ? (int) $snapshot->getKey() : null, 'review_operation_run_id' => is_numeric($review->operation_run_id) ? (int) $review->operation_run_id : null, 'scope' => [ 'workspace_id' => (int) $review->workspace_id, 'managed_environment_id' => (int) $review->managed_environment_id, 'environment_review_id' => (int) $review->getKey(), ], ]; } private function reviewPackMatchesCurrentOutput(EnvironmentReview $review, ?ReviewPack $pack): bool { if (! $pack instanceof ReviewPack || ! is_string($pack->fingerprint) || $pack->fingerprint === '') { return false; } $options = is_array($pack->options) ? $pack->options : []; $expectedFingerprint = $this->reviewPacks->computeFingerprintForReview($review, $options); return hash_equals($expectedFingerprint, (string) $pack->fingerprint); } /** * @return list */ private function publicationBlockers(EnvironmentReview $review): array { return collect($this->readinessGate->blockersForReview($review)) ->map(static fn (string $blocker): string => mb_substr(trim($blocker), 0, 240)) ->filter(static fn (string $blocker): bool => $blocker !== '') ->values() ->all(); } /** * @param Collection $latestReports * @return array */ private function reportDimensionStates(?EvidenceSnapshot $snapshot, Collection $latestReports): array { $items = $snapshot instanceof EvidenceSnapshot ? $snapshot->items->keyBy('dimension_key') : new Collection; $states = []; foreach (self::REQUIRED_REPORT_DIMENSIONS as $dimension) { $item = $items->get($dimension); $report = $latestReports->get($dimension); $itemSourceRecordId = $item instanceof EvidenceSnapshotItem && is_numeric($item->source_record_id) ? (int) $item->source_record_id : null; $itemComplete = $item instanceof EvidenceSnapshotItem && (string) $item->state === EvidenceCompletenessState::Complete->value; $latestReportId = $report instanceof StoredReport ? (int) $report->getKey() : null; $latestReportReady = $report instanceof StoredReport && (string) $report->status === StoredReport::STATUS_READY; $hasCurrentReadyReport = $latestReportReady && $itemComplete && $itemSourceRecordId === $latestReportId; $itemReferencesLatestFailedReport = $report instanceof StoredReport && ! $latestReportReady && $itemSourceRecordId === $latestReportId; $states[$dimension] = [ 'state' => $hasCurrentReadyReport ? EvidenceCompletenessState::Complete->value : ($itemReferencesLatestFailedReport ? EvidenceCompletenessState::Missing->value : ($item instanceof EvidenceSnapshotItem ? (string) $item->state : EvidenceCompletenessState::Missing->value)), 'source_record_id' => $hasCurrentReadyReport ? $latestReportId : ($itemReferencesLatestFailedReport ? null : $itemSourceRecordId), 'current_report_id' => $latestReportId, 'current_report_status' => $report instanceof StoredReport ? (string) $report->status : null, 'snapshot_source_record_id' => $itemSourceRecordId, ]; } return $states; } /** * @param array $states * @return list */ private function missingReportDimensions(array $states): array { $missing = []; foreach ($states as $dimension => $state) { if (! is_numeric($state['current_report_id'] ?? null) || (string) ($state['current_report_status'] ?? '') !== StoredReport::STATUS_READY) { $missing[] = $dimension; } } return $missing; } /** * @param array $states * @return list */ private function snapshotMissingCurrentReportReferences(array $states): array { $missing = []; foreach ($states as $dimension => $state) { $currentReportId = $state['current_report_id'] ?? null; if (! is_numeric($currentReportId) || (string) ($state['current_report_status'] ?? '') !== StoredReport::STATUS_READY) { continue; } if (($state['state'] ?? EvidenceCompletenessState::Missing->value) !== EvidenceCompletenessState::Complete->value || ! is_numeric($state['source_record_id'] ?? null) || (int) $state['source_record_id'] !== (int) $currentReportId) { $missing[] = $dimension; } } return $missing; } /** * @return Collection */ private function latestRequiredReports(EnvironmentReview $review): Collection { return StoredReport::query() ->where('workspace_id', (int) $review->workspace_id) ->where('managed_environment_id', (int) $review->managed_environment_id) ->whereIn('report_type', array_values(self::REQUIRED_REPORT_TYPES)) ->orderByDesc('generated_at') ->orderByDesc('updated_at') ->orderByDesc('id') ->get() ->unique('report_type') ->mapWithKeys(function (StoredReport $report): array { $dimension = array_search((string) $report->report_type, self::REQUIRED_REPORT_TYPES, true); return is_string($dimension) ? [$dimension => $report] : []; }); } /** * @param Collection $latestReports * @return list */ private function staleReportDimensions(EvidenceSnapshot $snapshot, Collection $latestReports): array { $stale = []; foreach (self::REQUIRED_REPORT_DIMENSIONS as $dimension) { $report = $latestReports->get($dimension); $item = $snapshot->items->firstWhere('dimension_key', $dimension); if (! $report instanceof StoredReport || ! $item instanceof EvidenceSnapshotItem) { continue; } if ((string) $item->state !== EvidenceCompletenessState::Complete->value || ! is_numeric($item->source_record_id)) { continue; } if ((string) $report->status !== StoredReport::STATUS_READY) { $stale[] = $dimension; continue; } if ((int) $item->source_record_id === (int) $report->getKey()) { continue; } if ($this->reportIsNewerThanSnapshot($report, $snapshot, $item->source_record_id)) { $stale[] = $dimension; } } return $stale; } private function reportIsNewerThanSnapshot(StoredReport $report, ?EvidenceSnapshot $snapshot, mixed $itemSourceRecordId): bool { if ($snapshot === null) { return true; } if (is_numeric($itemSourceRecordId) && (int) $itemSourceRecordId === (int) $report->getKey()) { return false; } $reportTimestamp = $this->latestTimestamp($report->generated_at, $report->updated_at, $report->created_at); $snapshotTimestamp = $this->latestTimestamp($snapshot->generated_at, $snapshot->updated_at, $snapshot->created_at); if (! $reportTimestamp instanceof CarbonInterface) { return false; } if (! $snapshotTimestamp instanceof CarbonInterface) { return true; } if ($reportTimestamp->greaterThan($snapshotTimestamp)) { return true; } if ($reportTimestamp->lessThan($snapshotTimestamp)) { return false; } return ! is_numeric($itemSourceRecordId) || (int) $report->getKey() > (int) $itemSourceRecordId; } private function latestTimestamp(?CarbonInterface ...$timestamps): ?CarbonInterface { return collect($timestamps) ->filter(static fn (?CarbonInterface $timestamp): bool => $timestamp instanceof CarbonInterface) ->sortByDesc(static fn (CarbonInterface $timestamp): int => $timestamp->getTimestamp()) ->first(); } /** * @return list */ private function sectionStates(EnvironmentReview $review): array { return $review->sections ->map(static fn ($section): array => [ 'key' => (string) $section->section_key, 'state' => (string) $section->completeness_state, 'required' => (bool) $section->required, 'source_fingerprint' => is_string($section->source_snapshot_fingerprint) ? $section->source_snapshot_fingerprint : null, ]) ->values() ->all(); } }