Automated PR created by Copilot: adds implementation and tests for specs/264 cross-tenant promotion execution. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #320
162 lines
6.7 KiB
PHP
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();
|
|
}
|
|
}
|