Automated PR created by Codex via Gitea API. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #459
388 lines
16 KiB
PHP
388 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\ReviewPublicationResolution;
|
|
|
|
use App\Models\EnvironmentReview;
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\EvidenceSnapshotItem;
|
|
use App\Models\ReviewPack;
|
|
use App\Models\StoredReport;
|
|
use App\Services\EnvironmentReviews\EnvironmentReviewReadinessGate;
|
|
use App\Services\ReviewPackService;
|
|
use App\Support\EnvironmentReviewCompletenessState;
|
|
use App\Support\EnvironmentReviewStatus;
|
|
use App\Support\Evidence\EvidenceCompletenessState;
|
|
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
|
|
use Carbon\CarbonInterface;
|
|
use Illuminate\Support\Collection;
|
|
|
|
final class ReviewPublicationReadinessEvaluator
|
|
{
|
|
private const array REQUIRED_REPORT_DIMENSIONS = [
|
|
'permission_posture',
|
|
'entra_admin_roles',
|
|
];
|
|
|
|
private const array REQUIRED_REPORT_TYPES = [
|
|
'permission_posture' => 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<string>,
|
|
* evidence_incomplete:bool,
|
|
* evidence_state:string,
|
|
* report_dimension_states:array<string, array{state:string,source_record_id:?int}>,
|
|
* review_requires_refresh:bool,
|
|
* review_status:string,
|
|
* review_completeness_state:string,
|
|
* publication_blockers:list<string>,
|
|
* guidance_state:string,
|
|
* readiness:array<string, mixed>,
|
|
* guidance:array<string, mixed>,
|
|
* 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<string, int>
|
|
* }
|
|
*/
|
|
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<string>
|
|
*/
|
|
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<string, StoredReport> $latestReports
|
|
* @return array<string, array{state:string,source_record_id:?int,current_report_id:?int,current_report_status:?string,snapshot_source_record_id:?int}>
|
|
*/
|
|
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<string, array{state:string,source_record_id:?int,current_report_id:?int,current_report_status:?string}> $states
|
|
* @return list<string>
|
|
*/
|
|
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<string, array{state:string,source_record_id:?int,current_report_id:?int,current_report_status:?string}> $states
|
|
* @return list<string>
|
|
*/
|
|
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<string, StoredReport>
|
|
*/
|
|
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<string, StoredReport> $latestReports
|
|
* @return list<string>
|
|
*/
|
|
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<array{key:string,state:string,required:bool,source_fingerprint:?string}>
|
|
*/
|
|
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();
|
|
}
|
|
}
|