record = $this->resolveRecord($record); $this->authorizeAccess(); $user = auth()->user(); if (! $user instanceof User || ! $this->record instanceof EnvironmentReview) { abort(403); } $caseService = app(ReviewPublicationResolutionService::class); $case = $user->can('refresh', $this->record) ? $caseService->openOrResume( review: $this->record, actor: $user, sourceSurface: 'environment_review_detail', ) : $caseService->activeCaseForReview($this->record); if ($case instanceof ReviewPublicationResolutionCase && ! $user->can('view', $case)) { abort(404); } if (! $user->can('refresh', $this->record) && ! ($case instanceof ReviewPublicationResolutionCase)) { abort(403); } if (! $case instanceof ReviewPublicationResolutionCase) { Notification::make() ->success() ->title('Review is ready for publication') ->body('No publication resolution case was needed for the current review state.') ->send(); $this->redirect(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $this->record], $this->record->tenant)); return; } $this->resolutionCaseId = (int) $case->getKey(); $this->heading = $case->status === ReviewPublicationResolutionCaseStatus::ReadyToContinue->value ? 'Review is ready to continue' : 'Review can\'t be published yet'; $this->subheading = $this->record->tenant?->getFilamentName(); } public function getTitle(): string { return $this->pageOutcomeTitle(); } public function getHeading(): string { return $this->pageOutcomeTitle(); } protected function resolveRecord(int|string $key): Model { return EnvironmentReviewResource::resolveScopedRecordOrFail($key); } protected function getHeaderActions(): array { return array_values(array_filter([ $this->executeCurrentStepAction(), Actions\Action::make('back_to_review') ->label('Return to review') ->icon('heroicon-o-arrow-left') ->color('gray') ->url(fn (): string => $this->reviewUrl()), Actions\ActionGroup::make([ $this->cancelResolutionAction(), ]) ->label('More') ->icon('heroicon-o-ellipsis-vertical') ->color('gray') ->visible(fn (): bool => $this->resolutionCase()?->statusEnum()->isActive() ?? false), ])); } /** * @return array */ public function caseState(): array { $case = $this->resolutionCase(); if (! $case instanceof ReviewPublicationResolutionCase) { return [ 'case' => null, 'steps' => [], ]; } $case->loadMissing(['steps.operationRun', 'environmentReview.tenant', 'tenant']); $tenant = $case->tenant; $currentStep = $case->currentStep(); return [ 'case' => [ 'id' => (int) $case->getKey(), 'status' => (string) $case->status, 'status_label' => $this->statusLabel((string) $case->status), 'status_color' => $this->caseStatusColor((string) $case->status), 'current_step_key' => $case->current_step_key, 'summary' => is_array($case->summary) ? $case->summary : [], 'created_at' => $case->created_at?->diffForHumans(), 'last_evaluated_at' => $case->last_evaluated_at?->diffForHumans(), ], 'decision' => $this->decisionState($case, $currentStep), 'steps' => $case->steps ->map(fn (ReviewPublicationResolutionStep $step): array => $this->stepState($step, $tenant)) ->values() ->all(), ]; } private function executeCurrentStepAction(): Actions\Action { $action = Actions\Action::make('execute_current_step') ->label(fn (): string => $this->currentStepActionLabel()) ->icon(fn (): string => $this->currentStepActionIcon()) ->color('primary') ->visible(fn (): bool => $this->hasExecutableCurrentStep() && ! $this->currentStepIsRunning()) ->disabled(fn (): bool => $this->currentStepIsRunning() || ! $this->canExecuteCurrentStep()) ->tooltip(fn (): ?string => $this->currentStepActionTooltip()) ->requiresConfirmation() ->modalHeading(fn (): string => $this->currentStepConfirmationHeading()) ->modalDescription(fn (): string => $this->currentStepConfirmationDescription()) ->modalSubmitActionLabel(fn (): string => $this->currentStepActionLabel()) ->action(function (): void { $case = $this->resolutionCase(); $user = auth()->user(); if (! $case instanceof ReviewPublicationResolutionCase || ! $user instanceof User) { abort(403); } if (! $this->canExecuteCurrentStep()) { abort(403); } $result = app(ReviewPublicationResolutionActionService::class)->executeCurrentStep($case, $user); $updatedCase = $result['case']; $this->resolutionCaseId = (int) $updatedCase->getKey(); if ($result['operation_run'] instanceof OperationRun && is_string($result['operation_type'])) { OperationUxPresenter::queuedToast($result['operation_type'])->send(); return; } Notification::make() ->success() ->title('Resolution step completed') ->send(); if ($updatedCase->status === ReviewPublicationResolutionCaseStatus::Completed->value) { $this->redirect($this->reviewUrl()); } }); return $action; } private function cancelResolutionAction(): Actions\Action { return UiEnforcement::forAction( Actions\Action::make('cancel_resolution') ->label('Cancel resolution') ->icon('heroicon-o-x-circle') ->color('danger') ->visible(fn (): bool => $this->resolutionCase()?->statusEnum()->isActive() ?? false) ->requiresConfirmation() ->modalHeading('Cancel publication resolution') ->modalDescription('This only cancels the TenantPilot resolution case. It does not modify the provider tenant or publish the review.') ->action(function (): void { $case = $this->resolutionCase(); $user = auth()->user(); if (! $case instanceof ReviewPublicationResolutionCase || ! $user instanceof User) { abort(403); } app(ReviewPublicationResolutionService::class)->cancel($case, $user); Notification::make() ->success() ->title('Resolution cancelled') ->send(); $this->redirect($this->reviewUrl()); }), ) ->requireCapability(Capabilities::ENVIRONMENT_REVIEW_MANAGE) ->preserveVisibility() ->apply(); } private function authorizeAccess(): void { $tenant = EnvironmentReviewResource::panelTenantContext(); $record = $this->getRecord(); $user = auth()->user(); if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment || ! $record instanceof EnvironmentReview) { abort(404); } if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) { abort(404); } if (! $user->canAccessTenant($tenant)) { abort(404); } if (! $user->can('view', $record)) { abort(403); } } private function resolutionCase(): ?ReviewPublicationResolutionCase { if (! is_numeric($this->resolutionCaseId)) { return null; } $case = ReviewPublicationResolutionCase::query() ->with(['steps.operationRun', 'environmentReview.tenant', 'tenant']) ->find((int) $this->resolutionCaseId); if (! $case instanceof ReviewPublicationResolutionCase) { return null; } $user = auth()->user(); if (! $user instanceof User || ! $user->can('view', $case)) { abort(403); } return $case; } private function pageOutcomeTitle(): string { $case = $this->resolutionCase(); if ($case instanceof ReviewPublicationResolutionCase && $case->status === ReviewPublicationResolutionCaseStatus::ReadyToContinue->value) { return 'Review is ready to continue'; } return 'Review can\'t be published yet'; } private function currentStep(): ?ReviewPublicationResolutionStep { return $this->resolutionCase()?->currentStep(); } private function hasExecutableCurrentStep(): bool { $step = $this->currentStep(); if (! $step instanceof ReviewPublicationResolutionStep) { return false; } return $step->statusEnum() !== ReviewPublicationResolutionStepStatus::Pending; } private function currentStepIsRunning(): bool { return $this->currentStep()?->statusEnum() === ReviewPublicationResolutionStepStatus::Running; } private function canExecuteCurrentStep(): bool { $case = $this->resolutionCase(); $user = auth()->user(); if (! $case instanceof ReviewPublicationResolutionCase || ! $user instanceof User) { return false; } return app(ReviewPublicationResolutionStepAuthorizer::class)->canExecuteCurrentStep($user, $case); } private function currentStepActionTooltip(): ?string { if ($this->currentStepIsRunning()) { return 'The linked operation is still running.'; } return $this->canExecuteCurrentStep() ? null : AuthUiTooltips::insufficientPermission(); } private function currentStepActionLabel(): string { return $this->currentStepActionLabelFor($this->currentStep()?->stepKeyEnum()); } private function currentStepActionLabelFor(?ReviewPublicationResolutionStepKey $stepKey): string { return match ($stepKey) { ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'Update required reports', ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'Collect evidence', ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'Refresh review', ReviewPublicationResolutionStepKey::GenerateReviewPack => 'Prepare export', ReviewPublicationResolutionStepKey::ReturnToPublication => 'Return to review', default => 'Continue', }; } private function currentStepActionIcon(): string { return $this->currentStepActionIconFor($this->currentStep()?->stepKeyEnum()); } private function currentStepActionIconFor(?ReviewPublicationResolutionStepKey $stepKey): string { return match ($stepKey) { ReviewPublicationResolutionStepKey::CompleteRequiredReports, ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot, ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'heroicon-o-arrow-path', ReviewPublicationResolutionStepKey::GenerateReviewPack => 'heroicon-o-arrow-down-tray', ReviewPublicationResolutionStepKey::ReturnToPublication => 'heroicon-o-check-badge', default => 'heroicon-o-play', }; } private function reviewUrl(): string { $record = $this->getRecord(); if (! $record instanceof EnvironmentReview) { return url('/admin'); } return EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $record], $record->tenant); } /** * @return array */ private function decisionState(ReviewPublicationResolutionCase $case, ?ReviewPublicationResolutionStep $currentStep): array { $summary = is_array($case->summary) ? $case->summary : []; $missingReports = collect((array) ($summary['missing_report_dimensions'] ?? [])) ->map(fn (mixed $dimension): string => $this->reportRequirementLabel((string) $dimension)) ->filter(static fn (string $dimension): bool => $dimension !== '') ->values() ->all(); $blockers = collect((array) ($summary['publication_blockers'] ?? [])) ->map(fn (mixed $blocker): string => $this->operatorBlockerLabel((string) $blocker)) ->filter(static fn (string $blocker): bool => $blocker !== '') ->unique() ->values() ->all(); $blockerCount = max((int) ($summary['publication_blocker_count'] ?? 0), count($blockers), count($missingReports)); $stepKey = $currentStep?->stepKeyEnum(); $stepStatus = $currentStep?->statusEnum(); $stepIsRunning = $stepStatus === ReviewPublicationResolutionStepStatus::Running; $stepFailed = $stepStatus === ReviewPublicationResolutionStepStatus::Failed; $readyToReturn = (string) $case->status === ReviewPublicationResolutionCaseStatus::ReadyToContinue->value || $stepKey === ReviewPublicationResolutionStepKey::ReturnToPublication; $permissionNotice = $currentStep instanceof ReviewPublicationResolutionStep && $this->hasExecutableCurrentStep() && ! $stepIsRunning && ! $this->canExecuteCurrentStep() ? 'You can inspect this preparation flow, but you do not have permission to run the next action.' : null; $stateNotice = match (true) { $stepIsRunning => [ 'label' => 'Operation in progress', 'color' => 'info', 'body' => 'TenantPilot is waiting for the linked operation to finish. No new start action is available while it runs.', ], $stepFailed => [ 'label' => 'Action needed', 'color' => 'danger', 'body' => 'The last operation did not complete. Review the linked operation, then retry the current preparation action when you are ready.', ], default => null, }; return [ 'headline' => $readyToReturn ? 'Review is ready to continue' : 'Review can\'t be published yet', 'status_badge_label' => match (true) { $stepIsRunning => 'Operation in progress', $stepFailed => 'Action needed', $readyToReturn => 'Ready to continue', default => 'Publication blocked', }, 'status_badge_color' => match (true) { $stepIsRunning => 'info', $stepFailed => 'danger', $readyToReturn => 'success', default => 'warning', }, 'blocked_summary' => $this->blockedSummary($blockerCount, count($missingReports)), 'blockers' => $blockers, 'missing_reports' => $missingReports, 'next_action_label' => $stepIsRunning ? 'Operation in progress' : $this->currentStepActionLabelFor($stepKey), 'next_action_description' => $this->nextStepDescription($stepKey, $stepStatus), 'after_this' => $this->afterNextStepDescription($stepKey), 'state_notice' => $stateNotice, 'permission_notice' => $permissionNotice, ]; } /** * @return array */ private function stepState(ReviewPublicationResolutionStep $step, ?ManagedEnvironment $tenant): array { $operationRun = $step->operationRun; $stepKey = $step->stepKeyEnum(); return [ 'key' => (string) $step->step_key, 'label' => (string) data_get($step->summary, 'label', $step->step_key), 'operator_label' => $this->operatorStepLabel($stepKey), 'description' => (string) data_get($step->summary, 'description', ''), 'operator_description' => $this->operatorStepDescription($stepKey), 'state_description' => (string) data_get($step->summary, 'state_description', ''), 'status' => (string) $step->status, 'status_label' => $this->statusLabel((string) $step->status), 'status_color' => $this->stepStatusColor((string) $step->status), 'proof_type' => $step->proof_type, 'proof_id' => $step->proof_id, 'proof_status' => $step->proof_status, 'proof_label' => (string) data_get($step->summary, 'proof_label', $this->proofLabelFromState($step)), 'proof_state_description' => (string) data_get($step->summary, 'proof_state_description', ''), 'proof_currentness' => (string) data_get($step->metadata, 'proof_currentness', ''), 'proof_usability' => (string) data_get($step->metadata, 'proof_usability', ''), 'proof_visibility' => (string) data_get($step->metadata, 'proof_visibility', ''), 'proof_reason_code' => (string) data_get($step->metadata, 'proof_reason_code', ''), 'proof_summary' => is_array(data_get($step->metadata, 'proof_summary')) ? data_get($step->metadata, 'proof_summary') : [], 'proof_url' => $this->proofUrl($step, $tenant), 'operation_run_id' => $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null, 'operation_url' => $this->operationUrl($step, $operationRun, $tenant), ]; } private function operationUrl( ReviewPublicationResolutionStep $step, ?OperationRun $operationRun, ?ManagedEnvironment $tenant, ): ?string { if (! $this->canDiscloseOperationRun($step, $operationRun, $tenant)) { return null; } return OperationRunLinks::view($operationRun, $tenant); } private function canDiscloseOperationRun( ReviewPublicationResolutionStep $step, ?OperationRun $operationRun, ?ManagedEnvironment $tenant, ): bool { if (! $operationRun instanceof OperationRun || ! $tenant instanceof ManagedEnvironment) { return false; } if (! is_numeric($step->operation_run_id) || (int) $step->operation_run_id !== (int) $operationRun->getKey()) { return false; } if ((int) $operationRun->workspace_id !== (int) $tenant->workspace_id || (int) $operationRun->managed_environment_id !== (int) $tenant->getKey()) { return false; } $user = auth()->user(); if (! $user instanceof User || ! $user->can('view', $operationRun)) { return false; } if ((string) data_get($step->metadata, 'proof_currentness') !== ResolutionProofCurrentness::Current->value) { return false; } if ((string) data_get($step->metadata, 'proof_visibility') !== ResolutionProofVisibility::OperatorVisible->value) { return false; } if (! in_array((string) data_get($step->metadata, 'proof_usability'), [ ResolutionProofUsability::Usable->value, ResolutionProofUsability::UsableWithWarning->value, ResolutionProofUsability::InspectionOnly->value, ], true)) { return false; } $summary = data_get($step->metadata, 'proof_summary'); if (! is_array($summary)) { return false; } return ResolutionProofEvaluation::sanitizeSummary($summary) === $summary; } private function proofUrl(ReviewPublicationResolutionStep $step, ?ManagedEnvironment $tenant): ?string { if (! $tenant instanceof ManagedEnvironment || ! is_numeric($step->proof_id) || ! is_string($step->proof_type)) { return null; } return match ($step->proof_type) { 'environment_review' => $this->environmentReviewProofUrl($step, $tenant), 'stored_report' => $this->storedReportProofUrl($step, $tenant), 'evidence_snapshot' => $this->evidenceSnapshotProofUrl($step, $tenant), 'review_pack' => $this->reviewPackProofUrl($step, $tenant), default => null, }; } private function environmentReviewProofUrl(ReviewPublicationResolutionStep $step, ManagedEnvironment $tenant): ?string { $review = EnvironmentReview::query()->whereKey((int) $step->proof_id)->first(); if (! $review instanceof EnvironmentReview || ! EnvironmentReviewResource::canView($review)) { return null; } return EnvironmentReviewResource::environmentScopedUrl('view', ['record' => (int) $step->proof_id], $tenant); } private function storedReportProofUrl(ReviewPublicationResolutionStep $step, ManagedEnvironment $tenant): ?string { $report = StoredReport::query()->whereKey((int) $step->proof_id)->first(); if (! $report instanceof StoredReport || ! StoredReportResource::canView($report)) { return null; } return StoredReportResource::getUrl('view', ['record' => (int) $step->proof_id], tenant: $tenant); } private function evidenceSnapshotProofUrl(ReviewPublicationResolutionStep $step, ManagedEnvironment $tenant): ?string { $snapshot = EvidenceSnapshot::query()->whereKey((int) $step->proof_id)->first(); if (! $snapshot instanceof EvidenceSnapshot || ! EvidenceSnapshotResource::canView($snapshot)) { return null; } return EvidenceSnapshotResource::getUrl('view', ['record' => (int) $step->proof_id], tenant: $tenant); } private function reviewPackProofUrl(ReviewPublicationResolutionStep $step, ManagedEnvironment $tenant): ?string { $reviewPack = ReviewPack::query()->whereKey((int) $step->proof_id)->first(); if (! $reviewPack instanceof ReviewPack || ! ReviewPackResource::canView($reviewPack)) { return null; } return ReviewPackResource::getUrl('view', ['record' => (int) $step->proof_id], tenant: $tenant); } private function statusLabel(string $status): string { return str($status)->replace('_', ' ')->title()->toString(); } private function caseStatusColor(string $status): string { return match ($status) { ReviewPublicationResolutionCaseStatus::Completed->value => 'success', ReviewPublicationResolutionCaseStatus::Blocked->value => 'danger', ReviewPublicationResolutionCaseStatus::WaitingForRun->value => 'info', ReviewPublicationResolutionCaseStatus::ReadyToContinue->value => 'warning', ReviewPublicationResolutionCaseStatus::Cancelled->value, ReviewPublicationResolutionCaseStatus::Superseded->value => 'gray', default => 'primary', }; } private function stepStatusColor(string $status): string { return match ($status) { ReviewPublicationResolutionStepStatus::Completed->value => 'success', ReviewPublicationResolutionStepStatus::Failed->value => 'danger', ReviewPublicationResolutionStepStatus::Running->value => 'info', ReviewPublicationResolutionStepStatus::Actionable->value => 'warning', ReviewPublicationResolutionStepStatus::Superseded->value => 'gray', default => 'gray', }; } private function reportRequirementLabel(string $dimension): string { return match ($dimension) { 'permission_posture' => 'Permission posture', 'entra_admin_roles' => 'Entra admin roles', default => str($dimension)->replace('_', ' ')->title()->toString(), }; } private function nextStepDescription( ?ReviewPublicationResolutionStepKey $stepKey, ?ReviewPublicationResolutionStepStatus $stepStatus = null, ): string { if ($stepStatus === ReviewPublicationResolutionStepStatus::Running) { return 'TenantPilot is waiting for the linked operation to finish. No new start action is available while it runs.'; } if ($stepStatus === ReviewPublicationResolutionStepStatus::Failed) { return 'The last operation did not complete. Review the linked operation, then retry the current preparation action when you are ready.'; } return match ($stepKey) { ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'TenantPilot will update the missing required reports. It will not publish the review.', ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'This will collect a current evidence snapshot for the review. It will not publish the review.', ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'This will refresh the review from current evidence. It will not publish the review.', ReviewPublicationResolutionStepKey::GenerateReviewPack => 'This will prepare the customer-ready export package. It will not publish the review.', ReviewPublicationResolutionStepKey::ReturnToPublication => 'All checks are resolved. Return to the review and use the existing publish action when you are ready.', default => 'TenantPilot will continue the next safe preparation step. It will not publish the review.', }; } private function afterNextStepDescription(?ReviewPublicationResolutionStepKey $stepKey): string { return match ($stepKey) { ReviewPublicationResolutionStepKey::ReturnToPublication => 'Publishing remains a separate action on the review page.', default => 'After this, TenantPilot re-checks the evidence, refreshes the review if needed, prepares the export if needed, and sends you back to the review. Publishing remains a separate action.', }; } private function currentStepConfirmationDescription(): string { return match ($this->currentStep()?->stepKeyEnum()) { ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'TenantPilot will update the missing required reports. This will not publish the review.', ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'TenantPilot will collect a current evidence snapshot for this review. This will not publish the review.', ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'TenantPilot will refresh the review from current evidence. This will not publish the review.', ReviewPublicationResolutionStepKey::GenerateReviewPack => 'TenantPilot will prepare the customer-ready export package for this review. This will not publish the review.', ReviewPublicationResolutionStepKey::ReturnToPublication => 'TenantPilot will return you to the review. Publishing remains a separate action.', default => 'TenantPilot will continue the next safe preparation step. This will not publish the review.', }; } private function currentStepConfirmationHeading(): string { return $this->currentStepActionLabel().'?'; } private function blockedSummary(int $blockerCount, int $missingReportCount): string { if ($missingReportCount > 0) { return sprintf( 'TenantPilot found %d required %s that must be updated before this review can become customer-ready.', $missingReportCount, $missingReportCount === 1 ? 'report' : 'reports', ); } if ($blockerCount > 0) { return sprintf( 'TenantPilot found %d missing %s before this review can become customer-ready.', $blockerCount, $blockerCount === 1 ? 'requirement' : 'requirements', ); } return 'TenantPilot is checking the remaining publication prerequisites before this review returns to the publish action.'; } private function operatorBlockerLabel(string $blocker): string { $blocker = trim($blocker); if ($blocker === '') { return ''; } if (str($blocker)->lower()->contains('report-backed evidence')) { return 'Required reports are missing.'; } return $blocker; } private function operatorStepLabel(?ReviewPublicationResolutionStepKey $stepKey): string { return match ($stepKey) { ReviewPublicationResolutionStepKey::ValidateReviewReadiness => 'Check readiness', ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'Update required reports', ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'Collect evidence', ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'Refresh review', ReviewPublicationResolutionStepKey::GenerateReviewPack => 'Prepare export', ReviewPublicationResolutionStepKey::ReturnToPublication => 'Return to review', default => 'Continue preparation', }; } private function operatorStepDescription(?ReviewPublicationResolutionStepKey $stepKey): string { return match ($stepKey) { ReviewPublicationResolutionStepKey::ValidateReviewReadiness => 'TenantPilot checks whether the review is safe to prepare for publication.', ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'Required reports must be current before publication can continue.', ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'The review needs a complete and current evidence snapshot.', ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'The review is rebuilt from the latest evidence and report state.', ReviewPublicationResolutionStepKey::GenerateReviewPack => 'The customer-ready export is prepared before returning to the review.', ReviewPublicationResolutionStepKey::ReturnToPublication => 'Return to the review and decide whether to publish.', default => 'TenantPilot prepares the next safe publication prerequisite.', }; } private function proofLabelFromState(ReviewPublicationResolutionStep $step): string { return match ((string) $step->status) { ReviewPublicationResolutionStepStatus::Running->value => 'Operation running', ReviewPublicationResolutionStepStatus::Failed->value => 'Action failed', ReviewPublicationResolutionStepStatus::Completed->value => 'Current proof', default => is_string($step->proof_type) ? 'Proof cannot be verified' : 'Proof missing', }; } }