TenantAtlas/apps/platform/app/Services/PortfolioCompare/CrossTenantPromotionExecutionService.php
Ahmed Darrazi 983abb18a1
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 3m22s
chore: commit workspace changes (automated)
2026-05-02 16:36:21 +02:00

162 lines
6.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\PortfolioCompare;
use App\Jobs\Operations\CrossTenantPromotionExecutionJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\OperationRunService;
use App\Services\Providers\ProviderOperationStartResult;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\OperationalControls\OperationalControlBlockedException;
use App\Support\OperationalControls\OperationalControlEvaluator;
use App\Support\OperationRunStatus;
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
use App\Support\PortfolioCompare\CrossTenantPromotionExecutionPlanner;
use Carbon\CarbonImmutable;
final class CrossTenantPromotionExecutionService
{
public function __construct(
private readonly CrossTenantPromotionExecutionPlanner $planner,
private readonly OperationRunService $operationRuns,
private readonly WorkspaceAuditLogger $auditLogger,
private readonly OperationalControlEvaluator $operationalControls,
) {}
/**
* @param array<string, mixed> $preview
* @param array<string, mixed> $preflight
*/
public function start(
CrossTenantCompareSelection $selection,
array $preview,
array $preflight,
User $actor,
): ProviderOperationStartResult {
$workspace = $selection->targetTenant->workspace;
if (! $workspace instanceof Workspace) {
throw new \RuntimeException('Promotion execution requires a workspace context.');
}
$decision = $this->operationalControls->evaluate('promotion.execute', $workspace);
if ($decision->isPaused()) {
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::OperationalControlExecutionBlocked,
context: [
'metadata' => array_filter([
'control_key' => $decision->controlKey,
'scope_type' => $decision->matchedScopeType,
'workspace_id' => (int) $workspace->getKey(),
'reason_text' => $decision->reasonText,
'expires_at' => $decision->expiresAt?->toIso8601String(),
'actor_id' => (int) $actor->getKey(),
'source_tenant_id' => (int) $selection->sourceTenant->getKey(),
'target_tenant_id' => (int) $selection->targetTenant->getKey(),
'requested_scope' => 'promotion.execute',
], static fn (mixed $value): bool => $value !== null && $value !== ''),
],
actor: $actor,
status: 'blocked',
resourceType: 'operational_control',
resourceId: $decision->sourceActivationId !== null ? (string) $decision->sourceActivationId : null,
targetLabel: 'Promotion execution',
summary: 'Promotion execution blocked by operational control',
tenant: $selection->targetTenant,
);
throw OperationalControlBlockedException::forDecision($decision, 'Promotion execution');
}
$plan = $this->planner->build($preview, $preflight);
$providerConnection = $this->defaultProviderConnection((int) $selection->targetTenant->getKey());
$now = CarbonImmutable::now();
$identity = array_replace($plan['identity'], [
'provider_connection_id' => $providerConnection?->getKey(),
]);
$context = [
'operation_type' => 'promotion.execute',
'source_tenant_id' => (int) $selection->sourceTenant->getKey(),
'source_tenant_name' => (string) $selection->sourceTenant->name,
'target_tenant_id' => (int) $selection->targetTenant->getKey(),
'target_tenant_name' => (string) $selection->targetTenant->name,
'provider_connection_id' => $providerConnection instanceof ProviderConnection ? (int) $providerConnection->getKey() : null,
'required_capability' => Capabilities::TENANT_MANAGE,
'workspace_required_capability' => Capabilities::WORKSPACE_BASELINES_MANAGE,
'target_scope' => [
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $selection->targetTenant->getKey(),
'provider_connection_id' => $providerConnection instanceof ProviderConnection ? (int) $providerConnection->getKey() : null,
'entra_tenant_id' => $providerConnection instanceof ProviderConnection
? (string) $providerConnection->entra_tenant_id
: (string) ($selection->targetTenant->tenant_id ?? $selection->targetTenant->external_id ?? $selection->targetTenant->getKey()),
],
'promotion_execution' => [
'queued_at' => $now->toIso8601String(),
'queued_by_user_id' => (int) $actor->getKey(),
'plan' => $plan,
],
'selection' => $plan['selection'],
];
$run = $this->operationRuns->ensureRunWithIdentity(
tenant: $selection->targetTenant,
type: 'promotion.execute',
identityInputs: $identity,
context: $context,
initiator: $actor,
);
if (! $run->wasRecentlyCreated) {
return ProviderOperationStartResult::deduped($run);
}
$this->operationRuns->updateRun($run, OperationRunStatus::Queued->value, summaryCounts: [
'total' => (int) $plan['summary']['ready'],
'processed' => 0,
'succeeded' => 0,
'failed' => 0,
'skipped' => 0,
'created' => 0,
'updated' => 0,
]);
$this->operationRuns->dispatchOrFail(
$run,
fn (OperationRun $operationRun): mixed => CrossTenantPromotionExecutionJob::dispatch($operationRun),
);
$this->auditLogger->logCrossTenantPromotionExecutionQueued(
workspace: $workspace,
sourceTenant: $selection->sourceTenant,
targetTenant: $selection->targetTenant,
operationRun: $run->fresh() ?? $run,
plan: $plan,
actor: $actor,
);
return ProviderOperationStartResult::started($run->fresh() ?? $run, true);
}
private function defaultProviderConnection(int $tenantId): ?ProviderConnection
{
return ProviderConnection::query()
->where('tenant_id', $tenantId)
->where('provider', 'microsoft')
->where('is_default', true)
->orderBy('id')
->first();
}
}