## Summary - Adds the Spec 431 Exchange PowerShell invocation gate and operation registration slice. - Introduces trusted provider operation start handling and read-only Exchange PowerShell runner abstractions. - Updates provider capability/readiness evaluation and coverage for Exchange PowerShell invocation safety. ## Verification - `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Providers/ProviderCapabilityEvaluationTest.php tests/Feature/Providers/ProviderOperationCapabilityGateTest.php tests/Feature/TenantConfiguration/Spec431ExchangePowerShellInvocationGateTest.php tests/Unit/Providers/ProviderCapabilityRegistryTest.php tests/Unit/Providers/ProviderOperationStartGateTest.php tests/Unit/Support/TenantConfiguration/Spec430ExchangePowerShellCommandAllowlistTest.php tests/Unit/Support/TenantConfiguration/Spec431ExchangePowerShellOperationRegistrationTest.php tests/Unit/Verification/ManagedEnvironmentPermissionCapabilityMappingTest.php` - Result: 80 passed, 685 assertions. ## Product Surface / Ops - Rendered UI surface changed: N/A. - Filament/Livewire surface changed: N/A. - Deployment impact: config/runtime service changes only; no migrations, queues, or assets added. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #498
732 lines
27 KiB
PHP
732 lines
27 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\TenantConfiguration;
|
|
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\TenantConfigurationResourceType;
|
|
use App\Models\User;
|
|
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
|
use App\Services\OperationRunService;
|
|
use App\Services\Providers\ProviderIdentityResolver;
|
|
use App\Services\Providers\ProviderOperationRegistry;
|
|
use App\Services\Providers\ProviderOperationTrustedStarter;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use App\Support\Operations\ExecutionAuthorityMode;
|
|
use App\Support\Operations\OperationRunCapabilityResolver;
|
|
use App\Support\Providers\Capabilities\ProviderCapabilityRegistry;
|
|
use App\Support\Providers\ProviderReasonCodes;
|
|
use Illuminate\Auth\Access\AuthorizationException;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
use Throwable;
|
|
|
|
final class ExchangePowerShellInvocationGate
|
|
{
|
|
public const string OPERATION_TYPE = OperationRunType::TenantConfigurationExchangePowerShellInvocation->value;
|
|
|
|
public const string FEATURE_CONFIG_PATH = 'tenantpilot.features.exchange_powershell_invocation';
|
|
|
|
public const string RUNNER_MODE_FAKE = 'fake';
|
|
|
|
public const string RUNNER_MODE_DISABLED = 'disabled';
|
|
|
|
public const string REDACTION_POLICY = 'strict_no_raw_output';
|
|
|
|
public function __construct(
|
|
private readonly ManagedEnvironmentAccessScopeResolver $accessScopeResolver,
|
|
private readonly ProviderOperationTrustedStarter $providerOperationStarter,
|
|
private readonly OperationRunService $operationRuns,
|
|
private readonly ProviderIdentityResolver $identityResolver,
|
|
private readonly ProviderOperationRegistry $providerOperationRegistry,
|
|
private readonly ProviderCapabilityRegistry $providerCapabilityRegistry,
|
|
private readonly OperationRunCapabilityResolver $capabilityResolver,
|
|
private readonly ResourceTypeRegistry $resourceTypeRegistry,
|
|
private readonly CoverageSourceContractResolver $sourceContractResolver,
|
|
private readonly ExchangePowerShellCommandContracts $commandContracts,
|
|
private readonly ExchangePowerShellCommandRunner $runner,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<string, mixed> $parameters
|
|
*/
|
|
public function invoke(
|
|
ManagedEnvironment $tenant,
|
|
ProviderConnection $providerConnection,
|
|
User $actor,
|
|
string $canonicalType,
|
|
?string $commandName = null,
|
|
array $parameters = [],
|
|
string $runnerMode = self::RUNNER_MODE_DISABLED,
|
|
): OperationRun {
|
|
$this->authorize($tenant, $actor);
|
|
$this->assertProviderConnectionInScope($tenant, $providerConnection);
|
|
|
|
$canonicalType = trim($canonicalType);
|
|
$runnerMode = $this->normalizeRunnerMode($runnerMode);
|
|
$startedRun = null;
|
|
|
|
$result = $this->providerOperationStarter->start(
|
|
tenant: $tenant,
|
|
connection: $providerConnection,
|
|
operationType: self::OPERATION_TYPE,
|
|
dispatcher: function (OperationRun $run) use (&$startedRun): void {
|
|
$startedRun = $run;
|
|
},
|
|
initiator: $actor,
|
|
extraContext: $this->initialContext($tenant, $providerConnection, $canonicalType, $commandName, $parameters, $runnerMode),
|
|
);
|
|
|
|
if ($startedRun instanceof OperationRun && $result->status === 'started' && $result->dispatched) {
|
|
return $this->executeStartedRun(
|
|
run: $startedRun,
|
|
tenant: $tenant,
|
|
providerConnection: $providerConnection,
|
|
canonicalType: $canonicalType,
|
|
commandName: $commandName,
|
|
parameters: $parameters,
|
|
runnerMode: $runnerMode,
|
|
);
|
|
}
|
|
|
|
return $result->run->fresh() ?? $result->run;
|
|
}
|
|
|
|
private function authorize(ManagedEnvironment $tenant, User $actor): void
|
|
{
|
|
$decision = $this->accessScopeResolver->decision($actor, $tenant, Capabilities::PROVIDER_RUN);
|
|
|
|
if ($decision->allowed()) {
|
|
return;
|
|
}
|
|
|
|
if ($decision->shouldDenyAsNotFound()) {
|
|
throw new NotFoundHttpException('Managed environment not found.');
|
|
}
|
|
|
|
throw (new AuthorizationException('This action is unauthorized.'))->withStatus(403);
|
|
}
|
|
|
|
private function assertProviderConnectionInScope(ManagedEnvironment $tenant, ProviderConnection $providerConnection): void
|
|
{
|
|
if ((int) $providerConnection->managed_environment_id !== (int) $tenant->getKey()
|
|
|| (int) $providerConnection->workspace_id !== (int) $tenant->workspace_id
|
|
) {
|
|
throw new NotFoundHttpException('Provider connection not found.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $parameters
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function initialContext(
|
|
ManagedEnvironment $tenant,
|
|
ProviderConnection $providerConnection,
|
|
string $canonicalType,
|
|
?string $commandName,
|
|
array $parameters,
|
|
string $runnerMode,
|
|
): array {
|
|
$context = [
|
|
'operation' => [
|
|
'type' => self::OPERATION_TYPE,
|
|
],
|
|
'source' => 'spec431_exchange_powershell_invocation_gate',
|
|
'target_resource_type' => $this->safeTargetResourceType($canonicalType),
|
|
'parameter_count' => count($parameters),
|
|
'runner_mode' => $this->knownRunnerMode($runnerMode) ? $runnerMode : 'invalid',
|
|
'redaction_policy' => self::REDACTION_POLICY,
|
|
'required_capability' => Capabilities::PROVIDER_RUN,
|
|
'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value,
|
|
'feature_gate' => [
|
|
'operation_type' => self::OPERATION_TYPE,
|
|
'config_path' => self::FEATURE_CONFIG_PATH,
|
|
'enabled' => (bool) config(self::FEATURE_CONFIG_PATH, false),
|
|
],
|
|
'provider_identity_resolution_mode' => $runnerMode === self::RUNNER_MODE_FAKE
|
|
? 'fake_runner_bypass'
|
|
: 'provider_identity_required',
|
|
'target_scope' => [
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'provider_connection_id' => (int) $providerConnection->getKey(),
|
|
],
|
|
];
|
|
|
|
$safeCommandName = $this->safeCommandName($canonicalType, $commandName);
|
|
|
|
if ($safeCommandName !== null) {
|
|
$context['command_name'] = $safeCommandName;
|
|
}
|
|
|
|
return $context;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $parameters
|
|
*/
|
|
private function executeStartedRun(
|
|
OperationRun $run,
|
|
ManagedEnvironment $tenant,
|
|
ProviderConnection $providerConnection,
|
|
string $canonicalType,
|
|
?string $commandName,
|
|
array $parameters,
|
|
string $runnerMode,
|
|
): OperationRun {
|
|
try {
|
|
$registryFailure = $this->registryFailure();
|
|
|
|
if ($registryFailure !== null) {
|
|
return $this->blockRun(
|
|
run: $run,
|
|
reasonCode: ProviderReasonCodes::ProviderBindingUnsupported,
|
|
failureCode: 'operation_registration_invalid',
|
|
message: $registryFailure,
|
|
);
|
|
}
|
|
|
|
$contract = $this->commandContracts->contractForCanonicalType($canonicalType);
|
|
|
|
if ($contract === null) {
|
|
return $this->blockRun(
|
|
run: $run,
|
|
reasonCode: ProviderReasonCodes::ProviderBindingUnsupported,
|
|
failureCode: 'source_contract_missing',
|
|
message: 'Exchange PowerShell source contract is not registered for this resource type.',
|
|
context: ['target_resource_type' => $this->safeTargetResourceType($canonicalType)],
|
|
);
|
|
}
|
|
|
|
$sourceDecision = $this->sourceContractDecision($canonicalType);
|
|
|
|
if (! $this->sourceContractAllowsInvocation($sourceDecision)) {
|
|
return $this->blockRun(
|
|
run: $run,
|
|
reasonCode: ProviderReasonCodes::ProviderBindingUnsupported,
|
|
failureCode: 'source_contract_not_verified',
|
|
message: 'Exchange PowerShell source contract is not verified for invocation.',
|
|
context: $this->sourceContractContext($sourceDecision),
|
|
);
|
|
}
|
|
|
|
$run = $this->mergeContext($run, [
|
|
'source_contract' => $this->sourceContractContext($sourceDecision),
|
|
]);
|
|
|
|
$resolvedCommandName = $commandName === null
|
|
? (string) $contract['command_name']
|
|
: trim($commandName);
|
|
|
|
if ($resolvedCommandName !== (string) $contract['command_name']) {
|
|
return $this->blockRun(
|
|
run: $run,
|
|
reasonCode: ProviderReasonCodes::ProviderBindingUnsupported,
|
|
failureCode: 'command_contract_rejected',
|
|
message: 'Exchange PowerShell command is not allowed for the requested resource type.',
|
|
context: ['command_validation' => ['reason_code' => 'command_contract_mismatch']],
|
|
);
|
|
}
|
|
|
|
$validation = $this->commandContracts->validateInvocation($contract, $parameters);
|
|
|
|
if (! $validation['accepted']) {
|
|
return $this->blockRun(
|
|
run: $run,
|
|
reasonCode: ProviderReasonCodes::ProviderBindingUnsupported,
|
|
failureCode: 'command_contract_rejected',
|
|
message: 'Exchange PowerShell invocation contract rejected the request.',
|
|
context: ['command_validation' => $this->safeCommandValidationContext($validation)],
|
|
);
|
|
}
|
|
|
|
if (! $this->knownRunnerMode($runnerMode)) {
|
|
return $this->blockRun(
|
|
run: $run,
|
|
reasonCode: ProviderReasonCodes::ProviderBindingUnsupported,
|
|
failureCode: 'execution_blocked_runner_mode_invalid',
|
|
message: 'Exchange PowerShell runner mode is not supported.',
|
|
context: ['runner_mode' => 'invalid'],
|
|
);
|
|
}
|
|
|
|
if (! (bool) config(self::FEATURE_CONFIG_PATH, false)) {
|
|
return $this->blockRun(
|
|
run: $run,
|
|
reasonCode: ProviderReasonCodes::ProviderBindingUnsupported,
|
|
failureCode: 'execution_blocked_feature_disabled',
|
|
message: 'Exchange PowerShell invocation feature gate is disabled.',
|
|
context: [
|
|
'feature_gate' => [
|
|
'operation_type' => self::OPERATION_TYPE,
|
|
'config_path' => self::FEATURE_CONFIG_PATH,
|
|
'enabled' => false,
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
$credentialSource = null;
|
|
|
|
if ($runnerMode === self::RUNNER_MODE_FAKE) {
|
|
$run = $this->mergeContext($run, [
|
|
'credential_policy' => [
|
|
'mode' => 'fake_runner_bypass',
|
|
'live_credentials_required' => false,
|
|
],
|
|
]);
|
|
} else {
|
|
$identity = $this->identityResolver->resolve($providerConnection);
|
|
|
|
if (! $identity->resolved) {
|
|
return $this->blockRun(
|
|
run: $run,
|
|
reasonCode: $identity->effectiveReasonCode(),
|
|
failureCode: 'execution_blocked_missing_credential_reference',
|
|
message: $identity->message ?? 'Provider identity could not be resolved.',
|
|
context: [
|
|
'credential_policy' => [
|
|
'mode' => 'provider_identity_required',
|
|
'credential_source' => $identity->credentialSource,
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
$credentialSource = $identity->credentialSource;
|
|
$run = $this->mergeContext($run, [
|
|
'credential_policy' => [
|
|
'mode' => 'provider_identity_required',
|
|
'credential_source' => $credentialSource,
|
|
],
|
|
]);
|
|
}
|
|
|
|
$run = $this->operationRuns->updateRun(
|
|
run: $run,
|
|
status: OperationRunStatus::Running->value,
|
|
summaryCounts: $this->emptySummaryCounts(),
|
|
);
|
|
|
|
$invocationContext = new ExchangePowerShellInvocationContext(
|
|
operationRunId: (int) $run->getKey(),
|
|
workspaceId: (int) $tenant->workspace_id,
|
|
managedEnvironmentId: (int) $tenant->getKey(),
|
|
providerConnectionId: (int) $providerConnection->getKey(),
|
|
canonicalType: $canonicalType,
|
|
commandName: $resolvedCommandName,
|
|
runnerMode: $runnerMode,
|
|
redactionPolicy: self::REDACTION_POLICY,
|
|
sourceContractState: CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE,
|
|
credentialSource: $credentialSource,
|
|
);
|
|
|
|
$verifiedContract = ExchangePowerShellCommandContract::fromVerifiedArray($contract, $this->commandContracts, $parameters);
|
|
$result = $this->runner->run($verifiedContract, $invocationContext);
|
|
|
|
return $this->finishRunWithResult($run, $verifiedContract, $result);
|
|
} catch (Throwable $exception) {
|
|
$run = $this->mergeContext($run, [
|
|
'runner_exception' => [
|
|
'class' => class_basename($exception),
|
|
'raw_message_persisted' => false,
|
|
],
|
|
]);
|
|
|
|
return $this->failRun(
|
|
run: $run,
|
|
reasonCode: ProviderReasonCodes::UnknownError,
|
|
failureCode: 'execution_failed_unexpected_exception',
|
|
message: 'Exchange PowerShell invocation failed safely before output promotion.',
|
|
);
|
|
}
|
|
}
|
|
|
|
private function registryFailure(): ?string
|
|
{
|
|
$definition = $this->providerOperationRegistry->get(self::OPERATION_TYPE);
|
|
$providerCapabilityKeys = $definition['provider_capability_keys'] ?? [];
|
|
|
|
if (($definition['required_capability'] ?? null) !== Capabilities::PROVIDER_RUN) {
|
|
return 'Exchange PowerShell provider operation is not registered with provider-run capability.';
|
|
}
|
|
|
|
if ($this->capabilityResolver->requiredExecutionCapabilityForType(self::OPERATION_TYPE) !== Capabilities::PROVIDER_RUN) {
|
|
return 'Exchange PowerShell operation run execution capability is not provider-run.';
|
|
}
|
|
|
|
if (! is_array($providerCapabilityKeys) || ! in_array('exchange_powershell_invoke', $providerCapabilityKeys, true)) {
|
|
return 'Exchange PowerShell provider capability is not registered for the operation.';
|
|
}
|
|
|
|
foreach ($providerCapabilityKeys as $providerCapabilityKey) {
|
|
$this->providerCapabilityRegistry->get((string) $providerCapabilityKey);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function sourceContractDecision(string $canonicalType): ?CoverageSourceContractDecision
|
|
{
|
|
$resourceType = $this->resourceTypeRegistry->findActive($canonicalType);
|
|
|
|
if (! $resourceType instanceof TenantConfigurationResourceType) {
|
|
return null;
|
|
}
|
|
|
|
return $this->sourceContractResolver->resolve($resourceType);
|
|
}
|
|
|
|
private function sourceContractAllowsInvocation(?CoverageSourceContractDecision $decision): bool
|
|
{
|
|
if (! $decision instanceof CoverageSourceContractDecision) {
|
|
return false;
|
|
}
|
|
|
|
$metadata = $decision->sourceMetadata;
|
|
|
|
return ($decision->sourceContractState ?? data_get($metadata, 'source_contract_state')) === CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE
|
|
&& data_get($metadata, 'provider_adapter_state') === 'adapter_contract_available'
|
|
&& data_get($metadata, 'provider_calls_allowed') === false
|
|
&& data_get($metadata, 'execution_enabled') === false;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function sourceContractContext(?CoverageSourceContractDecision $decision): array
|
|
{
|
|
if (! $decision instanceof CoverageSourceContractDecision) {
|
|
return [
|
|
'source_contract_state' => 'missing',
|
|
];
|
|
}
|
|
|
|
return array_filter([
|
|
'canonical_type' => $decision->canonicalType,
|
|
'source_contract_key' => $decision->contractKey,
|
|
'source_contract_state' => $decision->sourceContractState ?? data_get($decision->sourceMetadata, 'source_contract_state'),
|
|
'provider_adapter_state' => data_get($decision->sourceMetadata, 'provider_adapter_state'),
|
|
'provider_calls_allowed' => data_get($decision->sourceMetadata, 'provider_calls_allowed'),
|
|
'execution_enabled' => data_get($decision->sourceMetadata, 'execution_enabled'),
|
|
'fake_runner_testable' => data_get($decision->sourceMetadata, 'fake_runner_testable'),
|
|
'source_version' => $decision->sourceVersion,
|
|
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
|
}
|
|
|
|
private function finishRunWithResult(
|
|
OperationRun $run,
|
|
ExchangePowerShellCommandContract $contract,
|
|
ExchangePowerShellInvocationResult $result,
|
|
): OperationRun {
|
|
$safeRunnerResultContext = $this->safeRunnerResultContext($result);
|
|
|
|
if ($safeRunnerResultContext !== []) {
|
|
$run = $this->mergeContext($run, ['runner_result' => $safeRunnerResultContext]);
|
|
}
|
|
|
|
if (! $result->successful) {
|
|
if ($result->blocked) {
|
|
return $this->blockRun(
|
|
run: $run,
|
|
reasonCode: $result->reasonCode ?? ProviderReasonCodes::UnknownError,
|
|
failureCode: $result->failureCode ?? 'execution_blocked',
|
|
message: $result->message ?? 'Exchange PowerShell invocation was blocked.',
|
|
);
|
|
}
|
|
|
|
return $this->failRun(
|
|
run: $run,
|
|
reasonCode: $result->reasonCode ?? ProviderReasonCodes::UnknownError,
|
|
failureCode: $result->failureCode ?? 'execution_failed',
|
|
message: $result->message ?? 'Exchange PowerShell invocation failed.',
|
|
summaryCounts: $result->summaryCounts,
|
|
);
|
|
}
|
|
|
|
if (! $this->collectionShapeIsSafe($result->collection, $contract->toArray())) {
|
|
return $this->failRun(
|
|
run: $run,
|
|
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
|
|
failureCode: 'execution_failed_shape_unsafe',
|
|
message: 'Exchange PowerShell runner returned an unsafe response shape.',
|
|
context: ['response_shape' => ['safe' => false]],
|
|
);
|
|
}
|
|
|
|
return $this->operationRuns->updateRun(
|
|
run: $run,
|
|
status: OperationRunStatus::Completed->value,
|
|
outcome: OperationRunOutcome::Succeeded->value,
|
|
summaryCounts: $this->summaryCountsForCollection($result->collection),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $contract
|
|
*/
|
|
private function collectionShapeIsSafe(mixed $collection, array $contract): bool
|
|
{
|
|
if (! is_array($collection) || ! array_is_list($collection)) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($collection as $item) {
|
|
if (! is_array($item) || ! $this->itemHasIdentityCandidate($item, $contract)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $item
|
|
* @param array<string, mixed> $contract
|
|
*/
|
|
private function itemHasIdentityCandidate(array $item, array $contract): bool
|
|
{
|
|
$fields = [
|
|
...$this->stringList(data_get($contract, 'response_shape.required_identity_candidate_fields')),
|
|
...$this->stringList(data_get($contract, 'response_shape.derived_identity_candidate_fields')),
|
|
];
|
|
|
|
foreach ($fields as $field) {
|
|
if (! array_key_exists($field, $item)) {
|
|
continue;
|
|
}
|
|
|
|
$value = $item[$field];
|
|
|
|
if (is_scalar($value) && trim((string) $value) !== '') {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function stringList(mixed $values): array
|
|
{
|
|
if (! is_array($values)) {
|
|
return [];
|
|
}
|
|
|
|
return array_values(array_filter(
|
|
array_map(static fn (mixed $value): string => is_string($value) ? trim($value) : '', $values),
|
|
static fn (string $value): bool => $value !== '',
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
private function blockRun(
|
|
OperationRun $run,
|
|
string $reasonCode,
|
|
string $failureCode,
|
|
string $message,
|
|
array $context = [],
|
|
): OperationRun {
|
|
$run = $this->mergeContext($run, [
|
|
'failure_code' => $failureCode,
|
|
...$context,
|
|
]);
|
|
|
|
return $this->operationRuns->finalizeBlockedRun(
|
|
run: $run,
|
|
reasonCode: $reasonCode,
|
|
message: $message,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $summaryCounts
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
private function failRun(
|
|
OperationRun $run,
|
|
string $reasonCode,
|
|
string $failureCode,
|
|
string $message,
|
|
array $summaryCounts = [],
|
|
array $context = [],
|
|
): OperationRun {
|
|
$run = $this->mergeContext($run, [
|
|
'failure_code' => $failureCode,
|
|
...$context,
|
|
]);
|
|
|
|
return $this->operationRuns->updateRun(
|
|
run: $run,
|
|
status: OperationRunStatus::Completed->value,
|
|
outcome: OperationRunOutcome::Failed->value,
|
|
summaryCounts: $summaryCounts !== [] ? $summaryCounts : $this->failureSummaryCounts(),
|
|
failures: [
|
|
[
|
|
'code' => $failureCode,
|
|
'reason_code' => $reasonCode,
|
|
'message' => $message,
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
private function mergeContext(OperationRun $run, array $context): OperationRun
|
|
{
|
|
if ($context === []) {
|
|
return $run;
|
|
}
|
|
|
|
$existing = is_array($run->context) ? $run->context : [];
|
|
$run->forceFill([
|
|
'context' => array_replace_recursive($existing, $context),
|
|
])->save();
|
|
$run->refresh();
|
|
|
|
return $run;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, int>
|
|
*/
|
|
private function emptySummaryCounts(): array
|
|
{
|
|
return [
|
|
'total' => 0,
|
|
'processed' => 0,
|
|
'succeeded' => 0,
|
|
'failed' => 0,
|
|
'skipped' => 0,
|
|
'items' => 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $collection
|
|
* @return array<string, int>
|
|
*/
|
|
private function summaryCountsForCollection(array $collection): array
|
|
{
|
|
$count = count($collection);
|
|
|
|
return [
|
|
'total' => $count,
|
|
'processed' => $count,
|
|
'succeeded' => $count,
|
|
'failed' => 0,
|
|
'skipped' => 0,
|
|
'items' => $count,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, int>
|
|
*/
|
|
private function failureSummaryCounts(): array
|
|
{
|
|
return [
|
|
'total' => 1,
|
|
'processed' => 1,
|
|
'succeeded' => 0,
|
|
'failed' => 1,
|
|
'skipped' => 0,
|
|
'items' => 0,
|
|
];
|
|
}
|
|
|
|
private function normalizeRunnerMode(string $runnerMode): string
|
|
{
|
|
$runnerMode = trim($runnerMode);
|
|
|
|
return $runnerMode !== '' ? $runnerMode : self::RUNNER_MODE_DISABLED;
|
|
}
|
|
|
|
private function knownRunnerMode(string $runnerMode): bool
|
|
{
|
|
return in_array($runnerMode, [self::RUNNER_MODE_FAKE, self::RUNNER_MODE_DISABLED], true);
|
|
}
|
|
|
|
private function safeCommandName(string $canonicalType, ?string $commandName): ?string
|
|
{
|
|
$contract = $this->commandContracts->contractForCanonicalType($canonicalType);
|
|
|
|
if (! is_array($contract)) {
|
|
return null;
|
|
}
|
|
|
|
if ($commandName === null) {
|
|
return (string) $contract['command_name'];
|
|
}
|
|
|
|
$commandName = trim($commandName);
|
|
$validation = $this->commandContracts->validateCommandName($commandName);
|
|
|
|
return $validation['accepted'] ? $commandName : null;
|
|
}
|
|
|
|
private function safeTargetResourceType(string $canonicalType): string
|
|
{
|
|
$canonicalType = trim($canonicalType);
|
|
|
|
if (preg_match('/(secret|token|password|credential)/i', $canonicalType) === 1) {
|
|
return 'invalid';
|
|
}
|
|
|
|
if (preg_match('/\A[A-Za-z][A-Za-z0-9_.-]{0,79}\z/', $canonicalType) === 1) {
|
|
return $canonicalType;
|
|
}
|
|
|
|
return 'invalid';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $validation
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function safeCommandValidationContext(array $validation): array
|
|
{
|
|
return array_filter([
|
|
'accepted' => (bool) ($validation['accepted'] ?? false),
|
|
'reason_code' => is_string($validation['reason_code'] ?? null) ? $validation['reason_code'] : null,
|
|
'rejected_parameter_present' => (bool) ($validation['rejected_parameter_present'] ?? false) ?: null,
|
|
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function safeRunnerResultContext(ExchangePowerShellInvocationResult $result): array
|
|
{
|
|
$context = [];
|
|
|
|
foreach (['runner_mode', 'execution_enabled', 'failure_mode', 'retryable'] as $key) {
|
|
$value = $result->context[$key] ?? null;
|
|
|
|
if (is_scalar($value) || $value === null) {
|
|
$context[$key] = $value;
|
|
}
|
|
}
|
|
|
|
return array_filter([
|
|
'blocked' => $result->blocked,
|
|
'reason_code' => $result->reasonCode,
|
|
'failure_code' => $result->failureCode,
|
|
'message_persisted' => $result->message !== null,
|
|
...$context,
|
|
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
|
}
|
|
}
|