TenantAtlas/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionActionService.php
Ahmed Darrazi 5c02afcae8
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 6m16s
feat: implement ReviewPublicationResolutionWorkflow (Spec 386)
2026-06-18 22:57:10 +02:00

474 lines
19 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\ReviewPublicationResolution;
use App\Jobs\ScanEntraAdminRolesJob;
use App\Models\EnvironmentReview;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ReviewPublicationResolutionCase;
use App\Models\ReviewPublicationResolutionStep;
use App\Models\User;
use App\Services\EnvironmentReviews\EnvironmentReviewReadinessGate;
use App\Services\EnvironmentReviews\EnvironmentReviewService;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Services\OperationRunService;
use App\Services\ReviewPackService;
use App\Services\Verification\StartVerification;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunType;
use App\Support\ReviewPackStatus;
use Illuminate\Support\Facades\Gate;
use InvalidArgumentException;
use Throwable;
final class ReviewPublicationResolutionActionService
{
public function __construct(
private readonly ReviewPublicationResolutionService $caseService,
private readonly EvidenceSnapshotService $evidenceSnapshots,
private readonly EnvironmentReviewService $environmentReviews,
private readonly EnvironmentReviewReadinessGate $readinessGate,
private readonly ReviewPackService $reviewPacks,
private readonly OperationRunService $operationRuns,
private readonly StartVerification $verification,
private readonly ReviewPublicationResolutionStepAuthorizer $stepAuthorizer,
) {}
/**
* @return array{case:ReviewPublicationResolutionCase, step:ReviewPublicationResolutionStep, operation_run:?OperationRun, operation_type:?string}
*/
public function executeCurrentStep(ReviewPublicationResolutionCase $case, User $actor): array
{
Gate::forUser($actor)->authorize('executeStep', $case);
$case = $this->caseService->refreshCase($case, $actor);
Gate::forUser($actor)->authorize('executeStep', $case);
$step = $case->currentStep();
if (! $step instanceof ReviewPublicationResolutionStep) {
throw new InvalidArgumentException('There is no actionable resolution step.');
}
$stepKey = ReviewPublicationResolutionStepKey::tryFrom((string) $step->step_key);
if (! $stepKey instanceof ReviewPublicationResolutionStepKey) {
throw new InvalidArgumentException('The current resolution step is not recognized.');
}
$case->loadMissing(['environmentReview.tenant', 'tenant']);
$review = $case->environmentReview;
$tenant = $case->tenant;
if (! $review instanceof EnvironmentReview || ! $tenant instanceof ManagedEnvironment) {
throw new InvalidArgumentException('The resolution case is missing its review context.');
}
if (! $this->stepAuthorizer->canExecuteStep($actor, $tenant, $step)) {
abort(403);
}
$this->caseService->recordAudit(
case: $case,
action: AuditActionId::ReviewPublicationResolutionStepStarted,
actor: $actor,
metadata: [
'step_key' => $stepKey->value,
],
);
try {
return match ($stepKey) {
ReviewPublicationResolutionStepKey::CompleteRequiredReports => $this->completeRequiredReports($case, $step, $tenant, $actor),
ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => $this->collectEvidence($case, $step, $tenant, $actor),
ReviewPublicationResolutionStepKey::RefreshReviewComposition => $this->refreshReviewComposition($case, $step, $review, $actor),
ReviewPublicationResolutionStepKey::GenerateReviewPack => $this->generateReviewPack($case, $step, $review, $actor),
ReviewPublicationResolutionStepKey::ReturnToPublication => $this->returnToPublication($case, $step, $actor),
ReviewPublicationResolutionStepKey::ValidateReviewReadiness => throw new InvalidArgumentException('Readiness validation is automatic and cannot be manually executed.'),
};
} catch (Throwable $throwable) {
$step->forceFill([
'status' => ReviewPublicationResolutionStepStatus::Failed->value,
'failed_at' => now(),
'summary' => array_replace(is_array($step->summary) ? $step->summary : [], [
'state_description' => 'Step failed before it could be queued.',
'failure_code' => 'review_publication_resolution.step_failed_before_queue',
]),
])->save();
$case->forceFill([
'status' => ReviewPublicationResolutionCaseStatus::Blocked->value,
'current_step_key' => $stepKey->value,
])->save();
$this->caseService->recordAudit(
case: $case,
action: AuditActionId::ReviewPublicationResolutionStepFailed,
actor: $actor,
metadata: [
'step_key' => $stepKey->value,
'failure_code' => 'review_publication_resolution.step_failed_before_queue',
],
);
throw $throwable;
}
}
/**
* @return array{case:ReviewPublicationResolutionCase, step:ReviewPublicationResolutionStep, operation_run:?OperationRun, operation_type:?string}
*/
private function completeRequiredReports(
ReviewPublicationResolutionCase $case,
ReviewPublicationResolutionStep $step,
ManagedEnvironment $tenant,
User $actor,
): array {
$missingDimensions = array_values(array_filter(
(array) data_get($step->summary, 'missing_report_dimensions', []),
static fn (mixed $dimension): bool => is_string($dimension) && trim($dimension) !== '',
));
$targetDimension = (string) ($missingDimensions[0] ?? '');
if ($targetDimension === '') {
$step->forceFill([
'status' => ReviewPublicationResolutionStepStatus::Completed->value,
'completed_at' => now(),
'summary' => array_replace(is_array($step->summary) ? $step->summary : [], [
'state_description' => 'Required reports are already current.',
]),
])->save();
$this->caseService->refreshCase($case, $actor);
return [
'case' => $case->fresh(['steps.operationRun', 'environmentReview.tenant']),
'step' => $step->fresh('operationRun'),
'operation_run' => null,
'operation_type' => null,
];
}
if ($targetDimension === 'permission_posture') {
Gate::forUser($actor)->authorize(Capabilities::PROVIDER_RUN, $tenant);
$result = $this->verification->providerConnectionCheckForTenant($tenant, $actor, [
'trigger' => 'review_publication_resolution',
'review_publication_resolution_case_id' => (int) $case->getKey(),
'environment_review_id' => (int) $case->environment_review_id,
]);
$this->markQueuedOrCompleted(
case: $case,
step: $step,
proofType: 'operation_run',
proofId: (int) $result->run->getKey(),
proofStatus: (string) $result->run->status,
operationRun: $result->run,
actor: $actor,
operationType: 'provider.connection.check',
summary: [
'target_report_dimension' => $targetDimension,
],
);
return [
'case' => $case->fresh(['steps.operationRun', 'environmentReview.tenant']),
'step' => $step->fresh('operationRun'),
'operation_run' => $result->run,
'operation_type' => 'provider.connection.check',
];
}
if ($targetDimension === 'entra_admin_roles') {
Gate::forUser($actor)->authorize(Capabilities::ENTRA_ROLES_MANAGE, $tenant);
$operationRun = $this->operationRuns->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::EntraAdminRolesScan->value,
identityInputs: [
'managed_environment_id' => (int) $tenant->getKey(),
'trigger' => 'scan',
],
context: [
'workspace_id' => (int) $tenant->workspace_id,
'initiator_user_id' => (int) $actor->getKey(),
'review_publication_resolution_case_id' => (int) $case->getKey(),
'environment_review_id' => (int) $case->environment_review_id,
'trigger' => 'review_publication_resolution',
],
initiator: $actor,
);
if ($operationRun->wasRecentlyCreated) {
$this->operationRuns->dispatchOrFail(
$operationRun,
fn (): mixed => ScanEntraAdminRolesJob::dispatch(
tenantId: (int) $tenant->getKey(),
workspaceId: (int) $tenant->workspace_id,
initiatorUserId: (int) $actor->getKey(),
),
);
}
$this->markQueuedOrCompleted(
case: $case,
step: $step,
proofType: 'operation_run',
proofId: (int) $operationRun->getKey(),
proofStatus: (string) $operationRun->status,
operationRun: $operationRun,
actor: $actor,
operationType: OperationRunType::EntraAdminRolesScan->value,
summary: [
'target_report_dimension' => $targetDimension,
],
);
return [
'case' => $case->fresh(['steps.operationRun', 'environmentReview.tenant']),
'step' => $step->fresh('operationRun'),
'operation_run' => $operationRun,
'operation_type' => OperationRunType::EntraAdminRolesScan->value,
];
}
throw new InvalidArgumentException('The required report dimension is not executable by this workflow.');
}
/**
* @return array{case:ReviewPublicationResolutionCase, step:ReviewPublicationResolutionStep, operation_run:?OperationRun, operation_type:?string}
*/
private function collectEvidence(
ReviewPublicationResolutionCase $case,
ReviewPublicationResolutionStep $step,
ManagedEnvironment $tenant,
User $actor,
): array {
Gate::forUser($actor)->authorize(Capabilities::EVIDENCE_MANAGE, $tenant);
$snapshot = $this->evidenceSnapshots->generate($tenant, $actor);
$operationRun = $snapshot->operationRun;
$this->markQueuedOrCompleted(
case: $case,
step: $step,
proofType: 'evidence_snapshot',
proofId: (int) $snapshot->getKey(),
proofStatus: (string) $snapshot->status,
operationRun: $operationRun,
actor: $actor,
operationType: OperationRunType::EvidenceSnapshotGenerate->value,
);
return [
'case' => $case->fresh(['steps.operationRun', 'environmentReview.tenant']),
'step' => $step->fresh('operationRun'),
'operation_run' => $operationRun,
'operation_type' => OperationRunType::EvidenceSnapshotGenerate->value,
];
}
/**
* @return array{case:ReviewPublicationResolutionCase, step:ReviewPublicationResolutionStep, operation_run:?OperationRun, operation_type:?string}
*/
private function refreshReviewComposition(
ReviewPublicationResolutionCase $case,
ReviewPublicationResolutionStep $step,
EnvironmentReview $review,
User $actor,
): array {
Gate::forUser($actor)->authorize('refresh', $review);
$review = $this->environmentReviews->refresh($review, $actor);
$operationRun = $review->operationRun;
$this->markQueuedOrCompleted(
case: $case,
step: $step,
proofType: 'environment_review',
proofId: (int) $review->getKey(),
proofStatus: (string) $review->status,
operationRun: $operationRun,
actor: $actor,
operationType: OperationRunType::EnvironmentReviewCompose->value,
);
return [
'case' => $case->fresh(['steps.operationRun', 'environmentReview.tenant']),
'step' => $step->fresh('operationRun'),
'operation_run' => $operationRun,
'operation_type' => OperationRunType::EnvironmentReviewCompose->value,
];
}
/**
* @return array{case:ReviewPublicationResolutionCase, step:ReviewPublicationResolutionStep, operation_run:?OperationRun, operation_type:?string}
*/
private function generateReviewPack(
ReviewPublicationResolutionCase $case,
ReviewPublicationResolutionStep $step,
EnvironmentReview $review,
User $actor,
): array {
Gate::forUser($actor)->authorize('export', $review);
if (! $this->readinessGate->canExport($review)) {
throw new InvalidArgumentException('Review blockers must be resolved before generating the publication pack.');
}
$pack = $this->reviewPacks->generateFromReview($review, $actor, [
'include_pii' => false,
'include_operations' => true,
]);
$operationRun = $pack->operationRun;
if ($pack->status === ReviewPackStatus::Ready->value && (int) $review->current_export_review_pack_id !== (int) $pack->getKey()) {
$review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save();
}
$this->markQueuedOrCompleted(
case: $case,
step: $step,
proofType: 'review_pack',
proofId: (int) $pack->getKey(),
proofStatus: (string) $pack->status,
operationRun: $operationRun,
actor: $actor,
operationType: OperationRunType::ReviewPackGenerate->value,
);
return [
'case' => $case->fresh(['steps.operationRun', 'environmentReview.tenant']),
'step' => $step->fresh('operationRun'),
'operation_run' => $operationRun,
'operation_type' => OperationRunType::ReviewPackGenerate->value,
];
}
/**
* @return array{case:ReviewPublicationResolutionCase, step:ReviewPublicationResolutionStep, operation_run:?OperationRun, operation_type:?string}
*/
private function returnToPublication(
ReviewPublicationResolutionCase $case,
ReviewPublicationResolutionStep $step,
User $actor,
): array {
$step->forceFill([
'status' => ReviewPublicationResolutionStepStatus::Completed->value,
'completed_at' => now(),
'proof_type' => 'environment_review',
'proof_id' => (int) $case->environment_review_id,
'proof_status' => 'ready',
'summary' => array_replace(is_array($step->summary) ? $step->summary : [], [
'state_description' => 'Returned to the publication workflow.',
]),
])->save();
$case->forceFill([
'status' => ReviewPublicationResolutionCaseStatus::Completed->value,
'current_step_key' => null,
'completed_at' => now(),
])->save();
$this->caseService->recordAudit(
case: $case,
action: AuditActionId::ReviewPublicationResolutionStepCompleted,
actor: $actor,
metadata: [
'step_key' => ReviewPublicationResolutionStepKey::ReturnToPublication->value,
],
);
$this->caseService->recordAudit(
case: $case,
action: AuditActionId::ReviewPublicationResolutionCompleted,
actor: $actor,
metadata: [
'environment_review_id' => (int) $case->environment_review_id,
],
);
return [
'case' => $case->fresh(['steps.operationRun', 'environmentReview.tenant']),
'step' => $step->fresh('operationRun'),
'operation_run' => null,
'operation_type' => null,
];
}
private function markQueuedOrCompleted(
ReviewPublicationResolutionCase $case,
ReviewPublicationResolutionStep $step,
string $proofType,
int $proofId,
string $proofStatus,
?OperationRun $operationRun,
User $actor,
string $operationType,
array $summary = [],
): void {
$isReadyProof = in_array($proofStatus, ['active', 'ready', 'complete'], true);
$step->forceFill([
'status' => $isReadyProof && ! $operationRun?->wasRecentlyCreated
? ReviewPublicationResolutionStepStatus::Completed->value
: ReviewPublicationResolutionStepStatus::Running->value,
'started_at' => $step->started_at ?? now(),
'completed_at' => $isReadyProof && ! $operationRun?->wasRecentlyCreated
? now()
: $step->completed_at,
'operation_run_id' => $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null,
'proof_type' => $proofType,
'proof_id' => $proofId,
'proof_status' => $proofStatus,
'summary' => array_replace(is_array($step->summary) ? $step->summary : [], [
'state_description' => $isReadyProof && ! $operationRun?->wasRecentlyCreated
? 'Requirement is satisfied.'
: 'Queued for execution. Open the linked operation for progress.',
], $summary),
])->save();
$case->forceFill([
'status' => $step->status === ReviewPublicationResolutionStepStatus::Running->value
? ReviewPublicationResolutionCaseStatus::WaitingForRun->value
: ReviewPublicationResolutionCaseStatus::InProgress->value,
'current_step_key' => (string) $step->step_key,
])->save();
if ($operationRun instanceof OperationRun) {
$this->caseService->recordAudit(
case: $case,
action: AuditActionId::ReviewPublicationResolutionOperationLinked,
actor: $actor,
metadata: [
'step_key' => (string) $step->step_key,
'operation_type' => $operationType,
'proof_type' => $proofType,
'proof_id' => $proofId,
],
operationRun: $operationRun,
);
}
if ($step->status === ReviewPublicationResolutionStepStatus::Completed->value) {
$this->caseService->recordAudit(
case: $case,
action: AuditActionId::ReviewPublicationResolutionStepCompleted,
actor: $actor,
metadata: [
'step_key' => (string) $step->step_key,
'proof_type' => $proofType,
'proof_id' => $proofId,
],
operationRun: $operationRun,
);
$this->caseService->refreshCase($case, $actor);
}
}
}