TenantAtlas/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellInvocationGate.php
ahmido 9374260ae1 feat: add Exchange PowerShell invocation gate (#498)
## 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
2026-07-07 15:23:29 +00:00

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 !== '');
}
}