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 $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 $parameters * @return array */ 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 $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 */ 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 $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 $item * @param array $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 */ 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 $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 $summaryCounts * @param array $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 $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 */ private function emptySummaryCounts(): array { return [ 'total' => 0, 'processed' => 0, 'succeeded' => 0, 'failed' => 0, 'skipped' => 0, 'items' => 0, ]; } /** * @param list> $collection * @return array */ private function summaryCountsForCollection(array $collection): array { $count = count($collection); return [ 'total' => $count, 'processed' => $count, 'succeeded' => $count, 'failed' => 0, 'skipped' => 0, 'items' => $count, ]; } /** * @return array */ 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 $validation * @return array */ 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 */ 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 !== ''); } }