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
This commit is contained in:
parent
9b58a5696d
commit
9374260ae1
@ -5,10 +5,10 @@
|
|||||||
use App\Models\BackupSchedule;
|
use App\Models\BackupSchedule;
|
||||||
use App\Models\EntraGroup;
|
use App\Models\EntraGroup;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ProviderCredential;
|
use App\Models\ProviderCredential;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\ManagedEnvironment;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserTenantPreference;
|
use App\Models\UserTenantPreference;
|
||||||
use App\Observers\ProviderCredentialObserver;
|
use App\Observers\ProviderCredentialObserver;
|
||||||
@ -48,6 +48,8 @@
|
|||||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||||
use App\Services\Providers\ProviderConnectionResolver;
|
use App\Services\Providers\ProviderConnectionResolver;
|
||||||
use App\Services\Providers\ProviderGateway;
|
use App\Services\Providers\ProviderGateway;
|
||||||
|
use App\Services\TenantConfiguration\DisabledExchangePowerShellCommandRunner;
|
||||||
|
use App\Services\TenantConfiguration\ExchangePowerShellCommandRunner;
|
||||||
use App\Support\References\Contracts\ReferenceResolver;
|
use App\Support\References\Contracts\ReferenceResolver;
|
||||||
use App\Support\References\ReferenceResolverRegistry;
|
use App\Support\References\ReferenceResolverRegistry;
|
||||||
use App\Support\References\ReferenceStatePresenter;
|
use App\Support\References\ReferenceStatePresenter;
|
||||||
@ -65,8 +67,8 @@
|
|||||||
use App\Support\References\Resolvers\PrincipalReferenceResolver;
|
use App\Support\References\Resolvers\PrincipalReferenceResolver;
|
||||||
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||||
use Filament\Events\TenantSet;
|
use Filament\Events\TenantSet;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
@ -86,6 +88,7 @@ public function register(): void
|
|||||||
$this->app->scoped(WorkspaceCapabilityResolver::class);
|
$this->app->scoped(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
$this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class);
|
$this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class);
|
||||||
|
$this->app->bind(ExchangePowerShellCommandRunner::class, DisabledExchangePowerShellCommandRunner::class);
|
||||||
|
|
||||||
$this->app->bind(
|
$this->app->bind(
|
||||||
\App\Contracts\Hardening\WriteGateInterface::class,
|
\App\Contracts\Hardening\WriteGateInterface::class,
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Services\Providers;
|
namespace App\Services\Providers;
|
||||||
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\ManagedEnvironment;
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||||
@ -45,8 +45,12 @@ public function resolveDefault(ManagedEnvironment $tenant, string $provider): Pr
|
|||||||
return $this->validateConnection($tenant, $provider, $connection);
|
return $this->validateConnection($tenant, $provider, $connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function validateConnection(ManagedEnvironment $tenant, string $provider, ProviderConnection $connection): ProviderConnectionResolution
|
public function validateConnection(
|
||||||
{
|
ManagedEnvironment $tenant,
|
||||||
|
string $provider,
|
||||||
|
ProviderConnection $connection,
|
||||||
|
bool $requireIdentity = true,
|
||||||
|
): ProviderConnectionResolution {
|
||||||
if ((int) $connection->managed_environment_id !== (int) $tenant->getKey() || (string) $connection->provider !== $provider) {
|
if ((int) $connection->managed_environment_id !== (int) $tenant->getKey() || (string) $connection->provider !== $provider) {
|
||||||
return ProviderConnectionResolution::blocked(
|
return ProviderConnectionResolution::blocked(
|
||||||
ProviderReasonCodes::ProviderConnectionInvalid,
|
ProviderReasonCodes::ProviderConnectionInvalid,
|
||||||
@ -88,6 +92,10 @@ public function validateConnection(ManagedEnvironment $tenant, string $provider,
|
|||||||
return $consentBlocker;
|
return $consentBlocker;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $requireIdentity) {
|
||||||
|
return ProviderConnectionResolution::resolved($connection);
|
||||||
|
}
|
||||||
|
|
||||||
$identity = $this->identityResolver->resolve($connection);
|
$identity = $this->identityResolver->resolve($connection);
|
||||||
|
|
||||||
if (! $identity->resolved) {
|
if (! $identity->resolved) {
|
||||||
|
|||||||
@ -59,6 +59,13 @@ public function definitions(): array
|
|||||||
'required_capability' => Capabilities::TENANT_MANAGE,
|
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||||
'provider_capability_keys' => ['directory_role_definitions_read'],
|
'provider_capability_keys' => ['directory_role_definitions_read'],
|
||||||
],
|
],
|
||||||
|
'tenant_configuration.exchange_powershell_invocation' => [
|
||||||
|
'operation_type' => 'tenant_configuration.exchange_powershell_invocation',
|
||||||
|
'module' => 'exchange_powershell',
|
||||||
|
'label' => 'Exchange PowerShell invocation',
|
||||||
|
'required_capability' => Capabilities::PROVIDER_RUN,
|
||||||
|
'provider_capability_keys' => ['exchange_powershell_invoke'],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,6 +125,13 @@ public function providerBindings(): array
|
|||||||
exceptionNotes: 'Directory role definitions are Microsoft-owned provider semantics.',
|
exceptionNotes: 'Directory role definitions are Microsoft-owned provider semantics.',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
'tenant_configuration.exchange_powershell_invocation' => [
|
||||||
|
'microsoft' => $this->activeMicrosoftBinding(
|
||||||
|
operationType: 'tenant_configuration.exchange_powershell_invocation',
|
||||||
|
handlerNotes: 'Uses the Spec 431 Exchange PowerShell invocation gate with fake-runner-only execution and no evidence promotion.',
|
||||||
|
exceptionNotes: 'Production Exchange PowerShell execution is disabled until a later source-capture implementation validates credentials, permissions, and output promotion.',
|
||||||
|
),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,38 +3,14 @@
|
|||||||
namespace App\Services\Providers;
|
namespace App\Services\Providers;
|
||||||
|
|
||||||
use App\Models\ManagedEnvironment;
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Operations\ExecutionAuthorityMode;
|
|
||||||
use App\Support\Operations\OperationRunCapabilityResolver;
|
|
||||||
use App\Support\Providers\Capabilities\ProviderCapabilityEvaluator;
|
|
||||||
use App\Support\Providers\Capabilities\ProviderCapabilityResult;
|
|
||||||
use App\Support\Providers\ProviderNextStepsRegistry;
|
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
|
||||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
|
||||||
use App\Support\Providers\TargetScope\ProviderIdentityContextMetadata;
|
|
||||||
use App\Support\Verification\BlockedVerificationReportFactory;
|
|
||||||
use App\Support\Verification\StaleQueuedVerificationReportFactory;
|
|
||||||
use App\Support\Verification\VerificationReportWriter;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
use ReflectionFunction;
|
|
||||||
use ReflectionMethod;
|
|
||||||
|
|
||||||
final class ProviderOperationStartGate
|
final class ProviderOperationStartGate
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly OperationRunService $runs,
|
private readonly ProviderOperationTrustedStarter $trustedStarter,
|
||||||
private readonly ProviderOperationRegistry $registry,
|
|
||||||
private readonly ProviderConnectionResolver $resolver,
|
|
||||||
private readonly ProviderNextStepsRegistry $nextStepsRegistry,
|
|
||||||
private readonly OperationRunCapabilityResolver $capabilityResolver,
|
|
||||||
private readonly ProviderConnectionTargetScopeNormalizer $targetScopeNormalizer,
|
|
||||||
private readonly ProviderCapabilityEvaluator $providerCapabilityEvaluator,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -48,446 +24,30 @@ public function start(
|
|||||||
?User $initiator = null,
|
?User $initiator = null,
|
||||||
array $extraContext = [],
|
array $extraContext = [],
|
||||||
): ProviderOperationStartResult {
|
): ProviderOperationStartResult {
|
||||||
$definition = $this->registry->get($operationType);
|
if ($this->isExchangePowerShellInvocationOperation($operationType)) {
|
||||||
$binding = $this->resolveProviderBinding($operationType, $connection);
|
return $this->trustedStarter->block(
|
||||||
|
|
||||||
if (($binding['binding_status'] ?? null) !== ProviderOperationRegistry::BINDING_ACTIVE) {
|
|
||||||
return $this->startBlocked(
|
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
operationType: $operationType,
|
|
||||||
provider: (string) ($binding['provider'] ?? 'unknown'),
|
|
||||||
module: (string) $definition['module'],
|
|
||||||
reasonCode: ProviderReasonCodes::ProviderBindingUnsupported,
|
|
||||||
extensionReasonCode: 'ext.provider_binding_missing',
|
|
||||||
reasonMessage: 'No explicit provider binding supports this operation/provider combination.',
|
|
||||||
connection: $connection,
|
connection: $connection,
|
||||||
initiator: $initiator,
|
|
||||||
extraContext: array_merge($extraContext, [
|
|
||||||
'provider_binding' => $this->bindingContext($binding),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolution = $connection instanceof ProviderConnection
|
|
||||||
? $this->resolver->validateConnection($tenant, (string) $binding['provider'], $connection)
|
|
||||||
: $this->resolver->resolveDefault($tenant, (string) $binding['provider']);
|
|
||||||
|
|
||||||
if (! $resolution->resolved || ! $resolution->connection instanceof ProviderConnection) {
|
|
||||||
return $this->startBlocked(
|
|
||||||
tenant: $tenant,
|
|
||||||
operationType: $operationType,
|
operationType: $operationType,
|
||||||
provider: (string) $binding['provider'],
|
reasonCode: ProviderReasonCodes::ProviderBindingUnsupported,
|
||||||
module: (string) $definition['module'],
|
extensionReasonCode: 'ext.exchange_powershell_invocation_gate_required',
|
||||||
reasonCode: $resolution->effectiveReasonCode(),
|
reasonMessage: 'Exchange PowerShell invocation must start through the invocation gate.',
|
||||||
extensionReasonCode: $resolution->extensionReasonCode,
|
|
||||||
reasonMessage: $resolution->message,
|
|
||||||
connection: $resolution->connection ?? $connection,
|
|
||||||
initiator: $initiator,
|
initiator: $initiator,
|
||||||
extraContext: array_merge($extraContext, [
|
|
||||||
'provider_binding' => $this->bindingContext($binding),
|
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return DB::transaction(function () use ($tenant, $operationType, $dispatcher, $initiator, $extraContext, $definition, $binding, $resolution): ProviderOperationStartResult {
|
return $this->trustedStarter->start(
|
||||||
$connection = $resolution->connection;
|
|
||||||
$resolutionIdentity = $this->resolutionOperationIdentity($extraContext);
|
|
||||||
|
|
||||||
if (! $connection instanceof ProviderConnection) {
|
|
||||||
throw new InvalidArgumentException('Resolved provider connection is missing.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$lockedConnection = ProviderConnection::query()
|
|
||||||
->whereKey($connection->getKey())
|
|
||||||
->lockForUpdate()
|
|
||||||
->firstOrFail();
|
|
||||||
|
|
||||||
$providerConnectionId = (int) $lockedConnection->getKey();
|
|
||||||
$activeRuns = OperationRun::query()
|
|
||||||
->where('managed_environment_id', $tenant->getKey())
|
|
||||||
->active()
|
|
||||||
->orderByDesc('id')
|
|
||||||
->lockForUpdate()
|
|
||||||
->get()
|
|
||||||
->filter(static fn (OperationRun $run): bool => (int) data_get($run->context, 'provider_connection_id') === $providerConnectionId)
|
|
||||||
->values();
|
|
||||||
|
|
||||||
foreach ($activeRuns as $index => $activeRun) {
|
|
||||||
if ($this->runs->isStaleQueuedRun($activeRun)) {
|
|
||||||
$this->runs->failStaleQueuedRun($activeRun);
|
|
||||||
|
|
||||||
if ($activeRun->type === 'provider.connection.check') {
|
|
||||||
VerificationReportWriter::write(
|
|
||||||
run: $activeRun,
|
|
||||||
checks: StaleQueuedVerificationReportFactory::checks($activeRun),
|
|
||||||
identity: StaleQueuedVerificationReportFactory::identity($activeRun),
|
|
||||||
);
|
|
||||||
|
|
||||||
$activeRun->refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
$activeRuns->forget($index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$activeRuns = $activeRuns->values();
|
|
||||||
|
|
||||||
$mismatchedIdentityRun = $activeRuns->first(
|
|
||||||
fn (OperationRun $run): bool => ! $this->operationHasSameResolutionIdentity($run, $resolutionIdentity),
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($mismatchedIdentityRun instanceof OperationRun) {
|
|
||||||
return ProviderOperationStartResult::scopeBusyWithoutDisclosure($mismatchedIdentityRun);
|
|
||||||
}
|
|
||||||
|
|
||||||
$blockingRun = $activeRuns->first(static fn (OperationRun $run): bool => (string) $run->type !== $operationType);
|
|
||||||
|
|
||||||
if ($blockingRun instanceof OperationRun) {
|
|
||||||
return ProviderOperationStartResult::scopeBusy($blockingRun);
|
|
||||||
}
|
|
||||||
|
|
||||||
$dedupeRun = $activeRuns->first(
|
|
||||||
fn (OperationRun $run): bool => (string) $run->type === $operationType
|
|
||||||
&& $this->operationHasSameResolutionIdentity($run, $resolutionIdentity),
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($dedupeRun instanceof OperationRun) {
|
|
||||||
return ProviderOperationStartResult::deduped($dedupeRun);
|
|
||||||
}
|
|
||||||
|
|
||||||
$providerCapabilityResults = $this->providerCapabilityEvaluator->evaluateForOperation(
|
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
connection: $lockedConnection,
|
connection: $connection,
|
||||||
operationType: $operationType,
|
operationType: $operationType,
|
||||||
);
|
dispatcher: $dispatcher,
|
||||||
$blockingCapability = $this->providerCapabilityEvaluator->primaryBlockingResult($providerCapabilityResults);
|
|
||||||
|
|
||||||
if ($blockingCapability instanceof ProviderCapabilityResult) {
|
|
||||||
return $this->startBlocked(
|
|
||||||
tenant: $tenant,
|
|
||||||
operationType: $operationType,
|
|
||||||
provider: (string) $binding['provider'],
|
|
||||||
module: (string) $definition['module'],
|
|
||||||
reasonCode: $blockingCapability->reasonCode ?? ProviderReasonCodes::ProviderPermissionMissing,
|
|
||||||
reasonMessage: $blockingCapability->primaryMessage,
|
|
||||||
connection: $lockedConnection,
|
|
||||||
initiator: $initiator,
|
initiator: $initiator,
|
||||||
extraContext: array_merge($extraContext, [
|
extraContext: $extraContext,
|
||||||
'provider_binding' => $this->bindingContext($binding),
|
|
||||||
'provider_capabilities' => $this->providerCapabilityContext($providerCapabilityResults),
|
|
||||||
'provider_capability' => $blockingCapability->toArray(),
|
|
||||||
'provider_capability_status' => $blockingCapability->status->value,
|
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$context = array_merge($extraContext, [
|
private function isExchangePowerShellInvocationOperation(string $operationType): bool
|
||||||
'execution_authority_mode' => is_string($extraContext['execution_authority_mode'] ?? null)
|
|
||||||
? $extraContext['execution_authority_mode']
|
|
||||||
: ($initiator instanceof User ? ExecutionAuthorityMode::ActorBound->value : ExecutionAuthorityMode::SystemAuthority->value),
|
|
||||||
'required_capability' => $this->resolveRequiredCapability($operationType, $extraContext),
|
|
||||||
'provider' => $lockedConnection->provider,
|
|
||||||
'module' => $definition['module'],
|
|
||||||
'provider_binding' => $this->bindingContext($binding),
|
|
||||||
'required_provider_capabilities' => $this->registry->providerCapabilityKeysForOperation($operationType),
|
|
||||||
'provider_capabilities' => $this->providerCapabilityContext($providerCapabilityResults),
|
|
||||||
'provider_connection_id' => (int) $lockedConnection->getKey(),
|
|
||||||
'connection_type' => $lockedConnection->connection_type?->value ?? $lockedConnection->connection_type,
|
|
||||||
'target_scope' => $this->targetScopeContextForConnection($lockedConnection),
|
|
||||||
'provider_context' => $this->providerContextForConnection($lockedConnection),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$run = $this->runs->ensureRunWithIdentity(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: $operationType,
|
|
||||||
identityInputs: array_replace([
|
|
||||||
'provider_connection_id' => (int) $lockedConnection->getKey(),
|
|
||||||
], $resolutionIdentity),
|
|
||||||
context: $context,
|
|
||||||
initiator: $initiator,
|
|
||||||
);
|
|
||||||
|
|
||||||
$dispatched = false;
|
|
||||||
|
|
||||||
if ($run->wasRecentlyCreated) {
|
|
||||||
$this->invokeDispatcher($dispatcher, $run);
|
|
||||||
$dispatched = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ProviderOperationStartResult::started($run, $dispatched);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $extraContext
|
|
||||||
*/
|
|
||||||
private function startBlocked(
|
|
||||||
ManagedEnvironment $tenant,
|
|
||||||
string $operationType,
|
|
||||||
string $provider,
|
|
||||||
string $module,
|
|
||||||
string $reasonCode,
|
|
||||||
?string $extensionReasonCode = null,
|
|
||||||
?string $reasonMessage = null,
|
|
||||||
?ProviderConnection $connection = null,
|
|
||||||
?User $initiator = null,
|
|
||||||
array $extraContext = [],
|
|
||||||
): ProviderOperationStartResult {
|
|
||||||
$context = array_merge($extraContext, [
|
|
||||||
'execution_authority_mode' => is_string($extraContext['execution_authority_mode'] ?? null)
|
|
||||||
? $extraContext['execution_authority_mode']
|
|
||||||
: ($initiator instanceof User ? ExecutionAuthorityMode::ActorBound->value : ExecutionAuthorityMode::SystemAuthority->value),
|
|
||||||
'required_capability' => $this->resolveRequiredCapability($operationType, $extraContext),
|
|
||||||
'provider' => $provider,
|
|
||||||
'module' => $module,
|
|
||||||
'required_provider_capabilities' => $this->registry->providerCapabilityKeysForOperation($operationType),
|
|
||||||
'target_scope' => $connection instanceof ProviderConnection
|
|
||||||
? $this->targetScopeContextForConnection($connection)
|
|
||||||
: $this->targetScopeContextForTenant($tenant, $provider),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$identityInputs = array_replace([
|
|
||||||
'provider' => $provider,
|
|
||||||
'reason_code' => $reasonCode,
|
|
||||||
], $this->resolutionOperationIdentity($extraContext));
|
|
||||||
|
|
||||||
if (is_string($extensionReasonCode) && $extensionReasonCode !== '') {
|
|
||||||
$context['reason_code_extension'] = $extensionReasonCode;
|
|
||||||
$identityInputs['reason_code_extension'] = $extensionReasonCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($connection instanceof ProviderConnection) {
|
|
||||||
$context['provider_connection_id'] = (int) $connection->getKey();
|
|
||||||
$context['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type;
|
|
||||||
$context['provider_context'] = $this->providerContextForConnection($connection);
|
|
||||||
$identityInputs['provider_connection_id'] = (int) $connection->getKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
$run = $this->runs->ensureRunWithIdentity(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: $operationType,
|
|
||||||
identityInputs: $identityInputs,
|
|
||||||
context: $context,
|
|
||||||
initiator: $initiator,
|
|
||||||
);
|
|
||||||
|
|
||||||
$run = $this->runs->finalizeBlockedRun(
|
|
||||||
$run,
|
|
||||||
reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
|
|
||||||
nextSteps: $this->nextStepsRegistry->forReason($tenant, $reasonCode, $connection),
|
|
||||||
message: $reasonMessage,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($operationType === 'provider.connection.check') {
|
|
||||||
VerificationReportWriter::write(
|
|
||||||
run: $run,
|
|
||||||
checks: BlockedVerificationReportFactory::checks($run),
|
|
||||||
identity: BlockedVerificationReportFactory::identity($run),
|
|
||||||
);
|
|
||||||
|
|
||||||
$run->refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
return ProviderOperationStartResult::blocked($run);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $extraContext
|
|
||||||
* @return array<string, int|string>
|
|
||||||
*/
|
|
||||||
private function resolutionOperationIdentity(array $extraContext): array
|
|
||||||
{
|
{
|
||||||
$identity = [];
|
return $operationType === 'tenant_configuration.exchange_powershell_invocation';
|
||||||
|
|
||||||
foreach (['review_publication_resolution_case_id', 'environment_review_id'] as $key) {
|
|
||||||
$value = data_get($extraContext, $key);
|
|
||||||
|
|
||||||
if (is_numeric($value)) {
|
|
||||||
$identity[$key] = (int) $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$trigger = data_get($extraContext, 'trigger');
|
|
||||||
|
|
||||||
if ($identity !== [] && is_string($trigger) && $trigger !== '') {
|
|
||||||
$identity['trigger'] = $trigger;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $identity;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, int|string> $identity
|
|
||||||
*/
|
|
||||||
private function operationHasSameResolutionIdentity(OperationRun $run, array $identity): bool
|
|
||||||
{
|
|
||||||
return $this->operationResolutionIdentity($run) === $identity;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, int|string>
|
|
||||||
*/
|
|
||||||
private function operationResolutionIdentity(OperationRun $run): array
|
|
||||||
{
|
|
||||||
return $this->resolutionOperationIdentity(is_array($run->context) ? $run->context : []);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, ProviderCapabilityResult> $results
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private function providerCapabilityContext(array $results): array
|
|
||||||
{
|
|
||||||
return array_map(
|
|
||||||
static fn (ProviderCapabilityResult $result): array => $result->toArray(),
|
|
||||||
$results,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function invokeDispatcher(callable $dispatcher, OperationRun $run): void
|
|
||||||
{
|
|
||||||
$ref = null;
|
|
||||||
|
|
||||||
if (is_array($dispatcher) && count($dispatcher) === 2) {
|
|
||||||
$ref = new ReflectionMethod($dispatcher[0], (string) $dispatcher[1]);
|
|
||||||
} elseif (is_string($dispatcher) && str_contains($dispatcher, '::')) {
|
|
||||||
[$class, $method] = explode('::', $dispatcher, 2);
|
|
||||||
$ref = new ReflectionMethod($class, $method);
|
|
||||||
} elseif ($dispatcher instanceof \Closure) {
|
|
||||||
$ref = new ReflectionFunction($dispatcher);
|
|
||||||
} elseif (is_object($dispatcher) && method_exists($dispatcher, '__invoke')) {
|
|
||||||
$ref = new ReflectionMethod($dispatcher, '__invoke');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($ref && $ref->getNumberOfParameters() >= 1) {
|
|
||||||
$dispatcher($run);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$dispatcher();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{operation_type: string, provider: string, binding_status: string, handler_notes: string, exception_notes: string}
|
|
||||||
*/
|
|
||||||
private function resolveProviderBinding(string $operationType, ?ProviderConnection $connection): array
|
|
||||||
{
|
|
||||||
if ($connection instanceof ProviderConnection) {
|
|
||||||
$provider = trim((string) $connection->provider);
|
|
||||||
|
|
||||||
return $this->registry->bindingFor($operationType, $provider)
|
|
||||||
?? $this->registry->unsupportedBinding($operationType, $provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->registry->activeBindingFor($operationType)
|
|
||||||
?? $this->registry->unsupportedBinding($operationType, 'unknown');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{operation_type: string, provider: string, binding_status: string, handler_notes: string, exception_notes: string} $binding
|
|
||||||
* @return array{provider: string, binding_status: string, handler_notes: string, exception_notes: string}
|
|
||||||
*/
|
|
||||||
private function bindingContext(array $binding): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'provider' => (string) $binding['provider'],
|
|
||||||
'binding_status' => (string) $binding['binding_status'],
|
|
||||||
'handler_notes' => (string) $binding['handler_notes'],
|
|
||||||
'exception_notes' => (string) $binding['exception_notes'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* provider: string,
|
|
||||||
* scope_kind: string,
|
|
||||||
* scope_identifier: string,
|
|
||||||
* scope_display_name: string,
|
|
||||||
* shared_label: string,
|
|
||||||
* shared_help_text: string
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
private function targetScopeContextForConnection(ProviderConnection $connection): array
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
return $this->targetScopeNormalizer->descriptorForConnection($connection)->toArray();
|
|
||||||
} catch (InvalidArgumentException) {
|
|
||||||
$identifier = $connection->tenant instanceof ManagedEnvironment
|
|
||||||
? trim($connection->tenant->providerTenantContext())
|
|
||||||
: '';
|
|
||||||
|
|
||||||
if ($identifier === '') {
|
|
||||||
$identifier = trim((string) $connection->entra_tenant_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ProviderConnectionTargetScopeDescriptor::fromInput(
|
|
||||||
provider: (string) $connection->provider,
|
|
||||||
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
|
||||||
scopeIdentifier: $identifier !== '' ? $identifier : (string) $connection->getKey(),
|
|
||||||
scopeDisplayName: (string) ($connection->tenant?->name ?? $connection->display_name ?? $identifier),
|
|
||||||
)->toArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* provider: string,
|
|
||||||
* scope_kind: string,
|
|
||||||
* scope_identifier: string,
|
|
||||||
* scope_display_name: string,
|
|
||||||
* shared_label: string,
|
|
||||||
* shared_help_text: string
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
private function targetScopeContextForTenant(ManagedEnvironment $tenant, string $provider): array
|
|
||||||
{
|
|
||||||
$identifier = trim($tenant->providerTenantContext());
|
|
||||||
|
|
||||||
return ProviderConnectionTargetScopeDescriptor::fromInput(
|
|
||||||
provider: $provider !== '' ? $provider : 'unknown',
|
|
||||||
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
|
||||||
scopeIdentifier: $identifier,
|
|
||||||
scopeDisplayName: (string) ($tenant->name ?? $identifier),
|
|
||||||
)->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{provider: string, details: list<array<string, string>>}
|
|
||||||
*/
|
|
||||||
private function providerContextForConnection(ProviderConnection $connection): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'provider' => (string) $connection->provider,
|
|
||||||
'details' => array_map(
|
|
||||||
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
|
|
||||||
$this->targetScopeNormalizer->contextualIdentityDetailsForConnection($connection),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $extraContext
|
|
||||||
*/
|
|
||||||
private function resolveRequiredCapability(string $operationType, array $extraContext): ?string
|
|
||||||
{
|
|
||||||
if (is_string($extraContext['required_capability'] ?? null) && trim((string) $extraContext['required_capability']) !== '') {
|
|
||||||
return trim((string) $extraContext['required_capability']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->registry->isAllowed($operationType)) {
|
|
||||||
$definition = $this->registry->get($operationType);
|
|
||||||
$requiredCapability = $definition['required_capability'] ?? null;
|
|
||||||
|
|
||||||
if (is_string($requiredCapability) && trim($requiredCapability) !== '') {
|
|
||||||
return trim($requiredCapability);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->registry->isAllowed($operationType)) {
|
|
||||||
return Capabilities::PROVIDER_RUN;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->capabilityResolver->requiredExecutionCapabilityForType($operationType);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,553 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Providers;
|
||||||
|
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Operations\ExecutionAuthorityMode;
|
||||||
|
use App\Support\Operations\OperationRunCapabilityResolver;
|
||||||
|
use App\Support\Providers\Capabilities\ProviderCapabilityEvaluator;
|
||||||
|
use App\Support\Providers\Capabilities\ProviderCapabilityResult;
|
||||||
|
use App\Support\Providers\ProviderNextStepsRegistry;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderIdentityContextMetadata;
|
||||||
|
use App\Support\Verification\BlockedVerificationReportFactory;
|
||||||
|
use App\Support\Verification\StaleQueuedVerificationReportFactory;
|
||||||
|
use App\Support\Verification\VerificationReportWriter;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use ReflectionFunction;
|
||||||
|
use ReflectionMethod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal Internal operation gates use this to reuse provider binding, scope, capability,
|
||||||
|
* stale-run, and OperationRun lifecycle checks while ProviderOperationStartGate::start()
|
||||||
|
* remains the public start boundary.
|
||||||
|
*/
|
||||||
|
final class ProviderOperationTrustedStarter
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly OperationRunService $runs,
|
||||||
|
private readonly ProviderOperationRegistry $registry,
|
||||||
|
private readonly ProviderConnectionResolver $resolver,
|
||||||
|
private readonly ProviderNextStepsRegistry $nextStepsRegistry,
|
||||||
|
private readonly OperationRunCapabilityResolver $capabilityResolver,
|
||||||
|
private readonly ProviderConnectionTargetScopeNormalizer $targetScopeNormalizer,
|
||||||
|
private readonly ProviderCapabilityEvaluator $providerCapabilityEvaluator,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $extraContext
|
||||||
|
*/
|
||||||
|
public function start(
|
||||||
|
ManagedEnvironment $tenant,
|
||||||
|
?ProviderConnection $connection,
|
||||||
|
string $operationType,
|
||||||
|
callable $dispatcher,
|
||||||
|
?User $initiator = null,
|
||||||
|
array $extraContext = [],
|
||||||
|
): ProviderOperationStartResult {
|
||||||
|
$definition = $this->registry->get($operationType);
|
||||||
|
$binding = $this->resolveProviderBinding($operationType, $connection);
|
||||||
|
|
||||||
|
if (($binding['binding_status'] ?? null) !== ProviderOperationRegistry::BINDING_ACTIVE) {
|
||||||
|
return $this->startBlocked(
|
||||||
|
tenant: $tenant,
|
||||||
|
operationType: $operationType,
|
||||||
|
provider: (string) ($binding['provider'] ?? 'unknown'),
|
||||||
|
module: (string) $definition['module'],
|
||||||
|
reasonCode: ProviderReasonCodes::ProviderBindingUnsupported,
|
||||||
|
extensionReasonCode: 'ext.provider_binding_missing',
|
||||||
|
reasonMessage: 'No explicit provider binding supports this operation/provider combination.',
|
||||||
|
connection: $connection,
|
||||||
|
initiator: $initiator,
|
||||||
|
extraContext: array_merge($extraContext, [
|
||||||
|
'provider_binding' => $this->bindingContext($binding),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolution = $connection instanceof ProviderConnection
|
||||||
|
? $this->resolver->validateConnection(
|
||||||
|
tenant: $tenant,
|
||||||
|
provider: (string) $binding['provider'],
|
||||||
|
connection: $connection,
|
||||||
|
requireIdentity: $this->requiresProviderIdentity($operationType, $extraContext),
|
||||||
|
)
|
||||||
|
: $this->resolver->resolveDefault($tenant, (string) $binding['provider']);
|
||||||
|
|
||||||
|
if (! $resolution->resolved || ! $resolution->connection instanceof ProviderConnection) {
|
||||||
|
return $this->startBlocked(
|
||||||
|
tenant: $tenant,
|
||||||
|
operationType: $operationType,
|
||||||
|
provider: (string) $binding['provider'],
|
||||||
|
module: (string) $definition['module'],
|
||||||
|
reasonCode: $resolution->effectiveReasonCode(),
|
||||||
|
extensionReasonCode: $resolution->extensionReasonCode,
|
||||||
|
reasonMessage: $resolution->message,
|
||||||
|
connection: $resolution->connection ?? $connection,
|
||||||
|
initiator: $initiator,
|
||||||
|
extraContext: array_merge($extraContext, [
|
||||||
|
'provider_binding' => $this->bindingContext($binding),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($tenant, $operationType, $dispatcher, $initiator, $extraContext, $definition, $binding, $resolution): ProviderOperationStartResult {
|
||||||
|
$connection = $resolution->connection;
|
||||||
|
$resolutionIdentity = $this->resolutionOperationIdentity($extraContext);
|
||||||
|
|
||||||
|
if (! $connection instanceof ProviderConnection) {
|
||||||
|
throw new InvalidArgumentException('Resolved provider connection is missing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$lockedConnection = ProviderConnection::query()
|
||||||
|
->whereKey($connection->getKey())
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$providerConnectionId = (int) $lockedConnection->getKey();
|
||||||
|
$activeRuns = OperationRun::query()
|
||||||
|
->where('managed_environment_id', $tenant->getKey())
|
||||||
|
->active()
|
||||||
|
->orderByDesc('id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get()
|
||||||
|
->filter(static fn (OperationRun $run): bool => (int) data_get($run->context, 'provider_connection_id') === $providerConnectionId)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
foreach ($activeRuns as $index => $activeRun) {
|
||||||
|
if ($this->runs->isStaleQueuedRun($activeRun)) {
|
||||||
|
$this->runs->failStaleQueuedRun($activeRun);
|
||||||
|
|
||||||
|
if ($activeRun->type === 'provider.connection.check') {
|
||||||
|
VerificationReportWriter::write(
|
||||||
|
run: $activeRun,
|
||||||
|
checks: StaleQueuedVerificationReportFactory::checks($activeRun),
|
||||||
|
identity: StaleQueuedVerificationReportFactory::identity($activeRun),
|
||||||
|
);
|
||||||
|
|
||||||
|
$activeRun->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
$activeRuns->forget($index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$activeRuns = $activeRuns->values();
|
||||||
|
|
||||||
|
$mismatchedIdentityRun = $activeRuns->first(
|
||||||
|
fn (OperationRun $run): bool => ! $this->operationHasSameResolutionIdentity($run, $resolutionIdentity),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($mismatchedIdentityRun instanceof OperationRun) {
|
||||||
|
return ProviderOperationStartResult::scopeBusyWithoutDisclosure($mismatchedIdentityRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
$blockingRun = $activeRuns->first(static fn (OperationRun $run): bool => (string) $run->type !== $operationType);
|
||||||
|
|
||||||
|
if ($blockingRun instanceof OperationRun) {
|
||||||
|
return ProviderOperationStartResult::scopeBusy($blockingRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dedupeRun = $activeRuns->first(
|
||||||
|
fn (OperationRun $run): bool => (string) $run->type === $operationType
|
||||||
|
&& $this->operationHasSameResolutionIdentity($run, $resolutionIdentity),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($dedupeRun instanceof OperationRun) {
|
||||||
|
return ProviderOperationStartResult::deduped($dedupeRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
$providerCapabilityResults = $this->providerCapabilityEvaluator->evaluateForOperation(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $lockedConnection,
|
||||||
|
operationType: $operationType,
|
||||||
|
);
|
||||||
|
$blockingCapability = $this->providerCapabilityEvaluator->primaryBlockingResult($providerCapabilityResults);
|
||||||
|
|
||||||
|
if ($blockingCapability instanceof ProviderCapabilityResult) {
|
||||||
|
return $this->startBlocked(
|
||||||
|
tenant: $tenant,
|
||||||
|
operationType: $operationType,
|
||||||
|
provider: (string) $binding['provider'],
|
||||||
|
module: (string) $definition['module'],
|
||||||
|
reasonCode: $blockingCapability->reasonCode ?? ProviderReasonCodes::ProviderPermissionMissing,
|
||||||
|
reasonMessage: $blockingCapability->primaryMessage,
|
||||||
|
connection: $lockedConnection,
|
||||||
|
initiator: $initiator,
|
||||||
|
extraContext: array_merge($extraContext, [
|
||||||
|
'provider_binding' => $this->bindingContext($binding),
|
||||||
|
'provider_capabilities' => $this->providerCapabilityContext($providerCapabilityResults),
|
||||||
|
'provider_capability' => $blockingCapability->toArray(),
|
||||||
|
'provider_capability_status' => $blockingCapability->status->value,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = array_merge($extraContext, [
|
||||||
|
'execution_authority_mode' => is_string($extraContext['execution_authority_mode'] ?? null)
|
||||||
|
? $extraContext['execution_authority_mode']
|
||||||
|
: ($initiator instanceof User ? ExecutionAuthorityMode::ActorBound->value : ExecutionAuthorityMode::SystemAuthority->value),
|
||||||
|
'required_capability' => $this->resolveRequiredCapability($operationType, $extraContext),
|
||||||
|
'provider' => $lockedConnection->provider,
|
||||||
|
'module' => $definition['module'],
|
||||||
|
'provider_binding' => $this->bindingContext($binding),
|
||||||
|
'required_provider_capabilities' => $this->registry->providerCapabilityKeysForOperation($operationType),
|
||||||
|
'provider_capabilities' => $this->providerCapabilityContext($providerCapabilityResults),
|
||||||
|
'provider_connection_id' => (int) $lockedConnection->getKey(),
|
||||||
|
'connection_type' => $lockedConnection->connection_type?->value ?? $lockedConnection->connection_type,
|
||||||
|
'target_scope' => $this->targetScopeContextForConnection($lockedConnection),
|
||||||
|
'provider_context' => $this->providerContextForConnection($lockedConnection),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = $this->runs->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: $operationType,
|
||||||
|
identityInputs: array_replace([
|
||||||
|
'provider_connection_id' => (int) $lockedConnection->getKey(),
|
||||||
|
], $resolutionIdentity),
|
||||||
|
context: $context,
|
||||||
|
initiator: $initiator,
|
||||||
|
);
|
||||||
|
|
||||||
|
$dispatched = false;
|
||||||
|
|
||||||
|
if ($run->wasRecentlyCreated) {
|
||||||
|
$this->invokeDispatcher($dispatcher, $run);
|
||||||
|
$dispatched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProviderOperationStartResult::started($run, $dispatched);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $extraContext
|
||||||
|
*/
|
||||||
|
public function block(
|
||||||
|
ManagedEnvironment $tenant,
|
||||||
|
?ProviderConnection $connection,
|
||||||
|
string $operationType,
|
||||||
|
string $reasonCode,
|
||||||
|
?string $extensionReasonCode = null,
|
||||||
|
?string $reasonMessage = null,
|
||||||
|
?User $initiator = null,
|
||||||
|
array $extraContext = [],
|
||||||
|
): ProviderOperationStartResult {
|
||||||
|
$definition = $this->registry->get($operationType);
|
||||||
|
$binding = $this->resolveProviderBinding($operationType, $connection);
|
||||||
|
|
||||||
|
return $this->startBlocked(
|
||||||
|
tenant: $tenant,
|
||||||
|
operationType: $operationType,
|
||||||
|
provider: (string) ($binding['provider'] ?? 'unknown'),
|
||||||
|
module: (string) $definition['module'],
|
||||||
|
reasonCode: $reasonCode,
|
||||||
|
extensionReasonCode: $extensionReasonCode,
|
||||||
|
reasonMessage: $reasonMessage,
|
||||||
|
connection: $connection,
|
||||||
|
initiator: $initiator,
|
||||||
|
extraContext: array_merge($extraContext, [
|
||||||
|
'provider_binding' => $this->bindingContext($binding),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $extraContext
|
||||||
|
*/
|
||||||
|
private function startBlocked(
|
||||||
|
ManagedEnvironment $tenant,
|
||||||
|
string $operationType,
|
||||||
|
string $provider,
|
||||||
|
string $module,
|
||||||
|
string $reasonCode,
|
||||||
|
?string $extensionReasonCode = null,
|
||||||
|
?string $reasonMessage = null,
|
||||||
|
?ProviderConnection $connection = null,
|
||||||
|
?User $initiator = null,
|
||||||
|
array $extraContext = [],
|
||||||
|
): ProviderOperationStartResult {
|
||||||
|
$context = array_merge($extraContext, [
|
||||||
|
'execution_authority_mode' => is_string($extraContext['execution_authority_mode'] ?? null)
|
||||||
|
? $extraContext['execution_authority_mode']
|
||||||
|
: ($initiator instanceof User ? ExecutionAuthorityMode::ActorBound->value : ExecutionAuthorityMode::SystemAuthority->value),
|
||||||
|
'required_capability' => $this->resolveRequiredCapability($operationType, $extraContext),
|
||||||
|
'provider' => $provider,
|
||||||
|
'module' => $module,
|
||||||
|
'required_provider_capabilities' => $this->registry->providerCapabilityKeysForOperation($operationType),
|
||||||
|
'target_scope' => $connection instanceof ProviderConnection
|
||||||
|
? $this->targetScopeContextForConnection($connection)
|
||||||
|
: $this->targetScopeContextForTenant($tenant, $provider),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$identityInputs = array_replace([
|
||||||
|
'provider' => $provider,
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
], $this->resolutionOperationIdentity($extraContext));
|
||||||
|
|
||||||
|
if (is_string($extensionReasonCode) && $extensionReasonCode !== '') {
|
||||||
|
$context['reason_code_extension'] = $extensionReasonCode;
|
||||||
|
$identityInputs['reason_code_extension'] = $extensionReasonCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($connection instanceof ProviderConnection) {
|
||||||
|
$context['provider_connection_id'] = (int) $connection->getKey();
|
||||||
|
$context['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type;
|
||||||
|
$context['provider_context'] = $this->providerContextForConnection($connection);
|
||||||
|
$identityInputs['provider_connection_id'] = (int) $connection->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = $this->runs->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: $operationType,
|
||||||
|
identityInputs: $identityInputs,
|
||||||
|
context: $context,
|
||||||
|
initiator: $initiator,
|
||||||
|
);
|
||||||
|
|
||||||
|
$run = $this->runs->finalizeBlockedRun(
|
||||||
|
$run,
|
||||||
|
reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
|
||||||
|
nextSteps: $this->nextStepsRegistry->forReason($tenant, $reasonCode, $connection),
|
||||||
|
message: $reasonMessage,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($operationType === 'provider.connection.check') {
|
||||||
|
VerificationReportWriter::write(
|
||||||
|
run: $run,
|
||||||
|
checks: BlockedVerificationReportFactory::checks($run),
|
||||||
|
identity: BlockedVerificationReportFactory::identity($run),
|
||||||
|
);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProviderOperationStartResult::blocked($run);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $extraContext
|
||||||
|
* @return array<string, int|string>
|
||||||
|
*/
|
||||||
|
private function resolutionOperationIdentity(array $extraContext): array
|
||||||
|
{
|
||||||
|
$identity = [];
|
||||||
|
|
||||||
|
foreach (['review_publication_resolution_case_id', 'environment_review_id'] as $key) {
|
||||||
|
$value = data_get($extraContext, $key);
|
||||||
|
|
||||||
|
if (is_numeric($value)) {
|
||||||
|
$identity[$key] = (int) $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$trigger = data_get($extraContext, 'trigger');
|
||||||
|
|
||||||
|
if ($identity !== [] && is_string($trigger) && $trigger !== '') {
|
||||||
|
$identity['trigger'] = $trigger;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int|string> $identity
|
||||||
|
*/
|
||||||
|
private function operationHasSameResolutionIdentity(OperationRun $run, array $identity): bool
|
||||||
|
{
|
||||||
|
return $this->operationResolutionIdentity($run) === $identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int|string>
|
||||||
|
*/
|
||||||
|
private function operationResolutionIdentity(OperationRun $run): array
|
||||||
|
{
|
||||||
|
return $this->resolutionOperationIdentity(is_array($run->context) ? $run->context : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, ProviderCapabilityResult> $results
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function providerCapabilityContext(array $results): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (ProviderCapabilityResult $result): array => $result->toArray(),
|
||||||
|
$results,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function invokeDispatcher(callable $dispatcher, OperationRun $run): void
|
||||||
|
{
|
||||||
|
$ref = null;
|
||||||
|
|
||||||
|
if (is_array($dispatcher) && count($dispatcher) === 2) {
|
||||||
|
$ref = new ReflectionMethod($dispatcher[0], (string) $dispatcher[1]);
|
||||||
|
} elseif (is_string($dispatcher) && str_contains($dispatcher, '::')) {
|
||||||
|
[$class, $method] = explode('::', $dispatcher, 2);
|
||||||
|
$ref = new ReflectionMethod($class, $method);
|
||||||
|
} elseif ($dispatcher instanceof \Closure) {
|
||||||
|
$ref = new ReflectionFunction($dispatcher);
|
||||||
|
} elseif (is_object($dispatcher) && method_exists($dispatcher, '__invoke')) {
|
||||||
|
$ref = new ReflectionMethod($dispatcher, '__invoke');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ref && $ref->getNumberOfParameters() >= 1) {
|
||||||
|
$dispatcher($run);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dispatcher();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{operation_type: string, provider: string, binding_status: string, handler_notes: string, exception_notes: string}
|
||||||
|
*/
|
||||||
|
private function resolveProviderBinding(string $operationType, ?ProviderConnection $connection): array
|
||||||
|
{
|
||||||
|
if ($connection instanceof ProviderConnection) {
|
||||||
|
$provider = trim((string) $connection->provider);
|
||||||
|
|
||||||
|
return $this->registry->bindingFor($operationType, $provider)
|
||||||
|
?? $this->registry->unsupportedBinding($operationType, $provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->registry->activeBindingFor($operationType)
|
||||||
|
?? $this->registry->unsupportedBinding($operationType, 'unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $extraContext
|
||||||
|
*/
|
||||||
|
private function requiresProviderIdentity(string $operationType, array $extraContext): bool
|
||||||
|
{
|
||||||
|
return ! (
|
||||||
|
$this->isExchangePowerShellInvocationOperation($operationType)
|
||||||
|
&& ($extraContext['provider_identity_resolution_mode'] ?? null) === 'fake_runner_bypass'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isExchangePowerShellInvocationOperation(string $operationType): bool
|
||||||
|
{
|
||||||
|
return $operationType === 'tenant_configuration.exchange_powershell_invocation';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{operation_type: string, provider: string, binding_status: string, handler_notes: string, exception_notes: string} $binding
|
||||||
|
* @return array{provider: string, binding_status: string, handler_notes: string, exception_notes: string}
|
||||||
|
*/
|
||||||
|
private function bindingContext(array $binding): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'provider' => (string) $binding['provider'],
|
||||||
|
'binding_status' => (string) $binding['binding_status'],
|
||||||
|
'handler_notes' => (string) $binding['handler_notes'],
|
||||||
|
'exception_notes' => (string) $binding['exception_notes'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* provider: string,
|
||||||
|
* scope_kind: string,
|
||||||
|
* scope_identifier: string,
|
||||||
|
* scope_display_name: string,
|
||||||
|
* shared_label: string,
|
||||||
|
* shared_help_text: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function targetScopeContextForConnection(ProviderConnection $connection): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->targetScopeNormalizer->descriptorForConnection($connection)->toArray();
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
$identifier = $connection->tenant instanceof ManagedEnvironment
|
||||||
|
? trim($connection->tenant->providerTenantContext())
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if ($identifier === '') {
|
||||||
|
$identifier = trim((string) $connection->entra_tenant_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProviderConnectionTargetScopeDescriptor::fromInput(
|
||||||
|
provider: (string) $connection->provider,
|
||||||
|
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||||
|
scopeIdentifier: $identifier !== '' ? $identifier : (string) $connection->getKey(),
|
||||||
|
scopeDisplayName: (string) ($connection->tenant?->name ?? $connection->display_name ?? $identifier),
|
||||||
|
)->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* provider: string,
|
||||||
|
* scope_kind: string,
|
||||||
|
* scope_identifier: string,
|
||||||
|
* scope_display_name: string,
|
||||||
|
* shared_label: string,
|
||||||
|
* shared_help_text: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function targetScopeContextForTenant(ManagedEnvironment $tenant, string $provider): array
|
||||||
|
{
|
||||||
|
$identifier = trim($tenant->providerTenantContext());
|
||||||
|
|
||||||
|
return ProviderConnectionTargetScopeDescriptor::fromInput(
|
||||||
|
provider: $provider !== '' ? $provider : 'unknown',
|
||||||
|
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||||
|
scopeIdentifier: $identifier,
|
||||||
|
scopeDisplayName: (string) ($tenant->name ?? $identifier),
|
||||||
|
)->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{provider: string, details: list<array<string, string>>}
|
||||||
|
*/
|
||||||
|
private function providerContextForConnection(ProviderConnection $connection): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'provider' => (string) $connection->provider,
|
||||||
|
'details' => array_map(
|
||||||
|
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
|
||||||
|
$this->targetScopeNormalizer->contextualIdentityDetailsForConnection($connection),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $extraContext
|
||||||
|
*/
|
||||||
|
private function resolveRequiredCapability(string $operationType, array $extraContext): ?string
|
||||||
|
{
|
||||||
|
if (is_string($extraContext['required_capability'] ?? null) && trim((string) $extraContext['required_capability']) !== '') {
|
||||||
|
return trim((string) $extraContext['required_capability']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->registry->isAllowed($operationType)) {
|
||||||
|
$definition = $this->registry->get($operationType);
|
||||||
|
$requiredCapability = $definition['required_capability'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($requiredCapability) && trim((string) $requiredCapability) !== '') {
|
||||||
|
return trim($requiredCapability);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->registry->isAllowed($operationType)) {
|
||||||
|
return Capabilities::PROVIDER_RUN;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->capabilityResolver->requiredExecutionCapabilityForType($operationType);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\TenantConfiguration;
|
||||||
|
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
|
||||||
|
final class DisabledExchangePowerShellCommandRunner implements ExchangePowerShellCommandRunner
|
||||||
|
{
|
||||||
|
public function run(
|
||||||
|
ExchangePowerShellCommandContract $commandContract,
|
||||||
|
ExchangePowerShellInvocationContext $context,
|
||||||
|
): ExchangePowerShellInvocationResult {
|
||||||
|
return ExchangePowerShellInvocationResult::blocked(
|
||||||
|
reasonCode: ProviderReasonCodes::ProviderBindingUnsupported,
|
||||||
|
failureCode: 'execution_blocked_runner_disabled',
|
||||||
|
message: 'Exchange PowerShell production invocation is disabled for this release.',
|
||||||
|
context: [
|
||||||
|
'runner_mode' => $context->runnerMode,
|
||||||
|
'execution_enabled' => false,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\TenantConfiguration;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final readonly class ExchangePowerShellCommandContract
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @param array<string, mixed> $parameters
|
||||||
|
*/
|
||||||
|
private function __construct(
|
||||||
|
private array $payload,
|
||||||
|
private array $parameters,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $contract
|
||||||
|
* @param array<string, mixed> $parameters
|
||||||
|
*/
|
||||||
|
public static function fromVerifiedArray(
|
||||||
|
array $contract,
|
||||||
|
ExchangePowerShellCommandContracts $contracts,
|
||||||
|
array $parameters = [],
|
||||||
|
): self {
|
||||||
|
$validation = $contracts->validateInvocation($contract, $parameters);
|
||||||
|
|
||||||
|
if (! $validation['accepted']) {
|
||||||
|
throw new InvalidArgumentException('Exchange PowerShell command contract was not verified.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$commandName = $contract['command_name'] ?? null;
|
||||||
|
$canonicalContract = is_string($commandName)
|
||||||
|
? $contracts->contractForCommandName($commandName)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (! is_array($canonicalContract)) {
|
||||||
|
throw new InvalidArgumentException('Exchange PowerShell command contract was not canonical.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self($canonicalContract, $parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return $this->payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function parameters(): array
|
||||||
|
{
|
||||||
|
return $this->parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function parameterNames(): array
|
||||||
|
{
|
||||||
|
return array_values(array_map('strval', array_keys($this->parameters)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -129,7 +129,7 @@ public function validateCommandName(mixed $commandName): array
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $parameters
|
* @param array<string, mixed> $parameters
|
||||||
* @return array{accepted: bool, reason_code: string|null, rejected_parameter?: string}
|
* @return array{accepted: bool, reason_code: string|null, rejected_parameter_present?: bool}
|
||||||
*/
|
*/
|
||||||
public function validateInvocation(mixed $contract, array $parameters = []): array
|
public function validateInvocation(mixed $contract, array $parameters = []): array
|
||||||
{
|
{
|
||||||
@ -146,14 +146,11 @@ public function validateInvocation(mixed $contract, array $parameters = []): arr
|
|||||||
|
|
||||||
$allowlistedContract = $this->contractForCommandName((string) $commandName);
|
$allowlistedContract = $this->contractForCommandName((string) $commandName);
|
||||||
|
|
||||||
if ($allowlistedContract === null
|
if ($allowlistedContract === null || ! $this->contractsAreEquivalent($contract, $allowlistedContract)) {
|
||||||
|| ($contract['contract_key'] ?? null) !== $allowlistedContract['contract_key']
|
|
||||||
|| ($contract['read_only'] ?? null) !== true
|
|
||||||
) {
|
|
||||||
return $this->rejected('invalid_command_contract');
|
return $this->rejected('invalid_command_contract');
|
||||||
}
|
}
|
||||||
|
|
||||||
$allowedParameters = $contract['allowed_parameters'] ?? [];
|
$allowedParameters = $allowlistedContract['allowed_parameters'] ?? [];
|
||||||
|
|
||||||
if (! is_array($allowedParameters) || ! array_is_list($allowedParameters)) {
|
if (! is_array($allowedParameters) || ! array_is_list($allowedParameters)) {
|
||||||
return $this->rejected('invalid_allowed_parameter_policy');
|
return $this->rejected('invalid_allowed_parameter_policy');
|
||||||
@ -163,7 +160,7 @@ public function validateInvocation(mixed $contract, array $parameters = []): arr
|
|||||||
if (! is_string($parameterName) || ! in_array($parameterName, $allowedParameters, true)) {
|
if (! is_string($parameterName) || ! in_array($parameterName, $allowedParameters, true)) {
|
||||||
return [
|
return [
|
||||||
...$this->rejected('unknown_parameter_rejected'),
|
...$this->rejected('unknown_parameter_rejected'),
|
||||||
'rejected_parameter' => (string) $parameterName,
|
'rejected_parameter_present' => true,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -172,7 +169,7 @@ public function validateInvocation(mixed $contract, array $parameters = []): arr
|
|||||||
if ($this->containsUnsafeParameterValue($parameterValue)) {
|
if ($this->containsUnsafeParameterValue($parameterValue)) {
|
||||||
return [
|
return [
|
||||||
...$this->rejected('unsafe_parameter_value_rejected'),
|
...$this->rejected('unsafe_parameter_value_rejected'),
|
||||||
'rejected_parameter' => (string) $parameterName,
|
'rejected_parameter_present' => true,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -180,6 +177,39 @@ public function validateInvocation(mixed $contract, array $parameters = []): arr
|
|||||||
return $this->accepted();
|
return $this->accepted();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function contractsAreEquivalent(mixed $candidate, mixed $canonical): bool
|
||||||
|
{
|
||||||
|
if (! is_array($candidate) || ! is_array($canonical)) {
|
||||||
|
return $candidate === $canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_is_list($candidate) || array_is_list($canonical)) {
|
||||||
|
if (! array_is_list($candidate) || ! array_is_list($canonical) || count($candidate) !== count($canonical)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($canonical as $index => $canonicalValue) {
|
||||||
|
if (! array_key_exists($index, $candidate) || ! $this->contractsAreEquivalent($candidate[$index], $canonicalValue)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($candidate) !== count($canonical)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($canonical as $key => $canonicalValue) {
|
||||||
|
if (! array_key_exists($key, $candidate) || ! $this->contractsAreEquivalent($candidate[$key], $canonicalValue)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, array<string, mixed>>
|
* @return array<string, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\TenantConfiguration;
|
||||||
|
|
||||||
|
interface ExchangePowerShellCommandRunner
|
||||||
|
{
|
||||||
|
public function run(
|
||||||
|
ExchangePowerShellCommandContract $commandContract,
|
||||||
|
ExchangePowerShellInvocationContext $context,
|
||||||
|
): ExchangePowerShellInvocationResult;
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\TenantConfiguration;
|
||||||
|
|
||||||
|
final readonly class ExchangePowerShellInvocationContext
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $operationRunId,
|
||||||
|
public int $workspaceId,
|
||||||
|
public int $managedEnvironmentId,
|
||||||
|
public int $providerConnectionId,
|
||||||
|
public string $canonicalType,
|
||||||
|
public string $commandName,
|
||||||
|
public string $runnerMode,
|
||||||
|
public string $redactionPolicy,
|
||||||
|
public string $sourceContractState,
|
||||||
|
public ?string $credentialSource = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return array_filter([
|
||||||
|
'operation_run_id' => $this->operationRunId,
|
||||||
|
'workspace_id' => $this->workspaceId,
|
||||||
|
'managed_environment_id' => $this->managedEnvironmentId,
|
||||||
|
'provider_connection_id' => $this->providerConnectionId,
|
||||||
|
'canonical_type' => $this->canonicalType,
|
||||||
|
'command_name' => $this->commandName,
|
||||||
|
'runner_mode' => $this->runnerMode,
|
||||||
|
'redaction_policy' => $this->redactionPolicy,
|
||||||
|
'source_contract_state' => $this->sourceContractState,
|
||||||
|
'credential_source' => $this->credentialSource,
|
||||||
|
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,731 @@
|
|||||||
|
<?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 !== '');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\TenantConfiguration;
|
||||||
|
|
||||||
|
final readonly class ExchangePowerShellInvocationResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $summaryCounts
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
private function __construct(
|
||||||
|
public bool $successful,
|
||||||
|
public mixed $collection,
|
||||||
|
public ?string $reasonCode = null,
|
||||||
|
public ?string $failureCode = null,
|
||||||
|
public ?string $message = null,
|
||||||
|
public bool $blocked = false,
|
||||||
|
public array $summaryCounts = [],
|
||||||
|
public array $context = [],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $items
|
||||||
|
* @param array<string, mixed> $summaryCounts
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public static function succeeded(array $items, array $summaryCounts = [], array $context = []): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
successful: true,
|
||||||
|
collection: $items,
|
||||||
|
summaryCounts: $summaryCounts,
|
||||||
|
context: $context,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public static function succeededPayload(mixed $collection, array $context = []): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
successful: true,
|
||||||
|
collection: $collection,
|
||||||
|
context: $context,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $summaryCounts
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public static function failed(
|
||||||
|
string $reasonCode,
|
||||||
|
string $failureCode,
|
||||||
|
?string $message = null,
|
||||||
|
array $summaryCounts = [],
|
||||||
|
array $context = [],
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
successful: false,
|
||||||
|
collection: [],
|
||||||
|
reasonCode: $reasonCode,
|
||||||
|
failureCode: $failureCode,
|
||||||
|
message: $message,
|
||||||
|
summaryCounts: $summaryCounts,
|
||||||
|
context: $context,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public static function blocked(string $reasonCode, string $failureCode, ?string $message = null, array $context = []): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
successful: false,
|
||||||
|
collection: [],
|
||||||
|
reasonCode: $reasonCode,
|
||||||
|
failureCode: $failureCode,
|
||||||
|
message: $message,
|
||||||
|
blocked: true,
|
||||||
|
context: $context,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\TenantConfiguration;
|
||||||
|
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class FakeExchangePowerShellCommandRunner implements ExchangePowerShellCommandRunner
|
||||||
|
{
|
||||||
|
public const string SCENARIO_SUCCESS = 'success';
|
||||||
|
|
||||||
|
public const string SCENARIO_EMPTY_SUCCESS = 'empty_success';
|
||||||
|
|
||||||
|
public const string SCENARIO_AUTH_FAILURE = 'auth_failure';
|
||||||
|
|
||||||
|
public const string SCENARIO_AUTHORIZATION_FAILURE = 'authorization_failure';
|
||||||
|
|
||||||
|
public const string SCENARIO_TIMEOUT = 'timeout';
|
||||||
|
|
||||||
|
public const string SCENARIO_THROTTLED = 'throttled';
|
||||||
|
|
||||||
|
public const string SCENARIO_MODULE_UNAVAILABLE = 'module_unavailable';
|
||||||
|
|
||||||
|
public const string SCENARIO_COMMAND_UNAVAILABLE = 'command_unavailable';
|
||||||
|
|
||||||
|
public const string SCENARIO_MALFORMED_SCALAR = 'malformed_scalar';
|
||||||
|
|
||||||
|
public const string SCENARIO_MALFORMED_TEXT = 'malformed_text';
|
||||||
|
|
||||||
|
public const string SCENARIO_UNEXPECTED_EXCEPTION = 'unexpected_exception';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public array $calls = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>>|null $items
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $scenario = self::SCENARIO_SUCCESS,
|
||||||
|
private readonly ?array $items = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(
|
||||||
|
ExchangePowerShellCommandContract $commandContract,
|
||||||
|
ExchangePowerShellInvocationContext $context,
|
||||||
|
): ExchangePowerShellInvocationResult {
|
||||||
|
$this->calls[] = [
|
||||||
|
'operation_run_id' => $context->operationRunId,
|
||||||
|
'canonical_type' => $context->canonicalType,
|
||||||
|
'command_name' => $context->commandName,
|
||||||
|
'runner_mode' => $context->runnerMode,
|
||||||
|
'parameter_names' => $commandContract->parameterNames(),
|
||||||
|
'context' => $context->toArray(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return match ($this->scenario) {
|
||||||
|
self::SCENARIO_EMPTY_SUCCESS => ExchangePowerShellInvocationResult::succeeded([]),
|
||||||
|
self::SCENARIO_AUTH_FAILURE => ExchangePowerShellInvocationResult::failed(
|
||||||
|
reasonCode: ProviderReasonCodes::ProviderAuthFailed,
|
||||||
|
failureCode: 'execution_failed_authentication',
|
||||||
|
message: 'Exchange PowerShell authentication failed.',
|
||||||
|
),
|
||||||
|
self::SCENARIO_AUTHORIZATION_FAILURE => ExchangePowerShellInvocationResult::failed(
|
||||||
|
reasonCode: ProviderReasonCodes::ProviderPermissionDenied,
|
||||||
|
failureCode: 'execution_failed_authorization',
|
||||||
|
message: 'Exchange PowerShell authorization failed.',
|
||||||
|
),
|
||||||
|
self::SCENARIO_TIMEOUT => ExchangePowerShellInvocationResult::failed(
|
||||||
|
reasonCode: ProviderReasonCodes::NetworkUnreachable,
|
||||||
|
failureCode: 'execution_failed_timeout',
|
||||||
|
message: 'Exchange PowerShell invocation timed out.',
|
||||||
|
),
|
||||||
|
self::SCENARIO_THROTTLED => ExchangePowerShellInvocationResult::failed(
|
||||||
|
reasonCode: ProviderReasonCodes::RateLimited,
|
||||||
|
failureCode: 'execution_failed_throttled',
|
||||||
|
message: 'Exchange PowerShell invocation was rate limited.',
|
||||||
|
),
|
||||||
|
self::SCENARIO_MODULE_UNAVAILABLE => ExchangePowerShellInvocationResult::blocked(
|
||||||
|
reasonCode: ProviderReasonCodes::ProviderBindingUnsupported,
|
||||||
|
failureCode: 'execution_blocked_module_unavailable',
|
||||||
|
message: 'Exchange PowerShell module is unavailable.',
|
||||||
|
),
|
||||||
|
self::SCENARIO_COMMAND_UNAVAILABLE => ExchangePowerShellInvocationResult::blocked(
|
||||||
|
reasonCode: ProviderReasonCodes::ProviderBindingUnsupported,
|
||||||
|
failureCode: 'execution_blocked_command_unavailable',
|
||||||
|
message: 'Exchange PowerShell command is unavailable.',
|
||||||
|
),
|
||||||
|
self::SCENARIO_MALFORMED_SCALAR => ExchangePowerShellInvocationResult::succeededPayload(42),
|
||||||
|
self::SCENARIO_MALFORMED_TEXT => ExchangePowerShellInvocationResult::succeededPayload('raw transcript rejected by shape gate'),
|
||||||
|
self::SCENARIO_UNEXPECTED_EXCEPTION => throw new RuntimeException('Simulated runner exception with token and secret redaction pressure.'),
|
||||||
|
default => ExchangePowerShellInvocationResult::succeeded($this->items ?? $this->defaultItems($commandContract->toArray())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $commandContract
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function defaultItems(array $commandContract): array
|
||||||
|
{
|
||||||
|
$canonicalType = (string) ($commandContract['canonical_type'] ?? 'exchange');
|
||||||
|
$label = (string) ($commandContract['human_label'] ?? 'Exchange object');
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'id' => 'spec431-'.$canonicalType.'-1',
|
||||||
|
'sourceId' => 'spec431-'.$canonicalType.'-source-1',
|
||||||
|
'Guid' => '00000000-0000-4000-8000-000000000431',
|
||||||
|
'Name' => 'Spec 431 '.$label,
|
||||||
|
'DisplayName' => 'Spec 431 '.$label,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -281,6 +281,7 @@ private static function canonicalDefinitions(): array
|
|||||||
'tenant.evidence.snapshot.generate' => new CanonicalOperationType('tenant.evidence.snapshot.generate', 'platform_foundation', 'evidence_snapshot', 'Evidence snapshot generation', true, 120),
|
'tenant.evidence.snapshot.generate' => new CanonicalOperationType('tenant.evidence.snapshot.generate', 'platform_foundation', 'evidence_snapshot', 'Evidence snapshot generation', true, 120),
|
||||||
'rbac.health_check' => new CanonicalOperationType('rbac.health_check', 'intune', null, 'RBAC health check', false, 30),
|
'rbac.health_check' => new CanonicalOperationType('rbac.health_check', 'intune', null, 'RBAC health check', false, 30),
|
||||||
'tenant_configuration.capture' => new CanonicalOperationType('tenant_configuration.capture', 'platform_foundation', null, 'Tenant configuration capture', false, 120),
|
'tenant_configuration.capture' => new CanonicalOperationType('tenant_configuration.capture', 'platform_foundation', null, 'Tenant configuration capture', false, 120),
|
||||||
|
'tenant_configuration.exchange_powershell_invocation' => new CanonicalOperationType('tenant_configuration.exchange_powershell_invocation', 'exchange', null, 'Exchange PowerShell invocation', false, 120),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -346,6 +347,7 @@ private static function operationAliases(): array
|
|||||||
new OperationTypeAlias('tenant.evidence.snapshot.generate', 'tenant.evidence.snapshot.generate', 'canonical', true),
|
new OperationTypeAlias('tenant.evidence.snapshot.generate', 'tenant.evidence.snapshot.generate', 'canonical', true),
|
||||||
new OperationTypeAlias('rbac.health_check', 'rbac.health_check', 'canonical', true),
|
new OperationTypeAlias('rbac.health_check', 'rbac.health_check', 'canonical', true),
|
||||||
new OperationTypeAlias('tenant_configuration.capture', 'tenant_configuration.capture', 'canonical', true),
|
new OperationTypeAlias('tenant_configuration.capture', 'tenant_configuration.capture', 'canonical', true),
|
||||||
|
new OperationTypeAlias('tenant_configuration.exchange_powershell_invocation', 'tenant_configuration.exchange_powershell_invocation', 'canonical', true),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ enum OperationRunType: string
|
|||||||
case EvidenceSnapshotGenerate = 'tenant.evidence.snapshot.generate';
|
case EvidenceSnapshotGenerate = 'tenant.evidence.snapshot.generate';
|
||||||
case RbacHealthCheck = 'rbac.health_check';
|
case RbacHealthCheck = 'rbac.health_check';
|
||||||
case TenantConfigurationCapture = 'tenant_configuration.capture';
|
case TenantConfigurationCapture = 'tenant_configuration.capture';
|
||||||
|
case TenantConfigurationExchangePowerShellInvocation = 'tenant_configuration.exchange_powershell_invocation';
|
||||||
|
|
||||||
public static function values(): array
|
public static function values(): array
|
||||||
{
|
{
|
||||||
|
|||||||
@ -48,7 +48,7 @@ public function requiredExecutionCapabilityForType(string $operationType): ?stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
return match ($operationType) {
|
return match ($operationType) {
|
||||||
'provider.connection.check', 'inventory.sync', 'compliance.snapshot' => Capabilities::PROVIDER_RUN,
|
'provider.connection.check', 'inventory.sync', 'compliance.snapshot', 'tenant_configuration.exchange_powershell_invocation' => Capabilities::PROVIDER_RUN,
|
||||||
'policy.sync', 'tenant.sync' => Capabilities::TENANT_SYNC,
|
'policy.sync', 'tenant.sync' => Capabilities::TENANT_SYNC,
|
||||||
'policy.delete' => Capabilities::TENANT_MANAGE,
|
'policy.delete' => Capabilities::TENANT_MANAGE,
|
||||||
'assignments.restore', 'restore.execute', 'promotion.execute' => Capabilities::TENANT_MANAGE,
|
'assignments.restore', 'restore.execute', 'promotion.execute' => Capabilities::TENANT_MANAGE,
|
||||||
|
|||||||
@ -5,13 +5,16 @@
|
|||||||
namespace App\Support\Providers\Capabilities;
|
namespace App\Support\Providers\Capabilities;
|
||||||
|
|
||||||
use App\Models\ManagedEnvironment;
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\ManagedEnvironmentPermission;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Services\Intune\ManagedEnvironmentRequiredPermissionsViewModelBuilder;
|
use App\Services\Intune\ManagedEnvironmentRequiredPermissionsViewModelBuilder;
|
||||||
use App\Support\Links\RequiredPermissionsLinks;
|
use App\Support\Links\RequiredPermissionsLinks;
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
use App\Support\Providers\ProviderVerificationStatus;
|
||||||
|
use App\Support\Providers\Readiness\ProviderReadinessResolver;
|
||||||
use App\Support\Verification\ManagedEnvironmentPermissionCheckClusters;
|
use App\Support\Verification\ManagedEnvironmentPermissionCheckClusters;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
|
||||||
final class ProviderCapabilityEvaluator
|
final class ProviderCapabilityEvaluator
|
||||||
@ -19,6 +22,7 @@ final class ProviderCapabilityEvaluator
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ProviderCapabilityRegistry $registry,
|
private readonly ProviderCapabilityRegistry $registry,
|
||||||
private readonly ManagedEnvironmentRequiredPermissionsViewModelBuilder $requiredPermissionsViewModelBuilder,
|
private readonly ManagedEnvironmentRequiredPermissionsViewModelBuilder $requiredPermissionsViewModelBuilder,
|
||||||
|
private readonly ProviderReadinessResolver $providerReadinessResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -31,7 +35,9 @@ public function evaluate(
|
|||||||
?array $requiredPermissionsViewModel = null,
|
?array $requiredPermissionsViewModel = null,
|
||||||
): ProviderCapabilityResult {
|
): ProviderCapabilityResult {
|
||||||
$definition = $this->registry->get($capabilityKey);
|
$definition = $this->registry->get($capabilityKey);
|
||||||
$requiredPermissionsViewModel ??= $this->requiredPermissionsViewModel($tenant);
|
$requiredPermissionsViewModel ??= $connection instanceof ProviderConnection
|
||||||
|
? $this->requiredPermissionsViewModelForConnection($connection)
|
||||||
|
: $this->requiredPermissionsViewModel($tenant);
|
||||||
|
|
||||||
return $this->evaluateDefinition($tenant, $connection, $definition, $requiredPermissionsViewModel);
|
return $this->evaluateDefinition($tenant, $connection, $definition, $requiredPermissionsViewModel);
|
||||||
}
|
}
|
||||||
@ -62,7 +68,7 @@ public function evaluateForConnection(ProviderConnection $connection, ?ManagedEn
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$requiredPermissionsViewModel = $this->requiredPermissionsViewModel($tenant);
|
$requiredPermissionsViewModel = $this->requiredPermissionsViewModelForConnection($connection);
|
||||||
|
|
||||||
return array_map(
|
return array_map(
|
||||||
fn (ProviderCapabilityDefinition $definition): ProviderCapabilityResult => $this->evaluateDefinition(
|
fn (ProviderCapabilityDefinition $definition): ProviderCapabilityResult => $this->evaluateDefinition(
|
||||||
@ -84,7 +90,9 @@ public function evaluateForOperation(
|
|||||||
string $operationType,
|
string $operationType,
|
||||||
): array {
|
): array {
|
||||||
$definitions = $this->registry->forOperationType($operationType);
|
$definitions = $this->registry->forOperationType($operationType);
|
||||||
$requiredPermissionsViewModel = $this->requiredPermissionsViewModel($tenant);
|
$requiredPermissionsViewModel = $connection instanceof ProviderConnection
|
||||||
|
? $this->requiredPermissionsViewModelForConnection($connection)
|
||||||
|
: $this->requiredPermissionsViewModel($tenant);
|
||||||
|
|
||||||
return array_map(
|
return array_map(
|
||||||
fn (ProviderCapabilityDefinition $definition): ProviderCapabilityResult => $this->evaluateDefinition(
|
fn (ProviderCapabilityDefinition $definition): ProviderCapabilityResult => $this->evaluateDefinition(
|
||||||
@ -240,7 +248,7 @@ private function evaluateDefinition(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->evaluatePermissionRequirements($tenant, $definition, $requiredPermissionsViewModel, $base);
|
return $this->evaluatePermissionRequirements($tenant, $connection, $definition, $requiredPermissionsViewModel, $base);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -249,6 +257,7 @@ private function evaluateDefinition(
|
|||||||
*/
|
*/
|
||||||
private function evaluatePermissionRequirements(
|
private function evaluatePermissionRequirements(
|
||||||
ManagedEnvironment $tenant,
|
ManagedEnvironment $tenant,
|
||||||
|
ProviderConnection $connection,
|
||||||
ProviderCapabilityDefinition $definition,
|
ProviderCapabilityDefinition $definition,
|
||||||
array $requiredPermissionsViewModel,
|
array $requiredPermissionsViewModel,
|
||||||
array $base,
|
array $base,
|
||||||
@ -267,12 +276,31 @@ private function evaluatePermissionRequirements(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$requirementRows[$requirementKey] = ManagedEnvironmentPermissionCheckClusters::rowsForRequirementKey($permissions, $requirementKey);
|
$requirementRows[$requirementKey] = $this->rowsForRequirementKey(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $connection,
|
||||||
|
permissions: $permissions,
|
||||||
|
requirementKey: $requirementKey,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$allRows = array_values(array_merge(...array_values($requirementRows ?: [[]])));
|
$allRows = array_values(array_merge(...array_values($requirementRows ?: [[]])));
|
||||||
|
|
||||||
if ($allRows === []) {
|
if ($allRows === []) {
|
||||||
|
if ($this->requiresExplicitPermissionEvidence($definition)) {
|
||||||
|
return new ProviderCapabilityResult(
|
||||||
|
...$base,
|
||||||
|
status: ProviderCapabilityStatus::Unknown,
|
||||||
|
reasonCode: ProviderReasonCodes::ProviderPermissionRefreshFailed,
|
||||||
|
missingRequirementKeys: $this->providerRequirementKeysRequiringEvidence($definition),
|
||||||
|
primaryMessage: "{$definition->label} capability needs explicit provider permission evidence.",
|
||||||
|
providerHint: 'Rerun provider verification before starting this provider operation.',
|
||||||
|
evidenceCounts: ['requirements' => 0, 'missing' => 0, 'errors' => 1],
|
||||||
|
nextStepLabel: 'Open Required Permissions',
|
||||||
|
nextStepUrl: RequiredPermissionsLinks::requiredPermissions($tenant),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return new ProviderCapabilityResult(
|
return new ProviderCapabilityResult(
|
||||||
...$base,
|
...$base,
|
||||||
status: ProviderCapabilityStatus::NotApplicable,
|
status: ProviderCapabilityStatus::NotApplicable,
|
||||||
@ -387,6 +415,167 @@ private function evaluatePermissionRequirements(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $permissions
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function rowsForRequirementKey(
|
||||||
|
ManagedEnvironment $tenant,
|
||||||
|
ProviderConnection $connection,
|
||||||
|
array $permissions,
|
||||||
|
string $requirementKey,
|
||||||
|
): array {
|
||||||
|
$rows = $this->capabilityRowsForRequirementKey($permissions, $requirementKey);
|
||||||
|
|
||||||
|
if ($rows !== [] || $requirementKey !== 'provider.exchange_powershell_invocation') {
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->storedExchangePowerShellPermissionRows($tenant, $connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $permissions
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function capabilityRowsForRequirementKey(array $permissions, string $requirementKey): array
|
||||||
|
{
|
||||||
|
$definition = ManagedEnvironmentPermissionCheckClusters::definitionForKey($requirementKey);
|
||||||
|
|
||||||
|
if (! is_array($definition)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($permissions)
|
||||||
|
->filter(static fn (mixed $row): bool => is_array($row))
|
||||||
|
->map(fn (array $row): array => $this->normalizeCapabilityPermissionRow($row))
|
||||||
|
->filter(static fn (array $row): bool => ManagedEnvironmentPermissionCheckClusters::matchesDefinition($definition, $row))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function normalizeCapabilityPermissionRow(array $row): array
|
||||||
|
{
|
||||||
|
$type = $row['type'] ?? 'application';
|
||||||
|
|
||||||
|
if (! in_array($type, ['application', 'delegated'], true)) {
|
||||||
|
$type = 'application';
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = is_string($row['status'] ?? null) ? trim((string) $row['status']) : 'missing';
|
||||||
|
|
||||||
|
if (! in_array($status, ['granted', 'missing', 'blocked', 'expired', 'unknown', 'error'], true)) {
|
||||||
|
$status = 'missing';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'key' => (string) ($row['key'] ?? ''),
|
||||||
|
'type' => $type,
|
||||||
|
'description' => is_string($row['description'] ?? null) ? $row['description'] : null,
|
||||||
|
'features' => is_array($row['features'] ?? null) ? array_values($row['features']) : [],
|
||||||
|
'status' => $status,
|
||||||
|
'details' => is_array($row['details'] ?? null) ? $row['details'] : null,
|
||||||
|
'last_checked_at' => $row['last_checked_at'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function storedExchangePowerShellPermissionRows(ManagedEnvironment $tenant, ProviderConnection $connection): array
|
||||||
|
{
|
||||||
|
return ManagedEnvironmentPermission::query()
|
||||||
|
->where('managed_environment_id', (int) $tenant->getKey())
|
||||||
|
->where('workspace_id', (int) $tenant->workspace_id)
|
||||||
|
->where('permission_key', 'Exchange.ManageAsApp')
|
||||||
|
->get()
|
||||||
|
->map(function (ManagedEnvironmentPermission $permission) use ($connection): array {
|
||||||
|
$details = is_array($permission->details) ? $permission->details : null;
|
||||||
|
$status = $this->storedPermissionStatus((string) $permission->status);
|
||||||
|
|
||||||
|
if ($status === 'granted' && ! $this->storedPermissionEvidenceMatchesConnection($details, $connection)) {
|
||||||
|
$status = 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastCheckedAt = $permission->last_checked_at;
|
||||||
|
|
||||||
|
if (! $lastCheckedAt instanceof CarbonInterface || $lastCheckedAt->lt(now()->subDays(30))) {
|
||||||
|
$status = 'expired';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'key' => 'Exchange.ManageAsApp',
|
||||||
|
'type' => 'application',
|
||||||
|
'description' => null,
|
||||||
|
'features' => ['exchange-powershell-invocation'],
|
||||||
|
'status' => $status,
|
||||||
|
'details' => $details,
|
||||||
|
'last_checked_at' => $lastCheckedAt instanceof CarbonInterface ? $lastCheckedAt->toIso8601String() : null,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storedPermissionStatus(string $status): string
|
||||||
|
{
|
||||||
|
return in_array($status, ['granted', 'missing', 'blocked', 'expired', 'unknown', 'error'], true)
|
||||||
|
? $status
|
||||||
|
: 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $details
|
||||||
|
*/
|
||||||
|
private function storedPermissionEvidenceMatchesConnection(?array $details, ProviderConnection $connection): bool
|
||||||
|
{
|
||||||
|
if (! is_array($details)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$providerConnectionId = $details['provider_connection_id'] ?? null;
|
||||||
|
|
||||||
|
if (! is_numeric($providerConnectionId) || (int) $providerConnectionId !== (int) $connection->getKey()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = $details['workspace_id'] ?? null;
|
||||||
|
|
||||||
|
if (! is_numeric($workspaceId) || ! is_numeric($connection->workspace_id) || (int) $workspaceId !== (int) $connection->workspace_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$environmentId = $details['managed_environment_id'] ?? null;
|
||||||
|
|
||||||
|
if (! is_numeric($environmentId) || ! is_numeric($connection->managed_environment_id) || (int) $environmentId !== (int) $connection->managed_environment_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$provider = $details['provider'] ?? null;
|
||||||
|
|
||||||
|
return is_string($provider) && trim($provider) === trim((string) $connection->provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requiresExplicitPermissionEvidence(ProviderCapabilityDefinition $definition): bool
|
||||||
|
{
|
||||||
|
return $definition->key === 'exchange_powershell_invoke';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function providerRequirementKeysRequiringEvidence(ProviderCapabilityDefinition $definition): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(
|
||||||
|
$definition->providerRequirementKeys,
|
||||||
|
static fn (string $requirementKey): bool => $requirementKey !== 'permissions.admin_consent',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@ -400,6 +589,21 @@ private function requiredPermissionsViewModel(ManagedEnvironment $tenant): array
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function requiredPermissionsViewModelForConnection(ProviderConnection $connection): array
|
||||||
|
{
|
||||||
|
$readiness = $this->providerReadinessResolver->forConnection($connection)->toArray();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'permissions' => is_array($readiness['permission_rows'] ?? null) ? $readiness['permission_rows'] : [],
|
||||||
|
'overview' => [
|
||||||
|
'freshness' => is_array($readiness['freshness'] ?? null) ? $readiness['freshness'] : [],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, array<int, array<string, mixed>>> $requirementRows
|
* @param array<string, array<int, array<string, mixed>>> $requirementRows
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
|
|||||||
@ -57,6 +57,13 @@ public function definitions(): array
|
|||||||
providerRequirementKeys: ['provider.directory_role_definitions', 'permissions.admin_consent'],
|
providerRequirementKeys: ['provider.directory_role_definitions', 'permissions.admin_consent'],
|
||||||
defaultReasonCode: ProviderReasonCodes::ProviderPermissionMissing,
|
defaultReasonCode: ProviderReasonCodes::ProviderPermissionMissing,
|
||||||
),
|
),
|
||||||
|
'exchange_powershell_invoke' => new ProviderCapabilityDefinition(
|
||||||
|
key: 'exchange_powershell_invoke',
|
||||||
|
label: 'Exchange PowerShell invocation',
|
||||||
|
operationTypes: ['tenant_configuration.exchange_powershell_invocation'],
|
||||||
|
providerRequirementKeys: ['provider.exchange_powershell_invocation', 'permissions.admin_consent'],
|
||||||
|
defaultReasonCode: ProviderReasonCodes::ProviderPermissionMissing,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -300,7 +300,7 @@ private function permissionState(
|
|||||||
return [ProviderPermissionReadinessState::Unknown, 'provider_verification_not_run'];
|
return [ProviderPermissionReadinessState::Unknown, 'provider_verification_not_run'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($verificationStale || $storedStale) {
|
if ($verificationStale) {
|
||||||
return [ProviderPermissionReadinessState::Expired, 'provider_permission_evidence_expired'];
|
return [ProviderPermissionReadinessState::Expired, 'provider_permission_evidence_expired'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -310,6 +310,10 @@ private function permissionState(
|
|||||||
: [ProviderPermissionReadinessState::Unknown, 'provider_permission_evidence_unavailable'];
|
: [ProviderPermissionReadinessState::Unknown, 'provider_permission_evidence_unavailable'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($storedStale) {
|
||||||
|
return [ProviderPermissionReadinessState::Expired, 'provider_permission_evidence_expired'];
|
||||||
|
}
|
||||||
|
|
||||||
if ($storedStatus === 'granted') {
|
if ($storedStatus === 'granted') {
|
||||||
return [ProviderPermissionReadinessState::Granted, 'ok'];
|
return [ProviderPermissionReadinessState::Granted, 'ok'];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -595,6 +595,7 @@
|
|||||||
|
|
||||||
'features' => [
|
'features' => [
|
||||||
'conditional_access' => true,
|
'conditional_access' => true,
|
||||||
|
'exchange_powershell_invocation' => (bool) env('TENANTPILOT_EXCHANGE_POWERSHELL_INVOCATION_ENABLED', false),
|
||||||
],
|
],
|
||||||
|
|
||||||
'bulk_operations' => [
|
'bulk_operations' => [
|
||||||
|
|||||||
@ -75,6 +75,30 @@ function spec283SeedRequirementRows(ManagedEnvironment $tenant, array $requireme
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! function_exists('spec283SeedPermissionRow')) {
|
||||||
|
function spec283SeedPermissionRow(ManagedEnvironment $tenant, ProviderConnection $connection, string $permissionKey, string $status = 'granted'): void
|
||||||
|
{
|
||||||
|
ManagedEnvironmentPermission::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'permission_key' => $permissionKey,
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'status' => $status,
|
||||||
|
'details' => [
|
||||||
|
'source' => 'spec-283-test',
|
||||||
|
'workspace_id' => (int) $connection->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $connection->managed_environment_id,
|
||||||
|
'provider' => (string) $connection->provider,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
'last_checked_at' => now(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
it('evaluates supported provider capabilities from stored permission evidence', function (): void {
|
it('evaluates supported provider capabilities from stored permission evidence', function (): void {
|
||||||
$tenant = ManagedEnvironment::factory()->create([
|
$tenant = ManagedEnvironment::factory()->create([
|
||||||
'managed_environment_id' => '11111111-1111-1111-1111-111111111111',
|
'managed_environment_id' => '11111111-1111-1111-1111-111111111111',
|
||||||
@ -96,6 +120,28 @@ function spec283SeedRequirementRows(ManagedEnvironment $tenant, array $requireme
|
|||||||
->and($result->blocksExecution())->toBeFalse();
|
->and($result->blocksExecution())->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not support grouped provider capabilities from partial stored permission evidence', function (): void {
|
||||||
|
$tenant = ManagedEnvironment::factory()->create([
|
||||||
|
'managed_environment_id' => '11111111-1111-1111-1111-111111111112',
|
||||||
|
]);
|
||||||
|
$connection = ProviderConnection::factory()->withCredential()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'entra_tenant_id' => '11111111-1111-1111-1111-111111111112',
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'verification_status' => 'healthy',
|
||||||
|
]);
|
||||||
|
|
||||||
|
spec283SeedPermissionRow($tenant, $connection, 'DeviceManagementConfiguration.Read.All');
|
||||||
|
|
||||||
|
$result = app(ProviderCapabilityEvaluator::class)->evaluate($tenant, $connection, 'inventory_read');
|
||||||
|
|
||||||
|
expect($result->status)->toBe(ProviderCapabilityStatus::Missing)
|
||||||
|
->and($result->blocksExecution())->toBeTrue()
|
||||||
|
->and($result->missingRequirementKeys)->toContain('permissions.intune_configuration')
|
||||||
|
->and($result->missingRequirementKeys)->toContain('permissions.intune_apps');
|
||||||
|
});
|
||||||
|
|
||||||
it('returns capability-first missing and blocked states', function (): void {
|
it('returns capability-first missing and blocked states', function (): void {
|
||||||
$tenant = ManagedEnvironment::factory()->create([
|
$tenant = ManagedEnvironment::factory()->create([
|
||||||
'managed_environment_id' => '22222222-2222-2222-2222-222222222222',
|
'managed_environment_id' => '22222222-2222-2222-2222-222222222222',
|
||||||
@ -122,6 +168,29 @@ function spec283SeedRequirementRows(ManagedEnvironment $tenant, array $requireme
|
|||||||
->and($blocked->reasonCode)->toBe(ProviderReasonCodes::ProviderConnectionInvalid);
|
->and($blocked->reasonCode)->toBe(ProviderReasonCodes::ProviderConnectionInvalid);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps provider capabilities unknown when provider health verification is stale despite fresh permission rows', function (): void {
|
||||||
|
$tenant = ManagedEnvironment::factory()->create([
|
||||||
|
'managed_environment_id' => '22222222-2222-2222-2222-222222222223',
|
||||||
|
]);
|
||||||
|
$connection = ProviderConnection::factory()->withCredential()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
|
'entra_tenant_id' => '22222222-2222-2222-2222-222222222223',
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'verification_status' => 'healthy',
|
||||||
|
'last_health_check_at' => now()->subDays(31),
|
||||||
|
]);
|
||||||
|
|
||||||
|
spec283SeedRequirementRows($tenant, ['permissions.directory_groups']);
|
||||||
|
|
||||||
|
$result = app(ProviderCapabilityEvaluator::class)->evaluate($tenant, $connection, 'directory_groups_read');
|
||||||
|
|
||||||
|
expect($result->status)->toBe(ProviderCapabilityStatus::Unknown)
|
||||||
|
->and($result->reasonCode)->toBe(ProviderReasonCodes::ProviderPermissionRefreshFailed)
|
||||||
|
->and($result->missingRequirementKeys)->toContain('permissions.directory_groups')
|
||||||
|
->and($result->blocksExecution())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
it('treats admin consent as the provider connection check prerequisite', function (): void {
|
it('treats admin consent as the provider connection check prerequisite', function (): void {
|
||||||
$tenant = ManagedEnvironment::factory()->create([
|
$tenant = ManagedEnvironment::factory()->create([
|
||||||
'managed_environment_id' => '33333333-3333-3333-3333-333333333333',
|
'managed_environment_id' => '33333333-3333-3333-3333-333333333333',
|
||||||
|
|||||||
@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\ProviderCredential;
|
use App\Models\ProviderCredential;
|
||||||
use App\Models\ManagedEnvironment;
|
|
||||||
use App\Services\Providers\ProviderOperationStartGate;
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
@ -84,6 +84,8 @@
|
|||||||
'managed_environment_id' => (int) $tenant->getKey(),
|
'managed_environment_id' => (int) $tenant->getKey(),
|
||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
'entra_tenant_id' => '55555555-5555-5555-5555-555555555555',
|
'entra_tenant_id' => '55555555-5555-5555-5555-555555555555',
|
||||||
|
'verification_status' => 'healthy',
|
||||||
|
'last_health_check_at' => now(),
|
||||||
]);
|
]);
|
||||||
ProviderCredential::factory()->create([
|
ProviderCredential::factory()->create([
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
|||||||
@ -0,0 +1,615 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\ManagedEnvironmentMembership;
|
||||||
|
use App\Models\ManagedEnvironmentPermission;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\TenantConfigurationResource;
|
||||||
|
use App\Models\TenantConfigurationResourceEvidence;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||||
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
|
use App\Services\TenantConfiguration\ExchangePowerShellCommandContract;
|
||||||
|
use App\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
|
||||||
|
use App\Services\TenantConfiguration\ExchangePowerShellCommandRunner;
|
||||||
|
use App\Services\TenantConfiguration\ExchangePowerShellInvocationContext;
|
||||||
|
use App\Services\TenantConfiguration\ExchangePowerShellInvocationGate;
|
||||||
|
use App\Services\TenantConfiguration\FakeExchangePowerShellCommandRunner;
|
||||||
|
use App\Services\TenantConfiguration\ResourceTypeRegistry;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\Providers\ProviderVerificationStatus;
|
||||||
|
use App\Support\Verification\ManagedEnvironmentPermissionCheckClusters;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
||||||
|
config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec431 fake runner invocation creates a completed count-only OperationRun before runner execution', function (): void {
|
||||||
|
config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]);
|
||||||
|
|
||||||
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = spec431ProviderConnection($environment);
|
||||||
|
$runner = spec431FakeRunner();
|
||||||
|
|
||||||
|
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||||
|
tenant: $environment,
|
||||||
|
providerConnection: $connection,
|
||||||
|
actor: $user,
|
||||||
|
canonicalType: 'transportRule',
|
||||||
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($runner->calls)->toHaveCount(1)
|
||||||
|
->and($runner->calls[0]['operation_run_id'])->toBe((int) $run->getKey())
|
||||||
|
->and($run->type)->toBe(ExchangePowerShellInvocationGate::OPERATION_TYPE)
|
||||||
|
->and($run->status)->toBe(OperationRunStatus::Completed->value)
|
||||||
|
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
|
||||||
|
->and($run->summary_counts)->toMatchArray([
|
||||||
|
'total' => 1,
|
||||||
|
'processed' => 1,
|
||||||
|
'succeeded' => 1,
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
'items' => 1,
|
||||||
|
])
|
||||||
|
->and(data_get($run->context, 'provider_connection_id'))->toBe((int) $connection->getKey())
|
||||||
|
->and(data_get($run->context, 'provider_identity_resolution_mode'))->toBe('fake_runner_bypass')
|
||||||
|
->and(data_get($run->context, 'source_contract.source_contract_state'))->toBe('contract_verified_pending_capture')
|
||||||
|
->and(data_get($run->context, 'source_contract.provider_calls_allowed'))->toBeFalse()
|
||||||
|
->and(TenantConfigurationResource::query()->count())->toBe(0)
|
||||||
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
|
||||||
|
|
||||||
|
$contextJson = json_encode($run->context, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
expect($contextJson)
|
||||||
|
->not->toContain('raw_provider_payload')
|
||||||
|
->not->toContain('raw_shell_stdout')
|
||||||
|
->not->toContain('client_secret')
|
||||||
|
->not->toContain('access_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec431 default feature gate blocks before fake runner execution', function (): void {
|
||||||
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = spec431ProviderConnection($environment);
|
||||||
|
$runner = spec431FakeRunner();
|
||||||
|
|
||||||
|
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||||
|
tenant: $environment,
|
||||||
|
providerConnection: $connection,
|
||||||
|
actor: $user,
|
||||||
|
canonicalType: 'remoteDomain',
|
||||||
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($runner->calls)->toBe([])
|
||||||
|
->and($run->status)->toBe(OperationRunStatus::Completed->value)
|
||||||
|
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||||
|
->and(data_get($run->context, 'failure_code'))->toBe('execution_blocked_feature_disabled')
|
||||||
|
->and(data_get($run->context, 'feature_gate.enabled'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec431 denies in-scope users missing provider-run capability as forbidden without OperationRun', function (): void {
|
||||||
|
[$user, $environment] = createMinimalUserWithTenant(role: 'readonly', workspaceRole: 'readonly');
|
||||||
|
$connection = spec431ProviderConnection($environment);
|
||||||
|
$runner = spec431FakeRunner();
|
||||||
|
|
||||||
|
expect(fn () => app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||||
|
tenant: $environment,
|
||||||
|
providerConnection: $connection,
|
||||||
|
actor: $user,
|
||||||
|
canonicalType: 'transportRule',
|
||||||
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
||||||
|
))->toThrow(AuthorizationException::class);
|
||||||
|
|
||||||
|
expect($runner->calls)->toBe([])
|
||||||
|
->and(OperationRun::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec431 hides environments outside workspace membership without OperationRun', function (): void {
|
||||||
|
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$outsider = User::factory()->create();
|
||||||
|
$connection = spec431ProviderConnection($environment);
|
||||||
|
$runner = spec431FakeRunner();
|
||||||
|
|
||||||
|
expect(fn () => app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||||
|
tenant: $environment,
|
||||||
|
providerConnection: $connection,
|
||||||
|
actor: $outsider,
|
||||||
|
canonicalType: 'transportRule',
|
||||||
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
||||||
|
))->toThrow(NotFoundHttpException::class);
|
||||||
|
|
||||||
|
expect($runner->calls)->toBe([])
|
||||||
|
->and(OperationRun::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec431 hides environments outside explicit managed-environment entitlement scope', function (): void {
|
||||||
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$allowedEnvironment = ManagedEnvironment::factory()->create([
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
]);
|
||||||
|
$connection = spec431ProviderConnection($environment);
|
||||||
|
$runner = spec431FakeRunner();
|
||||||
|
|
||||||
|
ManagedEnvironmentMembership::query()
|
||||||
|
->where('managed_environment_id', (int) $environment->getKey())
|
||||||
|
->where('user_id', (int) $user->getKey())
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
ManagedEnvironmentMembership::query()->create([
|
||||||
|
'managed_environment_id' => (int) $allowedEnvironment->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
'source' => 'manual',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(ManagedEnvironmentAccessScopeResolver::class)->clearCache();
|
||||||
|
|
||||||
|
expect(fn () => app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||||
|
tenant: $environment,
|
||||||
|
providerConnection: $connection,
|
||||||
|
actor: $user,
|
||||||
|
canonicalType: 'transportRule',
|
||||||
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
||||||
|
))->toThrow(NotFoundHttpException::class);
|
||||||
|
|
||||||
|
expect($runner->calls)->toBe([])
|
||||||
|
->and(OperationRun::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec431 rejects provider connections outside the environment scope before runner execution', function (): void {
|
||||||
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$otherEnvironment = ManagedEnvironment::factory()->create([
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
]);
|
||||||
|
$connection = spec431ProviderConnection($otherEnvironment);
|
||||||
|
$runner = spec431FakeRunner();
|
||||||
|
|
||||||
|
expect(fn () => app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||||
|
tenant: $environment,
|
||||||
|
providerConnection: $connection,
|
||||||
|
actor: $user,
|
||||||
|
canonicalType: 'transportRule',
|
||||||
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
||||||
|
))->toThrow(NotFoundHttpException::class);
|
||||||
|
|
||||||
|
expect($runner->calls)->toBe([])
|
||||||
|
->and(OperationRun::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec431 provider readiness blocks missing admin consent before runner execution', function (): void {
|
||||||
|
config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]);
|
||||||
|
|
||||||
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = spec431ProviderConnection($environment, [
|
||||||
|
'consent_status' => ProviderConsentStatus::Required->value,
|
||||||
|
'consent_granted_at' => null,
|
||||||
|
]);
|
||||||
|
$runner = spec431FakeRunner();
|
||||||
|
|
||||||
|
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||||
|
tenant: $environment,
|
||||||
|
providerConnection: $connection,
|
||||||
|
actor: $user,
|
||||||
|
canonicalType: 'transportRule',
|
||||||
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($runner->calls)->toBe([])
|
||||||
|
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||||
|
->and(data_get($run->context, 'reason_code'))->toBe(ProviderReasonCodes::ProviderConsentMissing);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec431 provider readiness blocks missing Exchange permission evidence before fake runner execution', function (): void {
|
||||||
|
config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]);
|
||||||
|
|
||||||
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = spec431ProviderConnection($environment, seedExchangePowerShellPermission: false);
|
||||||
|
$runner = spec431FakeRunner();
|
||||||
|
|
||||||
|
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||||
|
tenant: $environment,
|
||||||
|
providerConnection: $connection,
|
||||||
|
actor: $user,
|
||||||
|
canonicalType: 'transportRule',
|
||||||
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($runner->calls)->toBe([])
|
||||||
|
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||||
|
->and(data_get($run->context, 'provider_capability.provider_capability_key'))->toBe('exchange_powershell_invoke')
|
||||||
|
->and(data_get($run->context, 'provider_capability.status'))->toBe('unknown')
|
||||||
|
->and(data_get($run->context, 'provider_capability.missing_requirement_keys'))->toContain('provider.exchange_powershell_invocation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec431 non-fake mode requires existing provider identity resolution before runner execution', function (): void {
|
||||||
|
config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]);
|
||||||
|
|
||||||
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = spec431ProviderConnection($environment);
|
||||||
|
$runner = spec431FakeRunner();
|
||||||
|
|
||||||
|
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||||
|
tenant: $environment,
|
||||||
|
providerConnection: $connection,
|
||||||
|
actor: $user,
|
||||||
|
canonicalType: 'transportRule',
|
||||||
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_DISABLED,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($runner->calls)->toBe([])
|
||||||
|
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||||
|
->and(data_get($run->context, 'reason_code'))->toBeIn([
|
||||||
|
ProviderReasonCodes::DedicatedCredentialMissing,
|
||||||
|
ProviderReasonCodes::ProviderCredentialMissing,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec431 rejects invalid contracts and raw mutation commands without runner execution', function (string $canonicalType, ?string $commandName, string $expectedFailure): void {
|
||||||
|
config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]);
|
||||||
|
|
||||||
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = spec431ProviderConnection($environment);
|
||||||
|
$runner = spec431FakeRunner();
|
||||||
|
|
||||||
|
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||||
|
tenant: $environment,
|
||||||
|
providerConnection: $connection,
|
||||||
|
actor: $user,
|
||||||
|
canonicalType: $canonicalType,
|
||||||
|
commandName: $commandName,
|
||||||
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($runner->calls)->toBe([])
|
||||||
|
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||||
|
->and(data_get($run->context, 'failure_code'))->toBe($expectedFailure);
|
||||||
|
|
||||||
|
$contextJson = json_encode($run->context, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
if (is_string($commandName) && str_starts_with($commandName, 'Set-')) {
|
||||||
|
expect($contextJson)->not->toContain($commandName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($canonicalType, 'client_secret')) {
|
||||||
|
expect($contextJson)->not->toContain($canonicalType);
|
||||||
|
}
|
||||||
|
})->with([
|
||||||
|
'unsupported target' => ['acceptedDomain', null, 'source_contract_missing'],
|
||||||
|
'unsafe target' => ['client_secret_super_token', null, 'source_contract_missing'],
|
||||||
|
'mutation command' => ['transportRule', 'Set-TransportRule', 'command_contract_rejected'],
|
||||||
|
'wrong allowlisted command' => ['transportRule', 'Get-RemoteDomain', 'command_contract_rejected'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('Spec431 rejects unknown command parameters before runner execution', function (): void {
|
||||||
|
config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]);
|
||||||
|
|
||||||
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = spec431ProviderConnection($environment);
|
||||||
|
$runner = spec431FakeRunner();
|
||||||
|
$secretParameterName = 'client_secret_super_token';
|
||||||
|
|
||||||
|
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||||
|
tenant: $environment,
|
||||||
|
providerConnection: $connection,
|
||||||
|
actor: $user,
|
||||||
|
canonicalType: 'transportRule',
|
||||||
|
parameters: [$secretParameterName => 'unexpected'],
|
||||||
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($runner->calls)->toBe([])
|
||||||
|
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||||
|
->and(data_get($run->context, 'failure_code'))->toBe('command_contract_rejected')
|
||||||
|
->and(data_get($run->context, 'command_validation.reason_code'))->toBe('unknown_parameter_rejected')
|
||||||
|
->and(data_get($run->context, 'command_validation.rejected_parameter_present'))->toBeTrue()
|
||||||
|
->and(data_get($run->context, 'command_validation.rejected_parameter'))->toBeNull()
|
||||||
|
->and(json_encode($run->context, JSON_THROW_ON_ERROR))
|
||||||
|
->not->toContain($secretParameterName)
|
||||||
|
->not->toContain('unexpected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec431 malformed fake-runner output fails safely without raw output promotion', function (): void {
|
||||||
|
config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]);
|
||||||
|
|
||||||
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = spec431ProviderConnection($environment);
|
||||||
|
$runner = spec431FakeRunner(FakeExchangePowerShellCommandRunner::SCENARIO_MALFORMED_TEXT);
|
||||||
|
|
||||||
|
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||||
|
tenant: $environment,
|
||||||
|
providerConnection: $connection,
|
||||||
|
actor: $user,
|
||||||
|
canonicalType: 'inboundConnector',
|
||||||
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($runner->calls)->toHaveCount(1)
|
||||||
|
->and($run->outcome)->toBe(OperationRunOutcome::Failed->value)
|
||||||
|
->and(data_get($run->context, 'failure_code'))->toBe('execution_failed_shape_unsafe')
|
||||||
|
->and(data_get($run->context, 'response_shape.safe'))->toBeFalse()
|
||||||
|
->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('raw transcript rejected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec431 fake-runner scenarios persist only sanitized lifecycle outcomes', function (
|
||||||
|
string $scenario,
|
||||||
|
string $expectedOutcome,
|
||||||
|
?string $expectedFailureCode,
|
||||||
|
?string $expectedReasonCode,
|
||||||
|
array $expectedSummaryCounts,
|
||||||
|
): void {
|
||||||
|
config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]);
|
||||||
|
|
||||||
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = spec431ProviderConnection($environment);
|
||||||
|
$runner = spec431FakeRunner($scenario);
|
||||||
|
|
||||||
|
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||||
|
tenant: $environment,
|
||||||
|
providerConnection: $connection,
|
||||||
|
actor: $user,
|
||||||
|
canonicalType: 'remoteDomain',
|
||||||
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
||||||
|
);
|
||||||
|
|
||||||
|
$failureSummary = is_array($run->failure_summary) ? $run->failure_summary : [];
|
||||||
|
$reasonCode = data_get($run->context, 'reason_code') ?? data_get($failureSummary, '0.reason_code');
|
||||||
|
$persistedJson = json_encode([
|
||||||
|
'context' => $run->context,
|
||||||
|
'failure_summary' => $failureSummary,
|
||||||
|
'summary_counts' => $run->summary_counts,
|
||||||
|
], JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
expect($runner->calls)->toHaveCount(1)
|
||||||
|
->and($run->status)->toBe(OperationRunStatus::Completed->value)
|
||||||
|
->and($run->outcome)->toBe($expectedOutcome)
|
||||||
|
->and($run->summary_counts)->toMatchArray($expectedSummaryCounts)
|
||||||
|
->and(array_keys($run->summary_counts ?? []))->each->toBeIn(['total', 'processed', 'succeeded', 'failed', 'skipped', 'items'])
|
||||||
|
->and(data_get($run->context, 'failure_code'))->toBe($expectedFailureCode)
|
||||||
|
->and($reasonCode)->toBe($expectedReasonCode)
|
||||||
|
->and($persistedJson)
|
||||||
|
->not->toContain('raw transcript rejected')
|
||||||
|
->not->toContain('Simulated runner exception')
|
||||||
|
->not->toContain('access_token')
|
||||||
|
->not->toContain('client_secret')
|
||||||
|
->not->toContain('refresh_token')
|
||||||
|
->not->toContain('password=');
|
||||||
|
})->with([
|
||||||
|
'empty success' => [
|
||||||
|
FakeExchangePowerShellCommandRunner::SCENARIO_EMPTY_SUCCESS,
|
||||||
|
OperationRunOutcome::Succeeded->value,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
['total' => 0, 'processed' => 0, 'succeeded' => 0, 'failed' => 0, 'skipped' => 0, 'items' => 0],
|
||||||
|
],
|
||||||
|
'authentication failure' => [
|
||||||
|
FakeExchangePowerShellCommandRunner::SCENARIO_AUTH_FAILURE,
|
||||||
|
OperationRunOutcome::Failed->value,
|
||||||
|
'execution_failed_authentication',
|
||||||
|
ProviderReasonCodes::ProviderAuthFailed,
|
||||||
|
['total' => 1, 'processed' => 1, 'succeeded' => 0, 'failed' => 1, 'skipped' => 0, 'items' => 0],
|
||||||
|
],
|
||||||
|
'authorization failure' => [
|
||||||
|
FakeExchangePowerShellCommandRunner::SCENARIO_AUTHORIZATION_FAILURE,
|
||||||
|
OperationRunOutcome::Failed->value,
|
||||||
|
'execution_failed_authorization',
|
||||||
|
ProviderReasonCodes::ProviderPermissionDenied,
|
||||||
|
['total' => 1, 'processed' => 1, 'succeeded' => 0, 'failed' => 1, 'skipped' => 0, 'items' => 0],
|
||||||
|
],
|
||||||
|
'timeout' => [
|
||||||
|
FakeExchangePowerShellCommandRunner::SCENARIO_TIMEOUT,
|
||||||
|
OperationRunOutcome::Failed->value,
|
||||||
|
'execution_failed_timeout',
|
||||||
|
ProviderReasonCodes::NetworkUnreachable,
|
||||||
|
['total' => 1, 'processed' => 1, 'succeeded' => 0, 'failed' => 1, 'skipped' => 0, 'items' => 0],
|
||||||
|
],
|
||||||
|
'throttled' => [
|
||||||
|
FakeExchangePowerShellCommandRunner::SCENARIO_THROTTLED,
|
||||||
|
OperationRunOutcome::Failed->value,
|
||||||
|
'execution_failed_throttled',
|
||||||
|
ProviderReasonCodes::RateLimited,
|
||||||
|
['total' => 1, 'processed' => 1, 'succeeded' => 0, 'failed' => 1, 'skipped' => 0, 'items' => 0],
|
||||||
|
],
|
||||||
|
'module unavailable' => [
|
||||||
|
FakeExchangePowerShellCommandRunner::SCENARIO_MODULE_UNAVAILABLE,
|
||||||
|
OperationRunOutcome::Blocked->value,
|
||||||
|
'execution_blocked_module_unavailable',
|
||||||
|
ProviderReasonCodes::ProviderBindingUnsupported,
|
||||||
|
['total' => 0, 'processed' => 0, 'succeeded' => 0, 'failed' => 0, 'skipped' => 0, 'items' => 0],
|
||||||
|
],
|
||||||
|
'command unavailable' => [
|
||||||
|
FakeExchangePowerShellCommandRunner::SCENARIO_COMMAND_UNAVAILABLE,
|
||||||
|
OperationRunOutcome::Blocked->value,
|
||||||
|
'execution_blocked_command_unavailable',
|
||||||
|
ProviderReasonCodes::ProviderBindingUnsupported,
|
||||||
|
['total' => 0, 'processed' => 0, 'succeeded' => 0, 'failed' => 0, 'skipped' => 0, 'items' => 0],
|
||||||
|
],
|
||||||
|
'malformed scalar' => [
|
||||||
|
FakeExchangePowerShellCommandRunner::SCENARIO_MALFORMED_SCALAR,
|
||||||
|
OperationRunOutcome::Failed->value,
|
||||||
|
'execution_failed_shape_unsafe',
|
||||||
|
ProviderReasonCodes::ProviderConnectionInvalid,
|
||||||
|
['total' => 1, 'processed' => 1, 'succeeded' => 0, 'failed' => 1, 'skipped' => 0, 'items' => 0],
|
||||||
|
],
|
||||||
|
'malformed text' => [
|
||||||
|
FakeExchangePowerShellCommandRunner::SCENARIO_MALFORMED_TEXT,
|
||||||
|
OperationRunOutcome::Failed->value,
|
||||||
|
'execution_failed_shape_unsafe',
|
||||||
|
ProviderReasonCodes::ProviderConnectionInvalid,
|
||||||
|
['total' => 1, 'processed' => 1, 'succeeded' => 0, 'failed' => 1, 'skipped' => 0, 'items' => 0],
|
||||||
|
],
|
||||||
|
'unexpected exception' => [
|
||||||
|
FakeExchangePowerShellCommandRunner::SCENARIO_UNEXPECTED_EXCEPTION,
|
||||||
|
OperationRunOutcome::Failed->value,
|
||||||
|
'execution_failed_unexpected_exception',
|
||||||
|
ProviderReasonCodes::UnknownError,
|
||||||
|
['total' => 1, 'processed' => 1, 'succeeded' => 0, 'failed' => 1, 'skipped' => 0, 'items' => 0],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('Spec431 direct provider start-gate invocation is blocked as a bypass path', function (): void {
|
||||||
|
config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]);
|
||||||
|
|
||||||
|
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||||
|
$connection = spec431ProviderConnection($environment);
|
||||||
|
$dispatched = 0;
|
||||||
|
|
||||||
|
$result = app(ProviderOperationStartGate::class)->start(
|
||||||
|
tenant: $environment,
|
||||||
|
connection: $connection,
|
||||||
|
operationType: ExchangePowerShellInvocationGate::OPERATION_TYPE,
|
||||||
|
dispatcher: function () use (&$dispatched): void {
|
||||||
|
$dispatched++;
|
||||||
|
},
|
||||||
|
extraContext: [
|
||||||
|
'invocation_gate' => 'exchange_powershell_invocation_gate',
|
||||||
|
'parameter_names' => ['client_secret_super_token'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($dispatched)->toBe(0)
|
||||||
|
->and($result->status)->toBe('blocked')
|
||||||
|
->and($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||||
|
->and(data_get($result->run->context, 'reason_code_extension'))->toBe('ext.exchange_powershell_invocation_gate_required')
|
||||||
|
->and(json_encode($result->run->context, JSON_THROW_ON_ERROR))->not->toContain('client_secret_super_token')
|
||||||
|
->and(file_get_contents(app_path('Providers/AppServiceProvider.php')) ?: '')->not->toContain('Closure::bind')
|
||||||
|
->and(file_get_contents(app_path('Services/Providers/ProviderOperationStartGate.php')) ?: '')->not->toContain('startTrusted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec431 direct runner calls reject raw command strings arrays and unverified parameters by type boundary', function (): void {
|
||||||
|
$runner = new FakeExchangePowerShellCommandRunner;
|
||||||
|
$context = new ExchangePowerShellInvocationContext(
|
||||||
|
operationRunId: 431,
|
||||||
|
workspaceId: 1,
|
||||||
|
managedEnvironmentId: 1,
|
||||||
|
providerConnectionId: 1,
|
||||||
|
canonicalType: 'transportRule',
|
||||||
|
commandName: 'Get-TransportRule',
|
||||||
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
||||||
|
redactionPolicy: ExchangePowerShellInvocationGate::REDACTION_POLICY,
|
||||||
|
sourceContractState: 'contract_verified_pending_capture',
|
||||||
|
);
|
||||||
|
|
||||||
|
$contracts = app(ExchangePowerShellCommandContracts::class);
|
||||||
|
$contract = $contracts->contractForCanonicalType('transportRule');
|
||||||
|
|
||||||
|
expect($contract)->toBeArray();
|
||||||
|
|
||||||
|
$verifiedContract = ExchangePowerShellCommandContract::fromVerifiedArray($contract, $contracts, []);
|
||||||
|
$mutatedContract = [
|
||||||
|
...$contract,
|
||||||
|
'allowed_parameters' => ['client_secret_super_token'],
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(fn () => $runner->run('Get-TransportRule', $context))->toThrow(TypeError::class)
|
||||||
|
->and(fn () => $runner->run(['command_name' => 'Get-TransportRule'], $context))->toThrow(TypeError::class)
|
||||||
|
->and(fn () => ExchangePowerShellCommandContract::fromVerifiedArray(
|
||||||
|
$contract,
|
||||||
|
$contracts,
|
||||||
|
['client_secret_super_token' => 'unexpected'],
|
||||||
|
))->toThrow(InvalidArgumentException::class)
|
||||||
|
->and(fn () => ExchangePowerShellCommandContract::fromVerifiedArray(
|
||||||
|
$mutatedContract,
|
||||||
|
$contracts,
|
||||||
|
['client_secret_super_token' => 'unexpected'],
|
||||||
|
))->toThrow(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
$runner->run($verifiedContract, $context);
|
||||||
|
|
||||||
|
expect($runner->calls[0]['parameter_names'])->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec431 introduces no rendered UI surface routes jobs migrations or evidence promotion', function (): void {
|
||||||
|
$runtimeFiles = [
|
||||||
|
app_path('Services/TenantConfiguration/ExchangePowerShellInvocationGate.php'),
|
||||||
|
app_path('Services/TenantConfiguration/ExchangePowerShellCommandRunner.php'),
|
||||||
|
app_path('Services/TenantConfiguration/FakeExchangePowerShellCommandRunner.php'),
|
||||||
|
app_path('Services/TenantConfiguration/DisabledExchangePowerShellCommandRunner.php'),
|
||||||
|
];
|
||||||
|
$runtimeSource = collect($runtimeFiles)
|
||||||
|
->map(fn (string $file): string => file_get_contents($file) ?: '')
|
||||||
|
->implode("\n");
|
||||||
|
|
||||||
|
expect($runtimeSource)
|
||||||
|
->not->toContain('tenant_id')
|
||||||
|
->not->toContain('provider_tenant_id')
|
||||||
|
->not->toContain('entra_tenant_id')
|
||||||
|
->not->toContain('Start-')
|
||||||
|
->not->toContain('Set-')
|
||||||
|
->not->toContain('New-')
|
||||||
|
->not->toContain('Remove-')
|
||||||
|
->and(glob(app_path('Filament/**/*ExchangePowerShell*')) ?: [])->toBe([])
|
||||||
|
->and(glob(base_path('routes/*exchange*')) ?: [])->toBe([])
|
||||||
|
->and(glob(resource_path('views/**/*ExchangePowerShell*')) ?: [])->toBe([])
|
||||||
|
->and(glob(database_path('migrations/*exchange_powershell*')) ?: [])->toBe([])
|
||||||
|
->and(File::exists(app_path('Jobs/TenantConfiguration/InvokeExchangePowerShell.php')))->toBeFalse()
|
||||||
|
->and(ManagedEnvironmentPermissionCheckClusters::definitionForKey('provider.exchange_powershell_invocation'))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $overrides
|
||||||
|
*/
|
||||||
|
function spec431ProviderConnection(
|
||||||
|
ManagedEnvironment $environment,
|
||||||
|
array $overrides = [],
|
||||||
|
bool $seedExchangePowerShellPermission = true,
|
||||||
|
): ProviderConnection {
|
||||||
|
$connection = ProviderConnection::factory()
|
||||||
|
->dedicated()
|
||||||
|
->consentGranted()
|
||||||
|
->create([
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'entra_tenant_id' => $environment->providerTenantContext(),
|
||||||
|
'is_default' => true,
|
||||||
|
'verification_status' => ProviderVerificationStatus::Healthy->value,
|
||||||
|
'last_health_check_at' => now(),
|
||||||
|
...$overrides,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($seedExchangePowerShellPermission) {
|
||||||
|
spec431SeedExchangePowerShellPermission($environment, $connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec431SeedExchangePowerShellPermission(ManagedEnvironment $environment, ProviderConnection $connection, string $status = 'granted'): void
|
||||||
|
{
|
||||||
|
ManagedEnvironmentPermission::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'managed_environment_id' => (int) $environment->getKey(),
|
||||||
|
'permission_key' => 'Exchange.ManageAsApp',
|
||||||
|
'workspace_id' => (int) $environment->workspace_id,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'status' => $status,
|
||||||
|
'details' => [
|
||||||
|
'source' => 'spec431-test',
|
||||||
|
'workspace_id' => (int) $connection->workspace_id,
|
||||||
|
'managed_environment_id' => (int) $connection->managed_environment_id,
|
||||||
|
'provider' => (string) $connection->provider,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
'last_checked_at' => now(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec431FakeRunner(string $scenario = FakeExchangePowerShellCommandRunner::SCENARIO_SUCCESS): FakeExchangePowerShellCommandRunner
|
||||||
|
{
|
||||||
|
$runner = new FakeExchangePowerShellCommandRunner($scenario);
|
||||||
|
app()->instance(ExchangePowerShellCommandRunner::class, $runner);
|
||||||
|
|
||||||
|
return $runner;
|
||||||
|
}
|
||||||
@ -16,9 +16,11 @@
|
|||||||
'restore_execute',
|
'restore_execute',
|
||||||
'directory_groups_read',
|
'directory_groups_read',
|
||||||
'directory_role_definitions_read',
|
'directory_role_definitions_read',
|
||||||
|
'exchange_powershell_invoke',
|
||||||
])
|
])
|
||||||
->and($registry->keysForOperationType('inventory.sync'))->toBe(['inventory_read'])
|
->and($registry->keysForOperationType('inventory.sync'))->toBe(['inventory_read'])
|
||||||
->and($registry->keysForOperationType('directory.role_definitions.sync'))->toBe(['directory_role_definitions_read'])
|
->and($registry->keysForOperationType('directory.role_definitions.sync'))->toBe(['directory_role_definitions_read'])
|
||||||
|
->and($registry->keysForOperationType('tenant_configuration.exchange_powershell_invocation'))->toBe(['exchange_powershell_invoke'])
|
||||||
->and($registry->get('restore_execute')->providerRequirementKeys)->toBe([
|
->and($registry->get('restore_execute')->providerRequirementKeys)->toBe([
|
||||||
'permissions.intune_configuration',
|
'permissions.intune_configuration',
|
||||||
'permissions.intune_rbac_assignments',
|
'permissions.intune_rbac_assignments',
|
||||||
@ -26,6 +28,10 @@
|
|||||||
->and($registry->get('directory_role_definitions_read')->providerRequirementKeys)->toBe([
|
->and($registry->get('directory_role_definitions_read')->providerRequirementKeys)->toBe([
|
||||||
'provider.directory_role_definitions',
|
'provider.directory_role_definitions',
|
||||||
'permissions.admin_consent',
|
'permissions.admin_consent',
|
||||||
|
])
|
||||||
|
->and($registry->get('exchange_powershell_invoke')->providerRequirementKeys)->toBe([
|
||||||
|
'provider.exchange_powershell_invocation',
|
||||||
|
'permissions.admin_consent',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,20 @@
|
|||||||
if (! function_exists('providerOperationStartGateSeedRequirementRows')) {
|
if (! function_exists('providerOperationStartGateSeedRequirementRows')) {
|
||||||
function providerOperationStartGateSeedRequirementRows(ManagedEnvironment $tenant, array $requirementKeys): void
|
function providerOperationStartGateSeedRequirementRows(ManagedEnvironment $tenant, array $requirementKeys): void
|
||||||
{
|
{
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('managed_environment_id', (int) $tenant->getKey())
|
||||||
|
->where('provider', 'microsoft')
|
||||||
|
->orderByDesc('is_default')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($connection instanceof ProviderConnection) {
|
||||||
|
$connection->forceFill([
|
||||||
|
'verification_status' => 'healthy',
|
||||||
|
'last_health_check_at' => now(),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
$permissions = array_merge(
|
$permissions = array_merge(
|
||||||
config('intune_permissions.permissions', []),
|
config('intune_permissions.permissions', []),
|
||||||
config('entra_permissions.permissions', []),
|
config('entra_permissions.permissions', []),
|
||||||
@ -39,7 +53,13 @@ function providerOperationStartGateSeedRequirementRows(ManagedEnvironment $tenan
|
|||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
], [
|
], [
|
||||||
'status' => 'granted',
|
'status' => 'granted',
|
||||||
'details' => ['source' => 'provider-operation-start-gate-test'],
|
'details' => [
|
||||||
|
'source' => 'provider-operation-start-gate-test',
|
||||||
|
'workspace_id' => $connection instanceof ProviderConnection ? (int) $connection->workspace_id : (int) $tenant->workspace_id,
|
||||||
|
'managed_environment_id' => $connection instanceof ProviderConnection ? (int) $connection->managed_environment_id : (int) $tenant->getKey(),
|
||||||
|
'provider' => $connection instanceof ProviderConnection ? (string) $connection->provider : 'microsoft',
|
||||||
|
'provider_connection_id' => $connection instanceof ProviderConnection ? (int) $connection->getKey() : null,
|
||||||
|
],
|
||||||
'last_checked_at' => now(),
|
'last_checked_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -350,6 +370,38 @@ function providerOperationStartGateSeedRequirementRows(ManagedEnvironment $tenan
|
|||||||
])->not->toHaveKey('entra_tenant_id');
|
])->not->toHaveKey('entra_tenant_id');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('requires provider identity for non-exchange starts even when fake runner bypass context is supplied', function (): void {
|
||||||
|
$tenant = ManagedEnvironment::factory()->create([
|
||||||
|
'managed_environment_id' => 'directory-no-credential-tenant-id',
|
||||||
|
]);
|
||||||
|
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||||
|
'managed_environment_id' => $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => 'directory-no-credential-tenant-id',
|
||||||
|
'consent_status' => 'granted',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dispatched = 0;
|
||||||
|
$gate = app(ProviderOperationStartGate::class);
|
||||||
|
|
||||||
|
$result = $gate->start(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $connection,
|
||||||
|
operationType: 'directory.groups.sync',
|
||||||
|
dispatcher: function () use (&$dispatched): void {
|
||||||
|
$dispatched++;
|
||||||
|
},
|
||||||
|
extraContext: [
|
||||||
|
'provider_identity_resolution_mode' => 'fake_runner_bypass',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($dispatched)->toBe(0);
|
||||||
|
expect($result->status)->toBe('blocked');
|
||||||
|
expect($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value);
|
||||||
|
expect($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::DedicatedCredentialMissing);
|
||||||
|
});
|
||||||
|
|
||||||
it('treats onboarding bootstrap provider starts as one protected scope', function (): void {
|
it('treats onboarding bootstrap provider starts as one protected scope', function (): void {
|
||||||
$tenant = ManagedEnvironment::factory()->create();
|
$tenant = ManagedEnvironment::factory()->create();
|
||||||
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||||
|
|||||||
@ -64,6 +64,10 @@
|
|||||||
it('Spec430 rejects unknown command names and arbitrary parameters', function (): void {
|
it('Spec430 rejects unknown command names and arbitrary parameters', function (): void {
|
||||||
$contracts = new ExchangePowerShellCommandContracts;
|
$contracts = new ExchangePowerShellCommandContracts;
|
||||||
$contract = $contracts->contractForCanonicalType('transportRule');
|
$contract = $contracts->contractForCanonicalType('transportRule');
|
||||||
|
$mutatedContract = [
|
||||||
|
...$contract,
|
||||||
|
'allowed_parameters' => ['Identity'],
|
||||||
|
];
|
||||||
|
|
||||||
expect($contracts->validateCommandName('Get-Mailbox'))->toMatchArray([
|
expect($contracts->validateCommandName('Get-Mailbox'))->toMatchArray([
|
||||||
'accepted' => false,
|
'accepted' => false,
|
||||||
@ -72,10 +76,36 @@
|
|||||||
->and($contracts->validateInvocation($contract, ['Identity' => 'rule-1']))->toMatchArray([
|
->and($contracts->validateInvocation($contract, ['Identity' => 'rule-1']))->toMatchArray([
|
||||||
'accepted' => false,
|
'accepted' => false,
|
||||||
'reason_code' => 'unknown_parameter_rejected',
|
'reason_code' => 'unknown_parameter_rejected',
|
||||||
'rejected_parameter' => 'Identity',
|
'rejected_parameter_present' => true,
|
||||||
|
])
|
||||||
|
->and($contracts->validateInvocation($mutatedContract, ['Identity' => 'rule-1']))->toMatchArray([
|
||||||
|
'accepted' => false,
|
||||||
|
'reason_code' => 'invalid_command_contract',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Spec430 rejects mutated Exchange PowerShell source contract safety metadata', function (string $key, mixed $value): void {
|
||||||
|
$contracts = new ExchangePowerShellCommandContracts;
|
||||||
|
$contract = $contracts->contractForCanonicalType('transportRule');
|
||||||
|
$mutatedContract = [
|
||||||
|
...$contract,
|
||||||
|
$key => $value,
|
||||||
|
];
|
||||||
|
|
||||||
|
expect($contracts->validateInvocation($mutatedContract))->toMatchArray([
|
||||||
|
'accepted' => false,
|
||||||
|
'reason_code' => 'invalid_command_contract',
|
||||||
|
]);
|
||||||
|
})->with([
|
||||||
|
'provider calls enabled' => ['provider_calls_allowed', true],
|
||||||
|
'execution enabled' => ['execution_enabled', true],
|
||||||
|
'source surface changed' => ['source_surface', 'shell'],
|
||||||
|
'contract version changed' => ['command_contract_version', 'evil'],
|
||||||
|
'response shape stripped' => ['response_shape', []],
|
||||||
|
'claim boundary promoted' => ['claim_boundary', ['customer_claims_allowed' => true]],
|
||||||
|
'unexpected extra field' => ['unexpected_extra_field', 'unsafe'],
|
||||||
|
]);
|
||||||
|
|
||||||
it('Spec430 fake runner accepts structured contracts only and never executes shell or provider calls', function (): void {
|
it('Spec430 fake runner accepts structured contracts only and never executes shell or provider calls', function (): void {
|
||||||
$contracts = new ExchangePowerShellCommandContracts;
|
$contracts = new ExchangePowerShellCommandContracts;
|
||||||
$runner = new Spec430ExchangePowerShellFakeCommandRunner($contracts);
|
$runner = new Spec430ExchangePowerShellFakeCommandRunner($contracts);
|
||||||
|
|||||||
@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\Providers\ProviderOperationRegistry;
|
||||||
|
use App\Services\TenantConfiguration\ExchangePowerShellInvocationGate;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\Operations\OperationRunCapabilityResolver;
|
||||||
|
use App\Support\Providers\Capabilities\ProviderCapabilityRegistry;
|
||||||
|
|
||||||
|
it('Spec431 registers one internal Exchange PowerShell invocation operation boundary', function (): void {
|
||||||
|
$providerOperations = app(ProviderOperationRegistry::class);
|
||||||
|
$providerCapabilities = app(ProviderCapabilityRegistry::class);
|
||||||
|
|
||||||
|
expect(OperationRunType::TenantConfigurationExchangePowerShellInvocation->value)
|
||||||
|
->toBe('tenant_configuration.exchange_powershell_invocation')
|
||||||
|
->and(OperationRunType::values())->toContain('tenant_configuration.exchange_powershell_invocation')
|
||||||
|
->and(OperationCatalog::canonicalCode('tenant_configuration.exchange_powershell_invocation'))
|
||||||
|
->toBe('tenant_configuration.exchange_powershell_invocation')
|
||||||
|
->and(OperationCatalog::label('tenant_configuration.exchange_powershell_invocation'))
|
||||||
|
->toBe('Exchange PowerShell invocation')
|
||||||
|
->and(app(OperationRunCapabilityResolver::class)->requiredExecutionCapabilityForType('tenant_configuration.exchange_powershell_invocation'))
|
||||||
|
->toBe(Capabilities::PROVIDER_RUN);
|
||||||
|
|
||||||
|
expect($providerOperations->get('tenant_configuration.exchange_powershell_invocation'))
|
||||||
|
->toMatchArray([
|
||||||
|
'operation_type' => 'tenant_configuration.exchange_powershell_invocation',
|
||||||
|
'module' => 'exchange_powershell',
|
||||||
|
'required_capability' => Capabilities::PROVIDER_RUN,
|
||||||
|
'provider_capability_keys' => ['exchange_powershell_invoke'],
|
||||||
|
])
|
||||||
|
->and($providerOperations->bindingFor('tenant_configuration.exchange_powershell_invocation', 'microsoft')['binding_status'])
|
||||||
|
->toBe(ProviderOperationRegistry::BINDING_ACTIVE)
|
||||||
|
->and($providerCapabilities->get('exchange_powershell_invoke')->operationTypes)
|
||||||
|
->toBe(['tenant_configuration.exchange_powershell_invocation'])
|
||||||
|
->and($providerCapabilities->get('exchange_powershell_invoke')->providerRequirementKeys)
|
||||||
|
->toBe(['provider.exchange_powershell_invocation', 'permissions.admin_consent']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Spec431 defaults the invocation feature gate off', function (): void {
|
||||||
|
putenv('TENANTPILOT_EXCHANGE_POWERSHELL_INVOCATION_ENABLED');
|
||||||
|
unset($_ENV['TENANTPILOT_EXCHANGE_POWERSHELL_INVOCATION_ENABLED'], $_SERVER['TENANTPILOT_EXCHANGE_POWERSHELL_INVOCATION_ENABLED']);
|
||||||
|
|
||||||
|
$config = require config_path('tenantpilot.php');
|
||||||
|
|
||||||
|
expect($config['features']['exchange_powershell_invocation'])->toBeFalse()
|
||||||
|
->and(ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH)
|
||||||
|
->toBe('tenantpilot.features.exchange_powershell_invocation');
|
||||||
|
});
|
||||||
@ -23,10 +23,22 @@
|
|||||||
'details' => null,
|
'details' => null,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$exchangePowerShellRow = [
|
||||||
|
'key' => 'Exchange.ManageAsApp',
|
||||||
|
'type' => 'application',
|
||||||
|
'description' => null,
|
||||||
|
'features' => ['exchange-powershell-invocation'],
|
||||||
|
'status' => 'missing',
|
||||||
|
'details' => null,
|
||||||
|
];
|
||||||
|
|
||||||
expect(ManagedEnvironmentPermissionCheckClusters::requirementKeysForPermissionRow($roleDefinitionRow))
|
expect(ManagedEnvironmentPermissionCheckClusters::requirementKeysForPermissionRow($roleDefinitionRow))
|
||||||
->toContain('provider.directory_role_definitions', 'permissions.admin_consent')
|
->toContain('provider.directory_role_definitions', 'permissions.admin_consent')
|
||||||
->and(ManagedEnvironmentPermissionCheckClusters::requirementKeysForPermissionRow($groupRow))
|
->and(ManagedEnvironmentPermissionCheckClusters::requirementKeysForPermissionRow($groupRow))
|
||||||
->toContain('permissions.directory_groups', 'permissions.admin_consent')
|
->toContain('permissions.directory_groups', 'permissions.admin_consent')
|
||||||
|
->and(ManagedEnvironmentPermissionCheckClusters::requirementKeysForPermissionRow($exchangePowerShellRow))
|
||||||
|
->toContain('permissions.admin_consent')
|
||||||
|
->not->toContain('provider.exchange_powershell_invocation')
|
||||||
->and(ManagedEnvironmentPermissionCheckClusters::rowsForRequirementKey([$roleDefinitionRow, $groupRow], 'provider.directory_role_definitions'))
|
->and(ManagedEnvironmentPermissionCheckClusters::rowsForRequirementKey([$roleDefinitionRow, $groupRow], 'provider.directory_role_definitions'))
|
||||||
->toHaveCount(1);
|
->toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,181 @@
|
|||||||
|
# Requirements Checklist: Exchange PowerShell Invocation Operation Registration and Execution Gate
|
||||||
|
|
||||||
|
**Purpose**: Validate Spec 431 preparation and later implementation against operation registration, invocation safety, no-promotion, and no-surface requirements.
|
||||||
|
**Created**: 2026-07-05
|
||||||
|
**Feature**: `specs/431-exchange-powershell-invocation-operation-registration-gate/spec.md`
|
||||||
|
|
||||||
|
## Applicability And Low-Impact Gate
|
||||||
|
|
||||||
|
- [x] CHK001 The change explicitly says no rendered UI surface is affected.
|
||||||
|
- [x] CHK002 The spec, plan, and tasks carry the same no-UI/no-product-surface decision.
|
||||||
|
- [x] CHK029 The spec includes exactly one coherent UI Surface Impact decision: `No UI surface impact`.
|
||||||
|
- [x] CHK037 Browser proof is `N/A - no rendered UI surface changed`.
|
||||||
|
- [x] CHK038 Human Product Sanity is N/A because no product surface changes.
|
||||||
|
- [x] CHK040 Completed historical specs were not rewritten or stripped of implementation history.
|
||||||
|
|
||||||
|
## Spec Package
|
||||||
|
|
||||||
|
- [x] `spec.md` exists.
|
||||||
|
- [x] `plan.md` exists.
|
||||||
|
- [x] `tasks.md` exists.
|
||||||
|
- [x] `checklists/requirements.md` exists.
|
||||||
|
- [x] `implementation-report.md` exists and records implementation validation.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- [x] Only `transportRule`.
|
||||||
|
- [x] Only `remoteDomain`.
|
||||||
|
- [x] Only `inboundConnector`.
|
||||||
|
- [x] Only `Get-TransportRule`.
|
||||||
|
- [x] Only `Get-RemoteDomain`.
|
||||||
|
- [x] Only `Get-InboundConnector`.
|
||||||
|
- [x] No Teams.
|
||||||
|
- [x] No Exchange Admin API.
|
||||||
|
- [x] No live provider execution.
|
||||||
|
- [x] No evidence.
|
||||||
|
- [x] No compare/render.
|
||||||
|
- [x] No certification.
|
||||||
|
- [x] No restore.
|
||||||
|
- [x] No customer claim.
|
||||||
|
- [x] No UI.
|
||||||
|
- [x] No migration.
|
||||||
|
|
||||||
|
## Proportionality
|
||||||
|
|
||||||
|
- [x] New operation type justified.
|
||||||
|
- [x] New catalog entry justified.
|
||||||
|
- [x] New provider operation justified.
|
||||||
|
- [x] New capability justified.
|
||||||
|
- [x] New runner interface justified.
|
||||||
|
- [x] No mini-platform planned.
|
||||||
|
- [x] No new persisted status framework planned.
|
||||||
|
|
||||||
|
## Operation Registration
|
||||||
|
|
||||||
|
- [x] OperationRunType registered.
|
||||||
|
- [x] OperationCatalog registered.
|
||||||
|
- [x] ProviderOperationRegistry registered.
|
||||||
|
- [x] ProviderCapabilityRegistry registered.
|
||||||
|
- [x] OperationRunCapabilityResolver mapping exists.
|
||||||
|
- [x] Tests prove all mappings.
|
||||||
|
|
||||||
|
## Summary Counts
|
||||||
|
|
||||||
|
- [x] Existing allowed keys reused or new keys explicitly added.
|
||||||
|
- [x] SummaryCountsNormalizer preserves emitted keys.
|
||||||
|
- [x] Unknown keys remain dropped.
|
||||||
|
- [x] No raw output in summary counts.
|
||||||
|
- [x] No secrets in summary counts.
|
||||||
|
|
||||||
|
## Failure Sanitization
|
||||||
|
|
||||||
|
- [x] Invocation blocker reasons sanitized.
|
||||||
|
- [x] Invocation failure reasons sanitized.
|
||||||
|
- [x] Unknown failures sanitized.
|
||||||
|
- [x] Secret-like values redacted.
|
||||||
|
- [x] Raw stderr/stdout not persisted.
|
||||||
|
|
||||||
|
## Invocation Gate
|
||||||
|
|
||||||
|
- [x] Verified contract required.
|
||||||
|
- [x] Allowlisted command required.
|
||||||
|
- [x] OperationRun required before fake runner execution.
|
||||||
|
- [x] Provider scope required.
|
||||||
|
- [x] Capability required.
|
||||||
|
- [x] Actor capability is `Capabilities::PROVIDER_RUN` or a spec amendment documents a narrower dedicated capability.
|
||||||
|
- [x] Feature gate `tenantpilot.features.exchange_powershell_invocation` required and default disabled.
|
||||||
|
- [x] Provider identity/credential reference required or fake-runner-only mode documented.
|
||||||
|
- [x] Bypass calls rejected.
|
||||||
|
|
||||||
|
## Runner Safety
|
||||||
|
|
||||||
|
- [x] Structured runner interface.
|
||||||
|
- [x] Fake runner.
|
||||||
|
- [x] Production runner disabled/inert.
|
||||||
|
- [x] Raw command strings rejected.
|
||||||
|
- [x] Mutation commands rejected.
|
||||||
|
- [x] Unknown parameters rejected.
|
||||||
|
- [x] No shell execution in unit tests.
|
||||||
|
- [x] No Microsoft service calls in tests.
|
||||||
|
|
||||||
|
## OperationRun Safety
|
||||||
|
|
||||||
|
- [x] OperationRun created before fake invocation.
|
||||||
|
- [x] Status transitions owned by `OperationRunService`.
|
||||||
|
- [x] Numeric/safe summary counts only.
|
||||||
|
- [x] Safe failure category only.
|
||||||
|
- [x] `provider_connection_id` only in safe context.
|
||||||
|
- [x] No `provider_connection_id` migration.
|
||||||
|
- [x] No raw stdout/stderr.
|
||||||
|
- [x] No raw provider payload.
|
||||||
|
- [x] No secrets.
|
||||||
|
|
||||||
|
## Credential / Provider Scope
|
||||||
|
|
||||||
|
- [x] Provider connection scoped to workspace/environment.
|
||||||
|
- [x] Credential reference source is existing `ProviderIdentityResolver` / `ProviderConnection::credential` / `ProviderCredential` truth, not a new persistence path.
|
||||||
|
- [x] Missing credential reference blocks.
|
||||||
|
- [x] Unsupported credential kind blocks.
|
||||||
|
- [x] Missing permission evidence blocks or stays unvalidated.
|
||||||
|
- [x] Username/password blocked.
|
||||||
|
- [x] No plaintext secret fields.
|
||||||
|
- [x] Capability checks enforced.
|
||||||
|
|
||||||
|
## Shape Validation
|
||||||
|
|
||||||
|
- [x] Structured collection accepted.
|
||||||
|
- [x] Empty collection distinguished from permission failure.
|
||||||
|
- [x] Malformed result blocks.
|
||||||
|
- [x] Missing identity fields block or fail safe.
|
||||||
|
- [x] Text/scalar output fails safe.
|
||||||
|
|
||||||
|
## No Promotion
|
||||||
|
|
||||||
|
- [x] No evidence rows.
|
||||||
|
- [x] No raw evidence payload.
|
||||||
|
- [x] No normalized evidence payload.
|
||||||
|
- [x] No content-backed state.
|
||||||
|
- [x] No comparable state.
|
||||||
|
- [x] No renderable state.
|
||||||
|
- [x] No certified state.
|
||||||
|
- [x] No restore-ready state.
|
||||||
|
- [x] No customer-ready state.
|
||||||
|
|
||||||
|
## Product Surface
|
||||||
|
|
||||||
|
- [x] No routes planned.
|
||||||
|
- [x] No Filament pages planned.
|
||||||
|
- [x] No Livewire components planned.
|
||||||
|
- [x] No navigation planned.
|
||||||
|
- [x] No global search changes planned.
|
||||||
|
- [x] No asset changes planned.
|
||||||
|
- [x] Browser N/A.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- [x] Uses OperationRun architecture.
|
||||||
|
- [x] Uses provider operation/capability architecture.
|
||||||
|
- [x] Uses Coverage v2 source contract architecture.
|
||||||
|
- [x] No `tenant_id`.
|
||||||
|
- [x] No Exchange mini-platform.
|
||||||
|
- [x] No legacy shim.
|
||||||
|
- [x] No fallback reader.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- [x] Focused unit tests pass.
|
||||||
|
- [x] Focused feature tests pass.
|
||||||
|
- [x] Regressions pass.
|
||||||
|
- [x] Pint passes.
|
||||||
|
- [x] `git diff --check` passes.
|
||||||
|
- [x] `git status --short` documented.
|
||||||
|
|
||||||
|
## Review Outcome
|
||||||
|
|
||||||
|
- [x] Review outcome class: implementation gate passed for backend-only slice.
|
||||||
|
- [x] Workflow outcome: keep.
|
||||||
|
- [x] Final note location: `implementation-report.md`.
|
||||||
|
|
||||||
|
## Final Candidate Gate
|
||||||
|
|
||||||
|
PASS for implementation. Runtime checklist items are checked against the implementation report and validation runs.
|
||||||
@ -0,0 +1,150 @@
|
|||||||
|
# Implementation Report: Spec 431 - Exchange PowerShell Invocation Operation Registration and Execution Gate
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Implemented on 2026-07-05. Backend-only operation registration and fake-runner invocation gate are complete for exactly `transportRule`, `remoteDomain`, and `inboundConnector`.
|
||||||
|
|
||||||
|
## Preflight
|
||||||
|
|
||||||
|
- **Active spec**: `specs/431-exchange-powershell-invocation-operation-registration-gate/`
|
||||||
|
- **Implementation branch**: `431-exchange-powershell-invocation-operation-registration-gate-session-1783272254`
|
||||||
|
- **HEAD before implementation**: `9b58a569 feat: add Exchange PowerShell adapter contract slice 1 (#497)`
|
||||||
|
- **Initial dirty state**: untracked active Spec 431 package only.
|
||||||
|
- **Activated skills/gates**: `spec-kit-implementation-loop`, `pest-testing`, `workflows/spec-readiness-gate`, `repo-contracts/workspace-scope-safety`, `repo-contracts/rbac-action-safety`, `repo-contracts/operation-run-truth`, `repo-contracts/provider-freshness-semantics`.
|
||||||
|
- **Hard-gate stop conditions checked**: no live provider execution, no shell/PowerShell process, no evidence, no UI, no migration, no scheduled capture/job, no `tenant_id`, no Exchange mini-platform.
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
Runtime:
|
||||||
|
|
||||||
|
- `apps/platform/app/Support/OperationRunType.php`
|
||||||
|
- `apps/platform/app/Support/OperationCatalog.php`
|
||||||
|
- `apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php`
|
||||||
|
- `apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityRegistry.php`
|
||||||
|
- `apps/platform/app/Services/Providers/ProviderOperationRegistry.php`
|
||||||
|
- `apps/platform/app/Services/Providers/ProviderConnectionResolver.php`
|
||||||
|
- `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`
|
||||||
|
- `apps/platform/app/Services/Providers/ProviderOperationTrustedStarter.php`
|
||||||
|
- `apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityEvaluator.php`
|
||||||
|
- `apps/platform/app/Providers/AppServiceProvider.php`
|
||||||
|
- `apps/platform/config/tenantpilot.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellInvocationGate.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCommandContract.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCommandRunner.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellInvocationContext.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellInvocationResult.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/FakeExchangePowerShellCommandRunner.php`
|
||||||
|
- `apps/platform/app/Services/TenantConfiguration/DisabledExchangePowerShellCommandRunner.php`
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
- `apps/platform/tests/Unit/Providers/ProviderCapabilityRegistryTest.php`
|
||||||
|
- `apps/platform/tests/Feature/Providers/ProviderCapabilityEvaluationTest.php`
|
||||||
|
- `apps/platform/tests/Feature/Providers/ProviderOperationCapabilityGateTest.php`
|
||||||
|
- `apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php`
|
||||||
|
- `apps/platform/tests/Unit/ManagedEnvironmentPermissionCheckClustersTest.php`
|
||||||
|
- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec431ExchangePowerShellOperationRegistrationTest.php`
|
||||||
|
- `apps/platform/tests/Feature/TenantConfiguration/Spec431ExchangePowerShellInvocationGateTest.php`
|
||||||
|
|
||||||
|
Spec artifacts:
|
||||||
|
|
||||||
|
- `specs/431-exchange-powershell-invocation-operation-registration-gate/tasks.md`
|
||||||
|
- `specs/431-exchange-powershell-invocation-operation-registration-gate/implementation-report.md`
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **New abstractions added**: one public gate, one internal trusted provider-operation starter, one command contract DTO, one runner interface, one invocation context DTO, one result DTO, one fake runner, one disabled runner.
|
||||||
|
- **Why needed**: the gate centralizes authorization, provider scope, source-contract state, feature flag, credential policy, operation lifecycle, shape validation, and redaction. The runner interface keeps command execution structured and typed, making raw shell/string invocation impossible at the contract boundary.
|
||||||
|
- **Existing mechanisms extended**: `OperationRunService`, `ProviderOperationStartGate`, `ProviderOperationRegistry`, `ProviderCapabilityRegistry`, `OperationRunCapabilityResolver`, `CoverageSourceContractResolver`, and `ExchangePowerShellCommandContracts`.
|
||||||
|
- **Why no smaller change was sufficient**: wiring the fake runner directly to tests would not prove OperationRun-before-runner ordering, provider scope/capability gates, feature default-off behavior, or bypass prevention.
|
||||||
|
- **Mini-platform avoidance**: no Exchange namespace, no adapter framework, no route/UI/job/migration/evidence writer, no provider-client dependency, no Teams or wider Exchange catalog.
|
||||||
|
|
||||||
|
## Operation Registration Proof
|
||||||
|
|
||||||
|
- **OperationRun type**: `tenant_configuration.exchange_powershell_invocation` added to `OperationRunType`.
|
||||||
|
- **OperationCatalog**: canonical definition and canonical alias added with internal label `Exchange PowerShell invocation`.
|
||||||
|
- **ProviderOperationRegistry**: added `exchange_powershell` module definition requiring `Capabilities::PROVIDER_RUN` and provider capability key `exchange_powershell_invoke`; Microsoft binding explicitly states fake-gated/no-evidence posture.
|
||||||
|
- **ProviderCapabilityRegistry**: added `exchange_powershell_invoke` with `provider.exchange_powershell_invocation` plus `permissions.admin_consent` requirements.
|
||||||
|
- **Execution capability**: `OperationRunCapabilityResolver` maps the invocation type to `Capabilities::PROVIDER_RUN`.
|
||||||
|
- **Feature flag**: `tenantpilot.features.exchange_powershell_invocation`, default `false` through `TENANTPILOT_EXCHANGE_POWERSHELL_INVOCATION_ENABLED`.
|
||||||
|
|
||||||
|
## Invocation Gate Proof
|
||||||
|
|
||||||
|
- **Single public entry point**: `ExchangePowerShellInvocationGate::invoke()`.
|
||||||
|
- **Direct provider-start bypass**: public `ProviderOperationStartGate::start()` always blocks `tenant_configuration.exchange_powershell_invocation` with `ext.exchange_powershell_invocation_gate_required`. The invocation gate uses an explicit internal `ProviderOperationTrustedStarter`, so caller-supplied context cannot unlock this operation through the public start boundary.
|
||||||
|
- **Direct runner bypass**: `ExchangePowerShellCommandRunner::run()` accepts only `ExchangePowerShellCommandContract` and `ExchangePowerShellInvocationContext`. The typed contract is created only after full canonical contract and parameter validation, stores the canonical allowlisted contract rather than caller-mutated arrays, and the runner receives no separate raw parameter array; tests prove raw command strings, raw arrays, mutating contract arrays, mutated safety metadata, unexpected contract fields, and unverified parameters fail before runner execution.
|
||||||
|
- **Spec 430 source gate**: requires `contract_verified_pending_capture`, `adapter_contract_available`, `provider_calls_allowed = false`, and `execution_enabled = false` from `CoverageSourceContractResolver`.
|
||||||
|
- **Target/command gate**: exactly `transportRule`, `remoteDomain`, `inboundConnector`; exactly `Get-TransportRule`, `Get-RemoteDomain`, `Get-InboundConnector`.
|
||||||
|
- **Provider scope**: explicit workspace and managed-environment match on `ProviderConnection`; out-of-workspace/out-of-scope results stay 404.
|
||||||
|
- **RBAC**: actor requires `Capabilities::PROVIDER_RUN`; readonly in-scope members get 403.
|
||||||
|
- **Provider capability**: provider readiness still flows through the internal `ProviderOperationTrustedStarter` and `ProviderCapabilityEvaluator`, using connection-scoped readiness evidence. Fake mode bypasses only provider identity resolution, not scope, consent, provider binding, provider health freshness, or provider capability evidence.
|
||||||
|
- **No shared verification surface promotion**: the Exchange PowerShell permission requirement remains internal to provider capability evaluation and is not registered as a shared `VerificationReport` check or rendered Required Permissions product-surface claim.
|
||||||
|
- **Credential source**: non-fake mode uses existing `ProviderIdentityResolver` / `ProviderConnection::credential` / `ProviderCredential`. Fake mode uses `provider_identity_resolution_mode = fake_runner_bypass` only on the trusted Exchange invocation operation after scope, enabled-state, consent, and provider capability checks; generic provider operation starts still require provider identity.
|
||||||
|
- **Operation lifecycle**: OperationRun is created by the internal provider-operation starter before runner execution; status/outcome transitions are made through `OperationRunService`.
|
||||||
|
|
||||||
|
## Runner, Shape, Redaction, And Failure Proof
|
||||||
|
|
||||||
|
- **Fake runner**: supports success, empty success, auth failure, authorization failure, timeout, throttling, module unavailable, command unavailable, malformed scalar/text payload, and unexpected exception.
|
||||||
|
- **Production runner**: `DisabledExchangePowerShellCommandRunner`, bound by default in `AppServiceProvider`, returns blocked `execution_blocked_runner_disabled`; no shell or Microsoft call exists.
|
||||||
|
- **Summary counts**: only existing keys are emitted: `total`, `processed`, `succeeded`, `failed`, `skipped`, `items`.
|
||||||
|
- **No new summary vocabulary**: `OperationSummaryKeys` unchanged; unknown-key behavior remains handled by `SummaryCountsNormalizer`.
|
||||||
|
- **Failure reasons**: use existing provider reason codes, with local failure codes in sanitized OperationRun context/failure summaries.
|
||||||
|
- **Shape validation**: successful runner payload must be a list of arrays and each non-empty item must include a required or derived identity candidate field. Empty success is valid only from a success envelope.
|
||||||
|
- **Malformed output**: scalar/text output fails safely with `execution_failed_shape_unsafe`.
|
||||||
|
- **Redaction**: OperationRun context stores command/resource metadata, counts, reason codes, and safe contract metadata only; tests assert no raw stdout/stderr/provider payload/transcript/token/secret promotion.
|
||||||
|
|
||||||
|
## No-Promotion / No-Surface Proof
|
||||||
|
|
||||||
|
- **No evidence**: tests assert `TenantConfigurationResource` and `TenantConfigurationResourceEvidence` rows remain absent.
|
||||||
|
- **No payload promotion**: no raw or normalized evidence payload is written.
|
||||||
|
- **No customer/product claim**: no content-backed, comparable, renderable, certified, restore-ready, customer-ready, report, Review Pack, or customer output state is introduced.
|
||||||
|
- **No UI**: no route, Filament page/resource, Livewire component, navigation, global search, asset, or browser surface introduced.
|
||||||
|
- **No migration**: no database migration and no `operation_runs.provider_connection_id` column; provider connection id remains sanitized OperationRun context metadata.
|
||||||
|
- **No tenant_id**: new Spec 431 runtime source is guarded for `tenant_id`, `provider_tenant_id`, and `entra_tenant_id`.
|
||||||
|
- **No mini-platform**: no Exchange-specific service namespace/table/job/legacy shim/fallback reader/dual-write path.
|
||||||
|
|
||||||
|
## Product Surface And Filament Close-Out
|
||||||
|
|
||||||
|
- **Product Surface Impact**: no rendered product surface changed.
|
||||||
|
- **UI Surface Impact**: none.
|
||||||
|
- **Page archetype/surface budgets/Technical Annex/deep-link demotion/canonical status vocabulary**: `N/A - no rendered UI surface changed`.
|
||||||
|
- **Product Surface exceptions**: none.
|
||||||
|
- **Focused browser proof**: `N/A - no rendered UI surface changed`.
|
||||||
|
- **Human Product Sanity**: `N/A - no product surface changed`.
|
||||||
|
- **Visible complexity outcome**: neutral.
|
||||||
|
- **No-legacy posture**: no legacy shim, fallback reader, dual truth, old route, duplicate UI, or mini-platform.
|
||||||
|
- **Livewire v4 compliance**: no Livewire runtime code changed; repo baseline remains Livewire v4.1.4.
|
||||||
|
- **Provider registration location**: no Filament panel provider registration changed; Laravel panel providers remain in `apps/platform/bootstrap/providers.php`. One service binding was added in `AppServiceProvider`.
|
||||||
|
- **Global search**: no Filament resource/global search surface changed.
|
||||||
|
- **Destructive/high-impact actions**: none added.
|
||||||
|
- **Asset strategy**: no assets registered; `filament:assets` is not required for this slice.
|
||||||
|
- **Deployment impact**: no migrations, queues, scheduler, storage, assets, or Dokploy/container runtime change. New env flag is optional and defaults disabled.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Successful:
|
||||||
|
|
||||||
|
- `cd apps/platform && ./vendor/bin/pint --dirty`
|
||||||
|
- `cd apps/platform && php artisan test --filter=Spec431 --compact`
|
||||||
|
Result: 30 passed, 416 assertions.
|
||||||
|
- `cd apps/platform && php artisan test tests/Unit/Support/TenantConfiguration/Spec430ExchangePowerShellCommandAllowlistTest.php --compact`
|
||||||
|
Result: 28 passed, 120 assertions.
|
||||||
|
- `cd apps/platform && php artisan test tests/Unit/Providers/ProviderOperationStartGateTest.php tests/Unit/Support/TenantConfiguration/Spec430ExchangePowerShellCommandAllowlistTest.php --compact`
|
||||||
|
Result: 39 passed, 220 assertions.
|
||||||
|
- `cd apps/platform && php artisan test tests/Unit/Providers/ProviderCapabilityRegistryTest.php tests/Unit/Providers/ProviderOperationRegistryCanonicalTypeTest.php tests/Feature/TenantConfiguration/Spec430ExchangePowerShellNoPromotionTest.php tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoMiniPlatformTest.php tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoEvidencePromotionTest.php tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoTenantIdTest.php tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php tests/Feature/TenantConfiguration/Spec419M365RegistryExpansionTest.php --compact`
|
||||||
|
Result: 20 passed, 1 PostgreSQL-only Spec 419 check skipped, 688 assertions.
|
||||||
|
- `cd apps/platform && php artisan test tests/Feature/Providers/ProviderCapabilityEvaluationTest.php tests/Feature/Providers/ProviderOperationCapabilityGateTest.php tests/Unit/Providers/ProviderOperationStartGateTest.php tests/Unit/ManagedEnvironmentPermissionCheckClustersTest.php tests/Unit/Providers/Spec394ProviderReadinessResolverTest.php tests/Unit/Support/TenantConfiguration/Spec430ExchangePowerShellCommandAllowlistTest.php tests/Unit/Verification/ManagedEnvironmentPermissionCapabilityMappingTest.php --compact`
|
||||||
|
Result: 58 passed, 299 assertions.
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
Attempted / noted:
|
||||||
|
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec431 --compact` was killed by signal 9 before producing test results. Non-Docker fallback was used afterward.
|
||||||
|
- The previously noted adjacent provider-start fixture failures were resolved; the focused provider capability/start/freshness suite now passes in non-Docker PHP.
|
||||||
|
|
||||||
|
## Deferred Work
|
||||||
|
|
||||||
|
- Production Exchange PowerShell execution hardening remains out of scope.
|
||||||
|
- Evidence capture/promotion for Exchange remains out of scope and should be a separate spec after runtime/permission proof.
|
||||||
|
- Teams invocation/evidence work remains separate.
|
||||||
|
- No staging/production feature enablement is included; the feature flag defaults disabled.
|
||||||
@ -0,0 +1,287 @@
|
|||||||
|
# Implementation Plan: Exchange PowerShell Invocation Operation Registration and Execution Gate
|
||||||
|
|
||||||
|
**Branch**: `431-exchange-powershell-invocation-operation-registration-gate` | **Date**: 2026-07-05 | **Spec**: `specs/431-exchange-powershell-invocation-operation-registration-gate/spec.md`
|
||||||
|
**Input**: Feature specification from `specs/431-exchange-powershell-invocation-operation-registration-gate/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Implement a narrow OperationRun/provider operation registration and fake-runner invocation gate for the three Exchange PowerShell contracts introduced by Spec 430: `transportRule`, `remoteDomain`, and `inboundConnector`. The implementation registers the canonical operation path, provider operation, provider capability, capability resolver mapping, safe summaries, sanitized failures, one invocation gate, a structured runner interface, and a fake runner. Production execution remains disabled/inert. No live Exchange calls, evidence, UI, migrations, jobs, scheduled capture, customer claims, or `tenant_id` are allowed.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4 / Laravel 12
|
||||||
|
**Primary Dependencies**: Filament v5 / Livewire v4 baseline remains unchanged; no UI code planned
|
||||||
|
**Storage**: PostgreSQL via existing `operation_runs` table only; no migration
|
||||||
|
**Testing**: Pest 4 focused unit and feature tests
|
||||||
|
**Validation Lanes**: fast-feedback for unit/feature; selected regressions for Spec 430/426/427/417/419/420; browser N/A
|
||||||
|
**Target Platform**: Laravel Sail local, Dokploy staging/production remains unaffected because production execution is disabled
|
||||||
|
**Project Type**: Laravel monolith under `apps/platform`
|
||||||
|
**Performance Goals**: No real provider work; fake-runner tests complete locally without network or shell execution
|
||||||
|
**Constraints**: No raw provider output persistence, no PowerShell process, no Microsoft calls, no UI, no migrations
|
||||||
|
**Scale/Scope**: Exactly three Exchange target types and one invocation operation
|
||||||
|
|
||||||
|
## Preflight Findings
|
||||||
|
|
||||||
|
- Current prep branch before Spec Kit execution: `platform-dev`.
|
||||||
|
- HEAD before Spec Kit execution: `9b58a569 feat: add Exchange PowerShell adapter contract slice 1 (#497)`.
|
||||||
|
- Initial dirty state before Spec Kit execution: clean.
|
||||||
|
- Spec Kit helper created branch: `431-exchange-powershell-invocation-operation-registration-gate`.
|
||||||
|
- Existing related packages: `specs/429-exchange-teams-source-surface-catalog-adapter-strategy/`, `specs/430-exchange-powershell-adapter-contract-slice-1/`.
|
||||||
|
- Spec 430 implementation report proves the three command contracts and no OperationRun invocation/evidence/UI/migration.
|
||||||
|
- Existing OperationRun type for generic capture: `tenant_configuration.capture`; no Exchange PowerShell invocation type exists.
|
||||||
|
- Existing operation catalog has `tenant_configuration.capture`; no invocation entry exists.
|
||||||
|
- Existing provider operation registry has provider connection, inventory, compliance snapshot, restore, directory groups, and role definitions entries; no Exchange PowerShell invocation entry exists.
|
||||||
|
- Existing provider capability registry has provider connection, inventory, configuration, restore, directory groups, and role definitions capabilities; no Exchange PowerShell invocation capability exists.
|
||||||
|
- Existing summary keys are fixed in `OperationSummaryKeys::all()` and unknown keys are dropped by `SummaryCountsNormalizer`.
|
||||||
|
- Existing `ProviderOperationStartGate` stores `provider_connection_id` in OperationRun context and validates provider bindings/capabilities; implementation should reuse or compose with this path where practical.
|
||||||
|
- Existing actor capability registry includes `App\Support\Auth\Capabilities::PROVIDER_RUN`; Spec 431 reuses it instead of adding a new actor/RBAC capability.
|
||||||
|
- Existing provider credentials are represented through `ProviderConnection::credential`, `ProviderCredential`, `CredentialManager`, and `ProviderIdentityResolver`; Spec 431 must use that path and must not invent a credential reference store.
|
||||||
|
- Existing feature flags live under `tenantpilot.features.*`; Spec 431 uses `tenantpilot.features.exchange_powershell_invocation`, default `false`.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: no operator-facing surface change.
|
||||||
|
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: N/A.
|
||||||
|
- **No-impact class, if applicable**: backend-only operation registration/gate.
|
||||||
|
- **Native vs custom classification summary**: N/A.
|
||||||
|
- **Shared-family relevance**: OperationRun and provider operation shared contracts only; no rendered UI shared family.
|
||||||
|
- **State layers in scope**: OperationRun lifecycle and summary/failure state only.
|
||||||
|
- **Audience modes in scope**: internal operator/reviewer only; no customer surface.
|
||||||
|
- **Decision/diagnostic/raw hierarchy plan**: No product view. Raw provider output is forbidden from persistence.
|
||||||
|
- **Raw/support gating plan**: Raw output not stored.
|
||||||
|
- **One-primary-action / duplicate-truth control**: N/A.
|
||||||
|
- **Handling modes by drift class or surface**: N/A.
|
||||||
|
- **Repository-signal treatment**: no rendered UI signal.
|
||||||
|
- **Special surface test profiles**: N/A.
|
||||||
|
- **Required tests or manual smoke**: `N/A - no rendered UI surface changed`.
|
||||||
|
- **Exception path and spread control**: none.
|
||||||
|
- **Active feature PR close-out entry**: no rendered product surface changed.
|
||||||
|
- **UI/Productization coverage decision**: No UI surface impact.
|
||||||
|
- **Coverage artifacts to update**: none.
|
||||||
|
- **No-impact rationale**: The spec forbids routes, Filament pages, Livewire components, navigation, global search, and assets.
|
||||||
|
- **Navigation / Filament provider-panel handling**: no panel/provider registration changes.
|
||||||
|
- **Screenshot or page-report need**: no.
|
||||||
|
|
||||||
|
## Product Surface Contract Plan
|
||||||
|
|
||||||
|
- **Product Surface Contract reference**: `docs/product/standards/product-surface-contract.md`.
|
||||||
|
- **No-legacy posture**: canonical addition only; no compatibility aliases, fallback readers, hidden routes, duplicate UI, old labels, or historical fixtures.
|
||||||
|
- **Page archetype and surface budget plan**: N/A.
|
||||||
|
- **Technical Annex and deep-link demotion plan**: N/A - no rendered product surface changed. OperationRun remains internal/audit truth.
|
||||||
|
- **Canonical status vocabulary plan**: N/A.
|
||||||
|
- **Product Surface exceptions**: none.
|
||||||
|
- **Browser verification plan**: `N/A - no rendered UI surface changed`.
|
||||||
|
- **Human Product Sanity plan**: N/A.
|
||||||
|
- **Visible complexity outcome target**: neutral.
|
||||||
|
- **Implementation report target**: `specs/431-exchange-powershell-invocation-operation-registration-gate/implementation-report.md`.
|
||||||
|
|
||||||
|
## Filament / Livewire / Deployment Posture
|
||||||
|
|
||||||
|
- **Livewire v4 compliance**: repo baseline remains Livewire v4; no Livewire runtime code planned.
|
||||||
|
- **Panel provider registration location**: no panel/provider change; Laravel providers remain in `apps/platform/bootstrap/providers.php`.
|
||||||
|
- **Global search posture**: no Filament resource/global search surface changed.
|
||||||
|
- **Destructive/high-impact action posture**: none added.
|
||||||
|
- **Asset strategy**: no assets; `filament:assets` is not required for this slice.
|
||||||
|
- **Testing plan**: no pages/widgets/relation managers/actions/browser tests because no rendered UI surface changes.
|
||||||
|
- **Deployment impact**: no env vars, migrations, queues, scheduler, storage, assets, container, or Dokploy changes. Production execution remains disabled/inert.
|
||||||
|
|
||||||
|
## Shared Pattern & System Fit
|
||||||
|
|
||||||
|
- **Cross-cutting feature marker**: yes.
|
||||||
|
- **Systems touched**: OperationRun type/catalog/lifecycle, provider operation registry, provider capability registry, capability resolver, summary normalization, failure sanitizer, provider operation start/gate path, Coverage v2 source contract resolver/contracts.
|
||||||
|
- **Shared abstractions reused**: `OperationRunService`, `OperationCatalog`, `ProviderOperationRegistry`, `ProviderCapabilityRegistry`, `OperationRunCapabilityResolver`, `OperationSummaryKeys`, `SummaryCountsNormalizer`, `RunFailureSanitizer`, `CoverageSourceContractResolver`, `ExchangePowerShellCommandContracts`.
|
||||||
|
- **New abstraction introduced? why?**: One invocation gate and one runner interface are allowed because provider/shell invocation must be centrally blocked, structured, and fake-testable before evidence work can safely use it.
|
||||||
|
- **Why the existing abstraction was sufficient or insufficient**: Existing registries and OperationRun lifecycle are sufficient foundations but lack this operation/capability/runner boundary.
|
||||||
|
- **Actor capability path**: Reuse `Capabilities::PROVIDER_RUN` for operation execution. If implementation proves that capability is too broad, stop and amend the spec before adding a new actor capability or role mapping.
|
||||||
|
- **Public gate boundary**: `ExchangePowerShellInvocationGate` is the sole public invocation entry point. It may call `ProviderOperationStartGate` internally for provider binding/capability/scope behavior; feature code and tests must not directly call the runner or provider start gate for this invocation.
|
||||||
|
- **Bounded deviation / spread control**: No parallel provider framework. If a production runner class is introduced, it must be inert and blocked by default.
|
||||||
|
|
||||||
|
## OperationRun UX Impact
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: yes, backend OperationRun creation/lifecycle only.
|
||||||
|
- **Central contract reused**: `OperationRunService` for creation/transition; `OperationSummaryKeys` and `SummaryCountsNormalizer`; `RunFailureSanitizer`.
|
||||||
|
- **Delegated UX behaviors**: no toast, run link, artifact link, browser event, queued DB notification, or surface messaging.
|
||||||
|
- **Surface-owned behavior kept local**: none.
|
||||||
|
- **Queued DB-notification policy**: N/A.
|
||||||
|
- **Terminal notification path**: central lifecycle mechanism where applicable; no feature-local notification.
|
||||||
|
- **Exception path**: none.
|
||||||
|
|
||||||
|
## Provider Boundary & Portability Fit
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: yes.
|
||||||
|
- **Provider-owned seams**: Exchange PowerShell command names, command contracts, response shape, command safety, fake-runner outcome vocabulary.
|
||||||
|
- **Platform-core seams**: OperationRun type/catalog, provider operation registry, provider capability registry, capability resolver, summary/failure sanitizer, provider connection context.
|
||||||
|
- **Neutral platform terms / contracts preserved**: operation, provider connection, capability, managed environment, workspace, source contract, summary counts, failure reason, runner mode.
|
||||||
|
- **Retained provider-specific semantics and why**: Exchange command names stay inside provider-owned contracts because Spec 431 is explicitly Exchange PowerShell only.
|
||||||
|
- **Bounded extraction or follow-up path**: document-in-feature; production runner hardening and evidence promotion remain separate specs.
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
- Inventory-first: no inventory or evidence truth is changed.
|
||||||
|
- Read/write separation: no restore/apply/mutation provider action; only read-only command contracts are fake-invoked.
|
||||||
|
- Graph contract path: no Microsoft Graph call path changed; no Graph call added.
|
||||||
|
- Deterministic capabilities: provider/actor capability mappings must be central and tested.
|
||||||
|
- Actor capability: the invocation operation maps to existing `Capabilities::PROVIDER_RUN`; no new actor capability is planned without a spec amendment.
|
||||||
|
- RBAC-UX: non-member workspace/environment scope is 404; member without capability is 403; readonly cannot invoke.
|
||||||
|
- Workspace isolation: workspace and managed environment are mandatory inputs.
|
||||||
|
- Tenant isolation: managed-environment entitlement is enforced before provider connection disclosure.
|
||||||
|
- Feature gate: logical key `tenant_configuration.exchange_powershell_invocation` is implemented via `tenantpilot.features.exchange_powershell_invocation` and defaults to false.
|
||||||
|
- Credential source: non-fake mode uses existing `ProviderConnection::credential` / `ProviderCredential` / `ProviderIdentityResolver` truth and blocks safely when no repo-supported credential reference exists.
|
||||||
|
- Run observability: invocation attempts are OperationRun-owned before fake runner execution.
|
||||||
|
- OperationRun start UX: no rendered start surface; lifecycle/summary/failure paths stay central.
|
||||||
|
- Ops-UX 3-surface feedback: no new UI feedback or notification policy.
|
||||||
|
- Ops-UX lifecycle: status/outcome transitions only through `OperationRunService`.
|
||||||
|
- Ops-UX summary counts: allowed keys only, flat numeric values only.
|
||||||
|
- Data minimization: OperationRun context stores safe identifiers and runner mode only; no raw output, credentials, tokens, transcripts, or payloads.
|
||||||
|
- Test governance: focused unit/feature tests with fake runners; no browser lane; no heavy shared fixtures by default.
|
||||||
|
- Proportionality: one operation/capability/gate/runner boundary justified by provider credential safety, RBAC, and operation truth.
|
||||||
|
- No premature abstraction: no generic PowerShell platform or multi-provider framework.
|
||||||
|
- Persisted truth: no new tables or persisted evidence truth.
|
||||||
|
- Behavioral state: blocker/failure codes must alter execution handling and sanitize operator diagnosis.
|
||||||
|
- Provider boundary: Exchange semantics remain provider-owned and do not become platform-core ownership truth.
|
||||||
|
- V1 explicitness / few layers: narrow explicit mapping and one gate preferred.
|
||||||
|
- Spec discipline / bloat check: proportionality review included in `spec.md`.
|
||||||
|
- Filament-native UI: N/A - no UI.
|
||||||
|
- Product Surface Contract: N/A - no rendered surface changed.
|
||||||
|
|
||||||
|
## Test Governance Check
|
||||||
|
|
||||||
|
- **Test purpose / classification by changed surface**: Unit for registries/summary/failure/runner shape; Feature for OperationRun/gate/provider scope/no evidence/no UI/no tenant_id.
|
||||||
|
- **Affected validation lanes**: fast-feedback/confidence focused tests; selected regressions; browser N/A.
|
||||||
|
- **Why this lane mix is the narrowest sufficient proof**: The behavior is backend registry/gate/service behavior with fake runner and DB OperationRun assertions; no rendered UI or PostgreSQL-specific migration exists.
|
||||||
|
- **Narrowest proving command(s)**:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec431 --compact`
|
||||||
|
- selected Spec 430/426/427/417/419/420 regression files or filters documented by implementation
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- `git diff --check`
|
||||||
|
- **Fixture / helper / factory / seed / context cost risks**: Provider connection/workspace/managed-environment/actor setup required for feature tests; keep helper setup explicit and local.
|
||||||
|
- **Expensive defaults or shared helper growth introduced?**: no; fake runner should be local to Spec 431 tests unless later specs adopt it intentionally.
|
||||||
|
- **Heavy-family additions, promotions, or visibility changes**: none planned.
|
||||||
|
- **Surface-class relief / special coverage rule**: backend-only; browser `N/A - no rendered UI surface changed`.
|
||||||
|
- **Closing validation and reviewer handoff**: implementation report must list exact tests and pass/fail counts.
|
||||||
|
- **Budget / baseline / trend follow-up**: none unless implementation materially expands regression lanes.
|
||||||
|
- **Review-stop questions**: verify no shell execution, no Microsoft calls, no evidence, no raw output persistence, no UI, no migration, no `tenant_id`, no mini-platform.
|
||||||
|
- **Escalation path**: reject-or-split if implementation attempts live provider execution, evidence promotion, UI, migrations, or broader Exchange types.
|
||||||
|
- **Active feature PR close-out entry**: no rendered UI surface changed; no deployment impact.
|
||||||
|
- **Why no dedicated follow-up spec is needed**: Routine test upkeep stays inside Spec 431; production runner hardening/evidence promotion are already separate follow-up candidates.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/431-exchange-powershell-invocation-operation-registration-gate/
|
||||||
|
|-- spec.md
|
||||||
|
|-- plan.md
|
||||||
|
|-- tasks.md
|
||||||
|
|-- checklists/
|
||||||
|
| `-- requirements.md
|
||||||
|
`-- implementation-report.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (likely affected by later implementation)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/app/Support/OperationRunType.php
|
||||||
|
apps/platform/app/Support/OperationCatalog.php
|
||||||
|
apps/platform/app/Services/Providers/ProviderOperationRegistry.php
|
||||||
|
apps/platform/app/Services/Providers/ProviderOperationStartGate.php
|
||||||
|
apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityRegistry.php
|
||||||
|
apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php
|
||||||
|
apps/platform/app/Support/Auth/Capabilities.php
|
||||||
|
apps/platform/app/Support/OpsUx/OperationSummaryKeys.php
|
||||||
|
apps/platform/app/Support/OpsUx/SummaryCountsNormalizer.php
|
||||||
|
apps/platform/app/Support/OpsUx/RunFailureSanitizer.php
|
||||||
|
apps/platform/app/Models/ProviderConnection.php
|
||||||
|
apps/platform/app/Models/ProviderCredential.php
|
||||||
|
apps/platform/app/Services/Providers/ProviderIdentityResolver.php
|
||||||
|
apps/platform/app/Services/Providers/CredentialManager.php
|
||||||
|
apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCommandContracts.php
|
||||||
|
apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php
|
||||||
|
apps/platform/app/Services/TenantConfiguration/ExchangePowerShellInvocationGate.php
|
||||||
|
apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCommandRunner.php
|
||||||
|
apps/platform/app/Services/TenantConfiguration/FakeExchangePowerShellCommandRunner.php
|
||||||
|
apps/platform/config/tenantpilot.php
|
||||||
|
apps/platform/tests/Unit/Support/TenantConfiguration/
|
||||||
|
apps/platform/tests/Feature/TenantConfiguration/
|
||||||
|
```
|
||||||
|
|
||||||
|
Forbidden source changes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/routes/**
|
||||||
|
apps/platform/resources/views/**
|
||||||
|
apps/platform/app/Filament/**
|
||||||
|
apps/platform/app/Livewire/**
|
||||||
|
apps/platform/database/migrations/**
|
||||||
|
Exchange-specific evidence tables
|
||||||
|
jobs for live provider execution
|
||||||
|
provider clients that call Microsoft
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 0 - Preflight
|
||||||
|
|
||||||
|
- Capture branch, HEAD, dirty state.
|
||||||
|
- Reconfirm Spec 430 contract state and no pending checklist blocker.
|
||||||
|
- Reconfirm target types and command names.
|
||||||
|
- Reconfirm no migration, no UI, no evidence, no live execution, no `tenant_id`.
|
||||||
|
|
||||||
|
### Phase 1 - Operation and Provider Registration
|
||||||
|
|
||||||
|
- Add canonical OperationRun type.
|
||||||
|
- Add OperationCatalog entry and alias inventory entry only if required by repo pattern.
|
||||||
|
- Add ProviderOperationRegistry definition and Microsoft active binding or explicit internal binding.
|
||||||
|
- Add ProviderCapabilityRegistry capability.
|
||||||
|
- Add OperationRunCapabilityResolver execution mapping.
|
||||||
|
|
||||||
|
### Phase 2 - Summary and Failure Plumbing
|
||||||
|
|
||||||
|
- Reuse `total`, `processed`, `succeeded`, `failed`, `skipped`, and `items` unless implementation proves a new key is necessary.
|
||||||
|
- Extend failure normalization only with safe bounded mappings or known provider reason codes.
|
||||||
|
- Test known/unknown failures and redaction.
|
||||||
|
|
||||||
|
### Phase 3 - Invocation Gate
|
||||||
|
|
||||||
|
- Add single gate service.
|
||||||
|
- Compose with existing provider operation/capability/scope gates where practical.
|
||||||
|
- Require verified Spec 430 contracts, allowlisted commands, parameters, actor capability `Capabilities::PROVIDER_RUN`, provider scope, feature gate `tenantpilot.features.exchange_powershell_invocation`, runner mode, redaction policy, and existing provider credential/identity resolution unless fake-runner-only.
|
||||||
|
- Create OperationRun before fake runner execution.
|
||||||
|
|
||||||
|
### Phase 4 - Runner Boundary
|
||||||
|
|
||||||
|
- Add structured runner interface.
|
||||||
|
- Add fake runner and structured envelopes.
|
||||||
|
- Add disabled/inert production runner only if needed.
|
||||||
|
- Reject raw/mutation/arbitrary command paths.
|
||||||
|
|
||||||
|
### Phase 5 - No-Promotion Safety
|
||||||
|
|
||||||
|
- Prove no evidence rows, no coverage promotion, no compare/render/certification/restore/customer state, no customer output, no UI/routes/assets/global search, no migration, no `tenant_id`, no mini-platform.
|
||||||
|
|
||||||
|
### Phase 6 - Validation and Report
|
||||||
|
|
||||||
|
- Run focused Spec 431 tests.
|
||||||
|
- Run selected regressions.
|
||||||
|
- Run Pint and `git diff --check`.
|
||||||
|
- Complete implementation report with proportionality, proof, no-surface, and deployment close-out.
|
||||||
|
|
||||||
|
## Risk Controls
|
||||||
|
|
||||||
|
- Stop if implementation needs a migration.
|
||||||
|
- Stop if implementation needs live provider execution.
|
||||||
|
- Stop if a production runner would call PowerShell.
|
||||||
|
- Stop if evidence persistence is required.
|
||||||
|
- Stop if UI/routes/jobs/scheduled capture are introduced.
|
||||||
|
- Stop if new summary keys are emitted without registration/tests.
|
||||||
|
- Stop if provider/capability resolution cannot fail closed.
|
||||||
|
- Stop if `Capabilities::PROVIDER_RUN` proves too broad and a new actor capability would be required without a spec amendment.
|
||||||
|
- Stop if the feature gate cannot default disabled through `tenantpilot.features.exchange_powershell_invocation`.
|
||||||
|
- Stop if credential material would be persisted or logged.
|
||||||
|
- Stop if credential checks require a new credential persistence path.
|
||||||
|
- Stop if `tenant_id` is introduced.
|
||||||
|
|
||||||
|
## Preparation Analyze Result
|
||||||
|
|
||||||
|
Initial preparation self-review recorded this package as aligned with current repo truth and preparation-only boundaries. Follow-up `/speckit-analyze` remediation clarified actor capability, feature-gate path/default, credential-source truth, and the single public invocation gate boundary. No application code was modified. The repo does not expose executable `speckit.plan`, `speckit.tasks`, or `speckit.analyze` commands; this package was created with the available Spec Kit feature/plan scripts plus manual artifact authoring against the repository templates and prompts.
|
||||||
@ -0,0 +1,398 @@
|
|||||||
|
# Feature Specification: Exchange PowerShell Invocation Operation Registration and Execution Gate
|
||||||
|
|
||||||
|
**Feature Branch**: `431-exchange-powershell-invocation-operation-registration-gate`
|
||||||
|
**Created**: 2026-07-05
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User-provided Spec 431 draft for Exchange PowerShell invocation operation registration, capability gating, and fake-runner proof.
|
||||||
|
|
||||||
|
## Preparation Selection
|
||||||
|
|
||||||
|
- **Selected candidate**: Spec 431 - Exchange PowerShell Invocation Operation Registration and Execution Gate.
|
||||||
|
- **Source location**: Direct user-provided candidate in the July 5, 2026 preparation request.
|
||||||
|
- **Why selected**: The active auto-prep queue in `docs/product/spec-candidates.md` is empty, but the user explicitly supplied a manual candidate that follows completed Spec 430 and narrows the next Exchange PowerShell step to operation registration and gated fake invocation only.
|
||||||
|
- **Roadmap relationship**: Builds on Coverage v2, OperationRun observability, provider operation/capability wiring, and the Spec 429/430 Exchange PowerShell sequence without promoting Exchange evidence or customer claims.
|
||||||
|
- **Completed-spec guardrail result**: Specs 429 and 430 are completed implementation context and were not modified. Spec 430's implementation report proves `transportRule`, `remoteDomain`, and `inboundConnector` command contracts and explicitly leaves OperationRun invocation, provider execution, evidence, UI, migrations, and customer claims out of scope. No existing `specs/431-*` package existed before this preparation.
|
||||||
|
- **Smallest viable implementation slice**: Register one canonical OperationRun invocation type, one provider operation, one internal provider capability, one capability resolver mapping, one gate, and a structured fake-runner path for exactly `transportRule`, `remoteDomain`, and `inboundConnector`.
|
||||||
|
- **Feature description fed into Spec Kit**: Prepare a safe OperationRun-owned Exchange PowerShell invocation path for the three Spec 430 contracts, with provider/capability registration, summary/failure sanitization, fake-runner proof, production execution disabled, no evidence, no UI, no migration, and no customer claims.
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: Spec 430 proves safe command contracts but the operation/provider/capability path does not yet know how to represent or gate an Exchange PowerShell invocation attempt.
|
||||||
|
- **Today's failure**: A later evidence spec would either have to bypass OperationRun/provider registries or invent execution plumbing while also trying to promote evidence, increasing risk of provider calls, raw output persistence, missing capability checks, or false customer readiness claims.
|
||||||
|
- **User-visible improvement**: Operators and reviewers gain an auditable internal truth: an Exchange PowerShell invocation attempt can be represented, started, blocked, failed, and summarized through existing operation/provider guardrails without claiming capture or evidence readiness.
|
||||||
|
- **Smallest enterprise-capable version**: One canonical operation type, one catalog entry, one provider operation, one internal provider capability, one resolver mapping, safe summary/failure plumbing, a single gate service, a structured runner interface, and a fake runner for the three Spec 430 commands.
|
||||||
|
- **Explicit non-goals**: No live Exchange Online execution, no PowerShell process, no Microsoft service calls, no evidence rows, no content-backed/comparable/renderable/certified/restore/customer state, no UI, no routes, no jobs, no scheduled capture, no migration, no `tenant_id`, no Exchange mini-platform.
|
||||||
|
- **Permanent complexity imported**: Adds one OperationRun type, catalog entry, provider operation entry, provider capability entry, capability mapping, invocation gate service, runner interface, fake runner, failure mappings, and focused tests. No new tables, persisted status framework, UI taxonomy, customer claim state, or evidence subsystem.
|
||||||
|
- **Why now**: Spec 430's contract slice is complete and the next safe step before evidence promotion is proving that invocation attempts are OperationRun-owned, capability-gated, provider-scoped, and fake-runner-testable.
|
||||||
|
- **Why not local**: This cannot be only local to a later capture service because remote/provider work must be observable through OperationRun, registered in provider operation/capability registries, sanitized by central OperationRun UX plumbing, and blocked by shared capability/scope gates.
|
||||||
|
- **Approval class**: Core Enterprise.
|
||||||
|
- **Red flags triggered**: New operation type, registry entries, capability mapping, interface, and service. Defense: each extension attaches to an existing repo mechanism required for OperationRun observability, RBAC/provider scope safety, and no-provider-call proof; no new persisted truth or broad platform framework is introduced.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
||||||
|
- **Decision**: approve as a narrow operation-registration and fake-runner gate slice.
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: canonical-view / environment-bound operation start path.
|
||||||
|
- **Primary Routes**: N/A - no routes or rendered UI surfaces.
|
||||||
|
- **Data Ownership**: No new persisted entity. OperationRun rows remain existing operation truth and must use existing `workspace_id` plus `managed_environment_id`; `provider_connection_id` is safe `context` only.
|
||||||
|
- **RBAC**: Workspace and managed-environment membership required; non-member or non-entitled scope returns 404. Member without invocation capability returns 403. Readonly cannot invoke. Provider connection must belong to the same workspace and managed environment.
|
||||||
|
|
||||||
|
For canonical-view specs:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: N/A - no rendered canonical view changed.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Invocation service must require workspace, managed environment, provider connection, actor/context, and operation type; cross-workspace/cross-environment provider connections are rejected before any runner call.
|
||||||
|
|
||||||
|
## No Legacy / No Backward Compatibility Constraint *(mandatory)*
|
||||||
|
|
||||||
|
TenantPilot is pre-production unless this spec explicitly records a compatibility exception.
|
||||||
|
|
||||||
|
- **Compatibility posture**: canonical addition only.
|
||||||
|
- **Legacy aliases, fallback readers, hidden routes, duplicate UI, old labels, or historical fixtures kept?**: no.
|
||||||
|
- **Why clean replacement is safe now**: This is a new operation path for a previously unregistered invocation type. No production data, external API consumer, or legacy operation value depends on it.
|
||||||
|
|
||||||
|
## UI Surface Impact *(mandatory - UI-COV-001)*
|
||||||
|
|
||||||
|
Does this spec add, remove, rename, or materially change any reachable UI surface?
|
||||||
|
|
||||||
|
- [x] No UI surface impact
|
||||||
|
- [ ] Existing page changed
|
||||||
|
- [ ] New page/route added
|
||||||
|
- [ ] Navigation changed
|
||||||
|
- [ ] Filament panel/provider surface changed
|
||||||
|
- [ ] New modal/drawer/wizard/action added
|
||||||
|
- [ ] New table/form/state added
|
||||||
|
- [ ] Customer-facing surface changed
|
||||||
|
- [ ] Dangerous action changed
|
||||||
|
- [ ] Status/evidence/review presentation changed
|
||||||
|
- [ ] Workspace/environment context presentation changed
|
||||||
|
|
||||||
|
## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact"; otherwise write `N/A - no reachable UI surface impact` plus rationale)*
|
||||||
|
|
||||||
|
N/A - no reachable UI surface impact. This spec registers and gates a backend operation path only. It forbids routes, Filament pages, Livewire components, navigation, global search changes, asset changes, execution buttons, capture buttons, dashboard badges, report output, and Review Pack output.
|
||||||
|
|
||||||
|
## Product Surface Impact *(mandatory for UI-affecting specs; otherwise write `N/A - no rendered product surface changed` plus rationale)*
|
||||||
|
|
||||||
|
Reference: `docs/product/standards/product-surface-contract.md`.
|
||||||
|
|
||||||
|
- **Product Surface Contract applies?**: no - no rendered product surface changed.
|
||||||
|
- **Page archetype**: N/A.
|
||||||
|
- **Primary user question**: N/A.
|
||||||
|
- **Primary action**: N/A.
|
||||||
|
- **Surface budget result**: N/A.
|
||||||
|
- **Technical Annex / deep-link demotion**: N/A - no default product view changed. OperationRun remains an internal/audit truth only.
|
||||||
|
- **Canonical status vocabulary**: N/A - no product-facing statuses changed.
|
||||||
|
- **Visible complexity impact**: neutral.
|
||||||
|
- **Product Surface exceptions**: none.
|
||||||
|
|
||||||
|
## Browser Verification Plan *(mandatory)*
|
||||||
|
|
||||||
|
- **Browser proof required?**: no.
|
||||||
|
- **No-browser rationale**: `N/A - no rendered UI surface changed`.
|
||||||
|
- **Focused path when required**: N/A.
|
||||||
|
- **Primary interaction to execute**: N/A.
|
||||||
|
- **Console, Livewire, Filament, network, and 500-error checks**: N/A.
|
||||||
|
- **Full-suite failure triage**: N/A.
|
||||||
|
|
||||||
|
## Human Product Sanity Check *(mandatory)*
|
||||||
|
|
||||||
|
- **Required?**: no.
|
||||||
|
- **No-human-sanity rationale**: N/A - no product surface changed.
|
||||||
|
- **Reviewer questions**: N/A.
|
||||||
|
- **Planned result location**: `implementation-report.md`.
|
||||||
|
|
||||||
|
## Product Surface Merge Gate Checklist *(mandatory)*
|
||||||
|
|
||||||
|
- [x] No-legacy posture or approved exception recorded.
|
||||||
|
- [x] Product Surface Impact is completed or `N/A` is justified.
|
||||||
|
- [x] Browser proof is completed or `N/A - no rendered UI surface changed` is justified.
|
||||||
|
- [x] Human Product Sanity is completed or not applicable with rationale.
|
||||||
|
- [x] Product Surface exceptions are documented or `none`.
|
||||||
|
- [x] Implementation report will state Livewire v4 compliance, provider registration location, global search posture, destructive/high-impact action posture, asset strategy, tests/browser result, deployment impact, and visible complexity outcome.
|
||||||
|
|
||||||
|
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||||
|
|
||||||
|
- **Cross-cutting feature?**: yes.
|
||||||
|
- **Interaction class(es)**: OperationRun lifecycle and operation/provider gate summaries.
|
||||||
|
- **Systems touched**: `OperationRunType`, `OperationCatalog`, `OperationRunService`, `ProviderOperationRegistry`, `ProviderCapabilityRegistry`, `OperationRunCapabilityResolver`, `OperationSummaryKeys`, `SummaryCountsNormalizer`, `RunFailureSanitizer`, `ProviderOperationStartGate` or a composed equivalent.
|
||||||
|
- **Existing pattern(s) to extend**: existing OperationRun architecture, provider operation registry, provider capability registry, OperationRun capability resolver, summary/failure sanitizer path.
|
||||||
|
- **Shared contract / presenter / builder / renderer to reuse**: OperationRun lifecycle and provider operation/capability contracts. No UI presenter is added.
|
||||||
|
- **Why the existing shared path is sufficient or insufficient**: Existing paths provide operation truth, provider binding, summary normalization, and provider connection context. They need one bounded operation/capability addition and a gate because Spec 430 intentionally did not start runs.
|
||||||
|
- **Allowed deviation and why**: none planned. `ExchangePowerShellInvocationGate` is the sole public invocation entry point for Spec 431. It may compose with `ProviderOperationStartGate` internally, but feature code and tests must not call the runner or `ProviderOperationStartGate` directly for this Exchange PowerShell invocation.
|
||||||
|
- **Consistency impact**: Invocation attempts must use existing OperationRun status/outcome transitions, flat numeric summary counts, sanitized reason codes/messages, and provider connection context shape.
|
||||||
|
- **Review focus**: Verify no direct runner invocation, direct shell/process execution, raw PowerShell command strings, or OperationRun status/outcome updates outside `OperationRunService`.
|
||||||
|
|
||||||
|
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: yes, backend OperationRun creation and lifecycle only; no rendered UX surface or deep link is added.
|
||||||
|
- **Shared OperationRun UX contract/layer reused**: Existing OperationRun lifecycle and summary/failure sanitizer path. No local UI messages, toasts, links, or browser events.
|
||||||
|
- **Delegated start/completion UX behaviors**: N/A - no start surface, toast, DB notification policy change, run link, artifact link, or browser event.
|
||||||
|
- **Local surface-owned behavior that remains**: none.
|
||||||
|
- **Queued DB-notification policy**: N/A - no queued DB notification opt-in.
|
||||||
|
- **Terminal notification path**: central lifecycle mechanism if OperationRun reaches terminal status. Tests must prove no feature-local completion notification is introduced.
|
||||||
|
- **Exception required?**: none.
|
||||||
|
|
||||||
|
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: yes.
|
||||||
|
- **Boundary classification**: mixed, with provider-owned Exchange PowerShell command semantics behind platform-core operation/provider registries.
|
||||||
|
- **Seams affected**: provider operation registry, provider capability registry, OperationRun capability resolver, OperationRun context provider connection metadata, Exchange PowerShell command contracts.
|
||||||
|
- **Neutral platform terms preserved or introduced**: operation, provider connection, managed environment, workspace, capability, source contract, runner mode, summary counts, failure reason.
|
||||||
|
- **Provider-specific semantics retained and why**: Exchange command names and target types are retained inside provider-owned command contracts because the current slice is explicitly Exchange PowerShell only.
|
||||||
|
- **Why this does not deepen provider coupling accidentally**: Platform-core registries only receive one operation/capability entry and do not adopt Exchange payloads, command vocabulary, or provider-native tenant identifiers as platform ownership truth.
|
||||||
|
- **Follow-up path**: document-in-feature; production runtime hardening and evidence promotion remain follow-up specs.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||||
|
|
||||||
|
N/A - no operator-facing surface change.
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
Spec 431 introduces structural runtime concepts and must justify each before implementation.
|
||||||
|
|
||||||
|
| Concept | Existing mechanism extended | Why needed now | Narrowness control |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| OperationRunType value | `OperationRunType`, `OperationCatalog` | Invocation attempts need canonical operation truth before any later evidence spec can start work safely. | One value only: `tenant_configuration.exchange_powershell_invocation` unless repo convention forces a documented equivalent. |
|
||||||
|
| Provider operation entry | `ProviderOperationRegistry` | Provider work must be explicitly registered and bound before it can be started or blocked. | One operation mapped to the canonical OperationRun type. |
|
||||||
|
| Provider capability | `ProviderCapabilityRegistry` | Provider readiness and permissions must fail closed and stay internal. | One internal capability such as `exchange_powershell_invoke`; no customer readiness semantics. |
|
||||||
|
| Capability resolver mapping | `OperationRunCapabilityResolver` and `App\Support\Auth\Capabilities::PROVIDER_RUN` | Actor execution authorization must be resolved consistently for the operation. | Reuse the existing provider-run actor capability unless implementation stops and amends the spec for a narrower dedicated actor capability; no new capability framework. |
|
||||||
|
| Summary/failure additions | `OperationSummaryKeys`, `SummaryCountsNormalizer`, `RunFailureSanitizer` | Invocation runs must preserve safe counts and sanitize blockers/failures. | Reuse existing keys by default; add keys only if tests prove unavoidable. |
|
||||||
|
| Invocation gate service | Existing service/action pattern and provider gates | Prevents direct runner calls and centralizes command, scope, feature, credential, and capability gates. | One service only; no Exchange mini-platform. |
|
||||||
|
| Runner interface | Service boundary for structured fake execution | Required to fake test execution outcomes without provider calls or shell execution. | Structured command contract only; no raw command strings or pipelines. |
|
||||||
|
| Fake runner | Test support | Required proof for success/failure/shape paths with no Microsoft calls. | Test-only behavior; no production provider execution. |
|
||||||
|
| Disabled production runner | Optional boundary | Only allowed if it returns blocked by default. | No PowerShell binary, module install, or Exchange Online connection. |
|
||||||
|
|
||||||
|
Answers required by BLOAT-001:
|
||||||
|
|
||||||
|
1. **Current operator problem**: The platform cannot honestly claim an auditable Exchange invocation path exists, and later evidence work would risk bypassing OperationRun/provider safety.
|
||||||
|
2. **Why existing structure is insufficient**: Existing OperationRun/provider registries have no Exchange invocation operation/capability mapping and Spec 430 explicitly disabled OperationRun invocation.
|
||||||
|
3. **Narrowest correct implementation**: Register and fake-prove one internal invocation operation for three already verified command contracts.
|
||||||
|
4. **Ownership cost**: Maintains one operation/capability/gate/runner test surface that future Exchange evidence specs must respect.
|
||||||
|
5. **Rejected alternative**: Directly invoking a runner from a capture service or implementing live Exchange execution in the evidence spec.
|
||||||
|
6. **Current-release truth or future-release preparation**: Current-release safety truth for the next Exchange evidence slice; not customer-facing readiness.
|
||||||
|
|
||||||
|
Forbidden proportionality outcomes:
|
||||||
|
|
||||||
|
- no new persisted status framework
|
||||||
|
- no new operation table family
|
||||||
|
- no Exchange mini-platform
|
||||||
|
- no provider subsystem unrelated to existing registries
|
||||||
|
- no UI surface
|
||||||
|
- no migration for `provider_connection_id`
|
||||||
|
- no evidence persistence
|
||||||
|
- no customer claim state
|
||||||
|
|
||||||
|
## User Stories & Tests *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Registered Operation Path (Priority: P1)
|
||||||
|
|
||||||
|
As a platform reviewer, I need the Exchange PowerShell invocation operation to be registered in OperationRun and provider registries so any future invocation attempt is governed by canonical operation truth instead of ad-hoc service code.
|
||||||
|
|
||||||
|
**Independent Test**: Unit tests prove the OperationRun type exists, OperationCatalog resolves it, ProviderOperationRegistry maps it, ProviderCapabilityRegistry contains the required capability, and OperationRunCapabilityResolver resolves the required actor capability.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** `tenant_configuration.exchange_powershell_invocation`, **When** OperationCatalog resolves it, **Then** it returns a known internal operation label and does not expose customer-facing claims.
|
||||||
|
2. **Given** the provider operation registry, **When** the invocation operation is requested, **Then** it resolves to the canonical OperationRun type and internal provider capability.
|
||||||
|
3. **Given** a member without the required execution capability, **When** the operation is evaluated, **Then** execution fails closed with a sanitized missing-capability blocker.
|
||||||
|
|
||||||
|
### User Story 2 - Gated Fake Invocation (Priority: P1)
|
||||||
|
|
||||||
|
As an implementation reviewer, I need a fake-runner invocation path that creates an OperationRun before execution and proves success, block, failure, and malformed-output behavior without any provider or shell calls.
|
||||||
|
|
||||||
|
**Independent Test**: Feature/unit tests invoke the gate in fake-runner mode for `transportRule`, `remoteDomain`, and `inboundConnector`, proving safe OperationRun context, safe summaries, sanitized failures, and no evidence.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an authorized actor, same-scope provider connection, enabled test feature gate, fake-runner mode, verified Spec 430 contract, and allowlisted command, **When** the gate runs, **Then** an OperationRun is created before runner invocation and only safe summary counts are persisted.
|
||||||
|
2. **Given** a missing credential reference in non-fake mode, **When** invocation is requested, **Then** it is blocked with `execution_blocked_missing_credential_reference` or a repo-equivalent sanitized code before runner execution.
|
||||||
|
3. **Given** malformed scalar/text fake output, **When** shape validation runs, **Then** the operation fails safely as `execution_failed_shape_unsafe` or a repo-equivalent sanitized code and is not treated as empty success.
|
||||||
|
|
||||||
|
### User Story 3 - No Promotion or Product Surface (Priority: P1)
|
||||||
|
|
||||||
|
As a release reviewer, I need the invocation operation to remain internal and no-promotion only so it cannot be interpreted as Exchange evidence readiness.
|
||||||
|
|
||||||
|
**Independent Test**: Guard/feature tests prove no evidence rows, no content-backed/comparable/renderable/certified/restore/customer states, no routes/UI/assets/global search changes, no `tenant_id`, and no raw output/provider payload persisted.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** any successful fake invocation, **When** persistence is inspected, **Then** no `tenant_configuration_resource_evidence` row or normalized evidence payload exists.
|
||||||
|
2. **Given** OperationRun context after a fake invocation, **When** it is inspected, **Then** `provider_connection_id` appears only as sanitized context and no raw stdout/stderr/provider payload/token/secret/certificate material is present.
|
||||||
|
3. **Given** the codebase after implementation, **When** route/UI/Filament/Livewire/assets/search surfaces are scanned, **Then** no rendered product surface changed.
|
||||||
|
|
||||||
|
## Functional Requirements
|
||||||
|
|
||||||
|
- **FR-431-001**: The implementation MUST include exactly `transportRule`, `remoteDomain`, and `inboundConnector` as invocation targets.
|
||||||
|
- **FR-431-002**: The only command contracts in scope MUST be `Get-TransportRule`, `Get-RemoteDomain`, and `Get-InboundConnector`.
|
||||||
|
- **FR-431-003**: The implementation MUST add one canonical OperationRun type, recommended as `tenant_configuration.exchange_powershell_invocation` unless repo convention requires a documented equivalent.
|
||||||
|
- **FR-431-004**: The operation MUST be registered in `OperationCatalog` with internal/operator-only semantics and no customer claim language.
|
||||||
|
- **FR-431-005**: The operation MUST be registered in `ProviderOperationRegistry` and mapped to the canonical OperationRun type.
|
||||||
|
- **FR-431-006**: The implementation MUST add or map one internal provider capability for Exchange PowerShell invocation, recommended as `exchange_powershell_invoke` or repo-canonical equivalent.
|
||||||
|
- **FR-431-007**: `OperationRunCapabilityResolver` MUST resolve the required actor execution capability for the invocation operation to `App\Support\Auth\Capabilities::PROVIDER_RUN`, unless implementation stops and amends this spec for a narrower dedicated actor capability. Unresolved or unknown capability MUST fail closed.
|
||||||
|
- **FR-431-008**: Summary counts MUST use allowed `OperationSummaryKeys` only. Preferred mapping is `total`, `processed`, `succeeded`, `failed`, `skipped`, and `items`.
|
||||||
|
- **FR-431-009**: Tests MUST prove emitted summary keys are preserved and unknown summary keys remain dropped.
|
||||||
|
- **FR-431-010**: Invocation blocker/failure reasons MUST normalize through `RunFailureSanitizer` or a repo-equivalent central path without leaking raw exception text.
|
||||||
|
- **FR-431-011**: The implementation MUST provide one invocation gate service under the repo-canonical TenantConfiguration/provider operation service path.
|
||||||
|
- **FR-431-012**: The gate MUST require verified Spec 430 source contracts, allowlisted commands, contract-defined parameters, a registered OperationRun type, registered provider operation, registered provider capability, actor capability `Capabilities::PROVIDER_RUN`, provider scope, feature gate `tenant_configuration.exchange_powershell_invocation` backed by `tenantpilot.features.exchange_powershell_invocation`, runner mode, redaction policy, and existing provider identity/credential resolution through `ProviderIdentityResolver`, `ProviderConnection::credential`, and `ProviderCredential` unless explicitly in fake-runner-only test mode.
|
||||||
|
- **FR-431-013**: The runner interface MUST accept structured command contracts and structured invocation context only.
|
||||||
|
- **FR-431-014**: The runner interface MUST reject raw command strings, script blocks, pipelines, arbitrary parameters, shell fragments, semicolon-separated commands, redirection, file writes, and module installation commands.
|
||||||
|
- **FR-431-015**: A fake runner MUST support success, empty success, authentication failure, authorization failure, permission failure, timeout, throttling, module unavailable, command unavailable, malformed result, and unexpected exception.
|
||||||
|
- **FR-431-016**: Any production runner boundary MUST be disabled/inert by default and return a sanitized blocked result without PowerShell execution or Microsoft calls.
|
||||||
|
- **FR-431-017**: The implementation MUST create OperationRuns before fake invocation and route status/outcome transitions through `OperationRunService`.
|
||||||
|
- **FR-431-018**: `provider_connection_id` MUST remain in sanitized OperationRun context and MUST NOT be added as an `operation_runs` column.
|
||||||
|
- **FR-431-019**: OperationRun context MUST NOT persist raw provider payload, raw stdout, raw stderr, transcripts, raw exception text, tokens, secrets, cookies, authorization headers, certificate material, mail body, message content, mailbox content, or file content.
|
||||||
|
- **FR-431-020**: Shape validation MUST distinguish structured collections, valid empty collections, permission failures, command/module failures, malformed output, and missing identity candidate fields.
|
||||||
|
- **FR-431-021**: Workspace, managed-environment, and provider-connection scope MUST be enforced server-side; non-member/not-entitled scope is 404 and member-without-capability is 403.
|
||||||
|
- **FR-431-022**: The feature gate for invocation MUST use logical key `tenant_configuration.exchange_powershell_invocation`, repo config path `tenantpilot.features.exchange_powershell_invocation`, and default disabled outside controlled test/service contexts.
|
||||||
|
- **FR-431-023**: The implementation MUST prove no evidence, normalized evidence, content-backed, comparable, renderable, certified, restore-ready, customer-ready, report, Review Pack, UI, route, job, scheduled capture, or global search promotion.
|
||||||
|
- **FR-431-024**: The implementation MUST introduce no `tenant_id`, legacy shim, fallback reader, dual-write path, or Exchange-specific evidence table.
|
||||||
|
|
||||||
|
## Non-Functional Requirements
|
||||||
|
|
||||||
|
- **NFR-431-001**: Use existing Laravel service and dependency-injection patterns; avoid broad provider frameworks.
|
||||||
|
- **NFR-431-002**: All persisted summaries are flat numeric counts after `SummaryCountsNormalizer`.
|
||||||
|
- **NFR-431-003**: Failure messages and codes are safe, bounded, and redacted.
|
||||||
|
- **NFR-431-004**: Tests must use fake runners and must not start a PowerShell process or call Microsoft services.
|
||||||
|
- **NFR-431-005**: No new runtime dependency, container image requirement, Dokploy runtime requirement, environment variable, scheduler, or queue worker requirement is introduced.
|
||||||
|
|
||||||
|
## Data / Truth Source Requirements
|
||||||
|
|
||||||
|
- **Execution truth**: Existing `OperationRun` rows.
|
||||||
|
- **Provider scope truth**: Existing `ProviderConnection` scoped by `workspace_id` and `managed_environment_id`.
|
||||||
|
- **Provider credential truth**: Existing `ProviderIdentityResolver`, `ProviderConnection::credential`, `ProviderCredential`, and existing credential-source metadata. Spec 431 MUST NOT create a new credential source; if the existing resolver path cannot prove a non-fake credential/identity reference, non-fake mode blocks.
|
||||||
|
- **Actor capability truth**: Existing `App\Support\Auth\Capabilities::PROVIDER_RUN` unless a spec amendment approves a narrower dedicated actor capability.
|
||||||
|
- **Feature gate truth**: Logical gate `tenant_configuration.exchange_powershell_invocation`, implemented through `tenantpilot.features.exchange_powershell_invocation`, default `false`.
|
||||||
|
- **Source contract truth**: Existing `ExchangePowerShellCommandContracts` and `CoverageSourceContractResolver` states from Spec 430.
|
||||||
|
- **Evidence truth**: N/A - no evidence is created or promoted.
|
||||||
|
- **Customer claim truth**: N/A - no customer claim is enabled.
|
||||||
|
- **Migration impact**: none.
|
||||||
|
|
||||||
|
## RBAC / Security Requirements
|
||||||
|
|
||||||
|
- Non-member workspace or managed-environment access MUST deny as not found.
|
||||||
|
- Member missing `Capabilities::PROVIDER_RUN` MUST fail as forbidden. If implementation determines `provider.run` is too broad for this operation, it MUST stop and amend this spec before adding a new actor capability or role mapping.
|
||||||
|
- Readonly role MUST NOT invoke.
|
||||||
|
- Provider connection MUST belong to the same workspace and managed environment.
|
||||||
|
- Non-fake invocation MUST validate the existing provider identity/credential resolver path without persisting credential material in OperationRun context.
|
||||||
|
- Credential material MUST NOT be persisted in OperationRun context, audit metadata, logs, summaries, or tests.
|
||||||
|
- Unsupported, expired, or missing credential references MUST block safely.
|
||||||
|
- Missing permission evidence MUST block or remain explicitly unvalidated; it MUST NOT be silently treated as least-privilege proof.
|
||||||
|
|
||||||
|
## Auditability / Observability Requirements
|
||||||
|
|
||||||
|
- Invocation attempts MUST be represented by OperationRun before runner execution.
|
||||||
|
- OperationRun lifecycle transitions MUST be service-owned.
|
||||||
|
- OperationRun summary counts MUST be safe and allowed.
|
||||||
|
- OperationRun reason codes/messages MUST be sanitized.
|
||||||
|
- No new UI notification, toast, browser event, or DB notification policy is introduced by this spec.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Live Exchange Online provider execution.
|
||||||
|
- `Connect-ExchangeOnline` or any PowerShell binary/process execution.
|
||||||
|
- Installing or configuring `ExchangeOnlineManagement`.
|
||||||
|
- Runtime/container/Dokploy hardening for PowerShell.
|
||||||
|
- Evidence persistence, normalized evidence, content-backed promotion, compare/render/certification, restore, customer claims, customer reports, PDFs, Review Pack output.
|
||||||
|
- UI, routes, Filament pages, Livewire components, navigation, global search, assets.
|
||||||
|
- Scheduled capture, queue-triggered live execution, provider clients, Teams, Exchange Admin API, outboundConnector, acceptedDomain, organizationConfig, mailboxPlan, sharingPolicy.
|
||||||
|
- New migration, `provider_connection_id` column on `operation_runs`, `tenant_id`, fallback readers, legacy shims, dual writes.
|
||||||
|
|
||||||
|
## Claim Guard
|
||||||
|
|
||||||
|
Allowed internal statement after implementation:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Exchange PowerShell invocation operation is registered and fake-runner proven for transportRule, remoteDomain, and inboundConnector.
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed only with this boundary:
|
||||||
|
|
||||||
|
```text
|
||||||
|
OperationRun-gated invocation path, production execution disabled, no evidence promotion.
|
||||||
|
```
|
||||||
|
|
||||||
|
Forbidden claims:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Exchange evidence ready
|
||||||
|
Exchange content-backed
|
||||||
|
Exchange comparable
|
||||||
|
Exchange renderable
|
||||||
|
Exchange certified
|
||||||
|
Exchange restore-ready
|
||||||
|
Exchange customer-ready
|
||||||
|
M365 ready
|
||||||
|
provider data captured
|
||||||
|
live Exchange capture works
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### AB1 - Spec Package
|
||||||
|
|
||||||
|
- `spec.md`, `plan.md`, `tasks.md`, `checklists/requirements.md`, and `implementation-report.md` exist.
|
||||||
|
|
||||||
|
### AB2 - Operation Registration
|
||||||
|
|
||||||
|
- OperationRun type, OperationCatalog entry, ProviderOperationRegistry entry, ProviderCapabilityRegistry capability, and OperationRunCapabilityResolver mapping exist and are tested.
|
||||||
|
|
||||||
|
### AB3 - Summary Counts
|
||||||
|
|
||||||
|
- Existing allowed keys are reused or new keys are explicitly added with tests.
|
||||||
|
- Unknown keys remain dropped.
|
||||||
|
- No raw output appears in summary counts.
|
||||||
|
|
||||||
|
### AB4 - Failure Sanitization
|
||||||
|
|
||||||
|
- Known blockers, known failures, unknown failures, raw exception text, token-like text, and stderr-like text sanitize safely.
|
||||||
|
|
||||||
|
### AB5 - Invocation Gate
|
||||||
|
|
||||||
|
- Single gate exists and requires verified contract, allowlisted command, OperationRun, provider scope, capability, feature gate, redaction, and credential/fake-mode policy.
|
||||||
|
|
||||||
|
### AB6 - Runner Safety
|
||||||
|
|
||||||
|
- Structured runner interface and fake runner exist.
|
||||||
|
- Production runner remains disabled/inert.
|
||||||
|
- Raw commands, mutation commands, unknown parameters, shell constructs, and arbitrary execution are rejected.
|
||||||
|
|
||||||
|
### AB7 - OperationRun Safety
|
||||||
|
|
||||||
|
- OperationRun is created before fake invocation.
|
||||||
|
- Status/outcome transitions go through `OperationRunService`.
|
||||||
|
- Context is sanitized and contains `provider_connection_id` only as context.
|
||||||
|
- No raw provider output or secret material is persisted.
|
||||||
|
|
||||||
|
### AB8 - No Evidence / No Product Promotion
|
||||||
|
|
||||||
|
- No evidence rows, raw evidence payload, normalized evidence payload, content-backed/comparable/renderable/certified/restore/customer state, report output, or Review Pack output exists.
|
||||||
|
|
||||||
|
### AB9 - No Product Surface
|
||||||
|
|
||||||
|
- No routes, Filament pages, Livewire components, navigation, global search, or assets are added or changed.
|
||||||
|
|
||||||
|
### AB10 - Architecture
|
||||||
|
|
||||||
|
- Existing OperationRun and provider operation/capability architecture is extended narrowly.
|
||||||
|
- Coverage v2 source contract architecture remains the source for contract verification.
|
||||||
|
- No `tenant_id`, migration, mini-platform, legacy shim, or fallback reader.
|
||||||
|
|
||||||
|
### AB11 - Validation
|
||||||
|
|
||||||
|
- Focused unit and feature tests pass.
|
||||||
|
- Required Spec 430/426/427/417/419/420 regressions pass or exact unavailable tests are documented.
|
||||||
|
- Pint and `git diff --check` pass.
|
||||||
|
- `git status --short` is documented.
|
||||||
|
|
||||||
|
## Follow-up Spec Candidates
|
||||||
|
|
||||||
|
- Spec 432 - Exchange PowerShell runtime execution hardening, only if production runner/container/Dokploy/credential validation remains blocked.
|
||||||
|
- Spec 433 - Exchange content-backed evidence promotion slice 1, only after Spec 431 proves operation/provider/capability gates.
|
||||||
|
- Teams PowerShell invocation/evidence slice, explicitly separate and not included here.
|
||||||
|
|
||||||
|
## Candidate Selection Gate
|
||||||
|
|
||||||
|
PASS. The selected candidate was directly provided by the user, follows completed Spec 430, is not covered by an existing active/completed Spec 431, aligns with the Exchange/Teams sequence, and is narrowed to operation registration plus fake-runner proof with adjacent concerns deferred.
|
||||||
|
|
||||||
|
## Spec Readiness Gate
|
||||||
|
|
||||||
|
PASS for preparation. `spec.md`, `plan.md`, `tasks.md`, `checklists/requirements.md`, and `implementation-report.md` define a bounded, testable package for a later implementation loop. No application implementation has been performed.
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
# Tasks: Exchange PowerShell Invocation Operation Registration and Execution Gate
|
||||||
|
|
||||||
|
**Input**: Design documents from `specs/431-exchange-powershell-invocation-operation-registration-gate/`
|
||||||
|
**Prerequisites**: `spec.md`, `plan.md`, Spec 430 implementation report, current repo source truth
|
||||||
|
**Tests**: Required. Runtime behavior changes must use Pest 4 focused unit/feature tests and selected regressions.
|
||||||
|
|
||||||
|
## Test Governance Checklist
|
||||||
|
|
||||||
|
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
||||||
|
- [x] New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit.
|
||||||
|
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
|
||||||
|
- [x] Planned validation commands cover the change without pulling in unrelated lane cost.
|
||||||
|
- [x] Browser proof is explicitly `N/A - no rendered UI surface changed`.
|
||||||
|
- [x] Human Product Sanity and Product Surface implementation-report close-out are planned as N/A.
|
||||||
|
- [x] Any material budget, baseline, trend, or escalation note is recorded in the implementation report.
|
||||||
|
|
||||||
|
## Phase 1: Preflight
|
||||||
|
|
||||||
|
- [x] T001 Capture current branch, HEAD, and `git status --short`.
|
||||||
|
- [x] T002 Confirm Spec 430 implementation report proves `transportRule`, `remoteDomain`, and `inboundConnector` command contracts and no OperationRun/evidence/UI/migration.
|
||||||
|
- [x] T003 Confirm no existing `tenant_configuration.exchange_powershell_invocation` operation type/catalog entry/provider operation/provider capability exists.
|
||||||
|
- [x] T004 Confirm allowed summary keys in `apps/platform/app/Support/OpsUx/OperationSummaryKeys.php` and unknown-key drop behavior in `SummaryCountsNormalizer`.
|
||||||
|
- [x] T005 Confirm hard stop boundaries: no live provider execution, no evidence, no UI, no migration, no scheduled capture/jobs, no `tenant_id`, no mini-platform.
|
||||||
|
|
||||||
|
## Phase 2: Operation Registration
|
||||||
|
|
||||||
|
- [x] T006 Add the canonical OperationRun type in `apps/platform/app/Support/OperationRunType.php`, using `tenant_configuration.exchange_powershell_invocation` unless repo convention forces a documented equivalent.
|
||||||
|
- [x] T007 Add the OperationCatalog canonical definition and canonical alias/write entry in `apps/platform/app/Support/OperationCatalog.php`.
|
||||||
|
- [x] T008 Add unit tests proving OperationRunType and OperationCatalog recognize the invocation operation and keep it internal/no-customer-claim.
|
||||||
|
|
||||||
|
## Phase 3: Provider Operation and Capability
|
||||||
|
|
||||||
|
- [x] T009 Add a ProviderOperationRegistry definition mapping the provider operation to the canonical invocation operation and internal module label.
|
||||||
|
- [x] T010 Add a provider binding that is explicit for Microsoft/Exchange PowerShell and does not imply evidence/capture readiness.
|
||||||
|
- [x] T011 Add or map one provider capability in `ProviderCapabilityRegistry`, recommended as `exchange_powershell_invoke` or repo-canonical equivalent.
|
||||||
|
- [x] T012 Add or update tests proving registry mapping, active/unsupported binding behavior, required provider capability keys, and capability registry lookup.
|
||||||
|
|
||||||
|
## Phase 4: Actor Capability Resolution
|
||||||
|
|
||||||
|
- [x] T013 Add `OperationRunCapabilityResolver` execution mapping for the invocation operation to existing `App\Support\Auth\Capabilities::PROVIDER_RUN`; do not add a new actor capability unless the spec is amended first.
|
||||||
|
- [x] T014 Add tests proving required actor capability resolution to `Capabilities::PROVIDER_RUN` and fail-closed behavior when capability is missing or unresolved.
|
||||||
|
- [x] T015 Ensure tests prove readonly actors cannot invoke, members missing `Capabilities::PROVIDER_RUN` receive 403, and non-member/not-entitled scope remains 404.
|
||||||
|
|
||||||
|
## Phase 5: Summary Counts and Failure Sanitization
|
||||||
|
|
||||||
|
- [x] T016 Implement the summary strategy using existing keys where possible: `total`, `processed`, `succeeded`, `failed`, `skipped`, `items`.
|
||||||
|
- [x] T017 Add new summary keys only if existing keys cannot represent required counts; if added, update `OperationSummaryKeys` and tests.
|
||||||
|
- [x] T018 Add tests proving emitted keys are preserved, unknown keys are dropped, values are numeric-only, and no raw output appears in summary counts.
|
||||||
|
- [x] T019 Add or map invocation blocker/failure reasons through `RunFailureSanitizer` or known provider reason codes.
|
||||||
|
- [x] T020 Add tests covering known invocation blocker, known invocation failure, unknown invocation failure, raw exception with token-like text, and stderr-like text with secret-like content.
|
||||||
|
|
||||||
|
## Phase 6: Invocation Gate
|
||||||
|
|
||||||
|
- [x] T021 Add one invocation gate service under the repo-canonical TenantConfiguration/provider service path, likely `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellInvocationGate.php`, and make it the sole public entry point for Spec 431 invocation.
|
||||||
|
- [x] T022 Gate target types to exactly `transportRule`, `remoteDomain`, and `inboundConnector`.
|
||||||
|
- [x] T023 Gate command contracts to exactly `Get-TransportRule`, `Get-RemoteDomain`, and `Get-InboundConnector`.
|
||||||
|
- [x] T024 Require Spec 430 `contract_verified_pending_capture` and `adapter_contract_available` state from `CoverageSourceContractResolver`.
|
||||||
|
- [x] T025 Require OperationRun type/catalog/provider operation/provider capability/capability resolver registration before runner execution.
|
||||||
|
- [x] T026 Require workspace, managed environment, provider connection, actor/context, operation type, feature gate `tenantpilot.features.exchange_powershell_invocation` defaulting false, runner mode, redaction policy, `Capabilities::PROVIDER_RUN`, and existing provider credential/identity resolution unless fake-runner-only mode explicitly bypasses live credentials.
|
||||||
|
- [x] T027 Reuse or compose with `ProviderOperationStartGate` internally where practical so provider binding/capability/scope behavior stays central; do not expose direct runner calls or direct provider-start-gate invocation as a public Exchange PowerShell invocation path.
|
||||||
|
- [x] T028 Create/reuse OperationRun before fake runner execution and route status/outcome transitions only through `OperationRunService`.
|
||||||
|
- [x] T029 Add gate tests for success, default feature disabled, controlled test feature enablement, missing `Capabilities::PROVIDER_RUN`, missing provider capability, missing provider credential/identity reference, invalid provider scope, invalid target type, invalid command, unknown parameters, direct runner bypass rejection, and direct provider-start-gate bypass rejection.
|
||||||
|
|
||||||
|
## Phase 7: Runner Boundary
|
||||||
|
|
||||||
|
- [x] T030 Add structured runner interface, likely `ExchangePowerShellCommandRunner`, accepting structured command contract, invocation context, timeout policy, and redaction policy.
|
||||||
|
- [x] T031 Ensure the runner interface rejects raw command strings, script blocks, pipelines, arbitrary parameters, shell fragments, semicolon-separated commands, redirection, file writes, and module installation commands.
|
||||||
|
- [x] T032 Add fake runner supporting success, empty result, authentication failure, authorization failure, permission failure, timeout, throttling, module unavailable, command unavailable, malformed result, and unexpected exception.
|
||||||
|
- [x] T033 Add disabled/inert production runner only if needed; default must return `execution_blocked_runner_disabled` or repo-equivalent without PowerShell or Microsoft calls.
|
||||||
|
- [x] T034 Add tests proving no PowerShell process starts and no Microsoft service call is made.
|
||||||
|
|
||||||
|
## Phase 8: Shape Validation and Redaction
|
||||||
|
|
||||||
|
- [x] T035 Validate structured collection-compatible results for each included command.
|
||||||
|
- [x] T036 Treat empty collection as valid empty success only when the runner envelope is success.
|
||||||
|
- [x] T037 Ensure permission/authorization failures are not treated as empty success.
|
||||||
|
- [x] T038 Fail safely on malformed scalar/text output and missing identity candidate fields.
|
||||||
|
- [x] T039 Add redaction tests proving OperationRun context excludes raw stdout, raw stderr, provider payloads, transcripts, raw exceptions, tokens, secrets, cookies, authorization headers, certificate material, mail body, message content, mailbox content, and file content.
|
||||||
|
- [x] T040 Prove `provider_connection_id` is stored only in sanitized OperationRun context and no `operation_runs.provider_connection_id` migration exists.
|
||||||
|
|
||||||
|
## Phase 9: No-Promotion and No-Surface Guards
|
||||||
|
|
||||||
|
- [x] T041 Add tests proving no `tenant_configuration_resource_evidence` rows are created.
|
||||||
|
- [x] T042 Add tests proving no raw evidence payload or normalized evidence payload is persisted.
|
||||||
|
- [x] T043 Add tests proving no content-backed, comparable, renderable, certified, restore-ready, customer-ready, report, Review Pack, or customer claim state appears.
|
||||||
|
- [x] T044 Add tests or guard scans proving no route, Filament page, Livewire component, navigation item, global search change, asset change, or browser surface is introduced.
|
||||||
|
- [x] T045 Add tests or guard scans proving no `tenant_id`, Exchange-specific evidence table, legacy shim, fallback reader, dual-write path, or Exchange mini-platform is introduced.
|
||||||
|
|
||||||
|
## Phase 10: Regression and Validation
|
||||||
|
|
||||||
|
- [x] T046 Run focused Spec 431 unit and feature tests.
|
||||||
|
- [x] T047 Run Spec 430 adapter contract regression tests.
|
||||||
|
- [x] T048 Run selected Spec 426 and Spec 427 no-promotion/source-contract regressions.
|
||||||
|
- [x] T049 Run selected Spec 417, Spec 419, and Spec 420 identity/registry/generic evidence regressions where available.
|
||||||
|
- [x] T050 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||||
|
- [x] T051 Run `git diff --check`.
|
||||||
|
- [x] T052 Capture final `git status --short`.
|
||||||
|
|
||||||
|
## Phase 11: Implementation Report
|
||||||
|
|
||||||
|
- [x] T053 Complete `specs/431-exchange-powershell-invocation-operation-registration-gate/implementation-report.md` with candidate gate result, branch/HEAD, dirty state, files changed, proportionality review, operation registration proof, provider/capability proof, `Capabilities::PROVIDER_RUN` resolver proof, feature-gate key/default proof, credential-source proof, single-public-gate proof, summary/failure proof, runner/fake-runner proof, production-disabled proof, command safety proof, provider scope proof, credential proof, shape validation proof, redaction proof, no shell/provider call proof, no evidence proof, no product promotion proof, no UI proof, no tenant_id proof, no mini-platform proof, Product Surface N/A decision, tests run, deferred work, and validation results.
|
||||||
|
|
||||||
|
## Explicit Non-Goals During Implementation
|
||||||
|
|
||||||
|
- [x] No live Exchange Online execution.
|
||||||
|
- [x] No PowerShell binary/process/module install.
|
||||||
|
- [x] No Microsoft service calls in tests.
|
||||||
|
- [x] No evidence persistence or promotion.
|
||||||
|
- [x] No UI/routes/navigation/global search/assets.
|
||||||
|
- [x] No migrations or `tenant_id`.
|
||||||
|
- [x] No Teams, Exchange Admin API, outboundConnector, acceptedDomain, organizationConfig, mailboxPlan, or sharingPolicy.
|
||||||
Loading…
Reference in New Issue
Block a user