feat: add Exchange PowerShell production runner gate (#499)
Spec 432: Exchange PowerShell production runner boundary and runtime gate. Validation: php artisan test --filter=Spec432 --compact; ./vendor/bin/pint --dirty --test --format agent; git diff --check. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #499
This commit is contained in:
parent
9374260ae1
commit
f4e342121a
@ -50,6 +50,10 @@
|
||||
use App\Services\Providers\ProviderGateway;
|
||||
use App\Services\TenantConfiguration\DisabledExchangePowerShellCommandRunner;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellCommandRunner;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellInvocationGate;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellProcessExecutor;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellProductionRunner;
|
||||
use App\Services\TenantConfiguration\SymfonyExchangePowerShellProcessExecutor;
|
||||
use App\Support\References\Contracts\ReferenceResolver;
|
||||
use App\Support\References\ReferenceResolverRegistry;
|
||||
use App\Support\References\ReferenceStatePresenter;
|
||||
@ -88,7 +92,17 @@ public function register(): void
|
||||
$this->app->scoped(WorkspaceCapabilityResolver::class);
|
||||
|
||||
$this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class);
|
||||
$this->app->bind(ExchangePowerShellCommandRunner::class, DisabledExchangePowerShellCommandRunner::class);
|
||||
$this->app->bind(ExchangePowerShellProcessExecutor::class, SymfonyExchangePowerShellProcessExecutor::class);
|
||||
$this->app->bind(ExchangePowerShellCommandRunner::class, function ($app): ExchangePowerShellCommandRunner {
|
||||
if (
|
||||
(bool) $app['config']->get(ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH, false)
|
||||
&& (bool) $app['config']->get(ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH, false)
|
||||
) {
|
||||
return $app->make(ExchangePowerShellProductionRunner::class);
|
||||
}
|
||||
|
||||
return $app->make(DisabledExchangePowerShellCommandRunner::class);
|
||||
});
|
||||
|
||||
$this->app->bind(
|
||||
\App\Contracts\Hardening\WriteGateInterface::class,
|
||||
|
||||
@ -433,9 +433,14 @@ private function resolveProviderBinding(string $operationType, ?ProviderConnecti
|
||||
*/
|
||||
private function requiresProviderIdentity(string $operationType, array $extraContext): bool
|
||||
{
|
||||
return ! (
|
||||
$this->isExchangePowerShellInvocationOperation($operationType)
|
||||
&& ($extraContext['provider_identity_resolution_mode'] ?? null) === 'fake_runner_bypass'
|
||||
if (! $this->isExchangePowerShellInvocationOperation($operationType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! in_array(
|
||||
$extraContext['provider_identity_resolution_mode'] ?? null,
|
||||
['fake_runner_bypass', 'production_runner_reference_gate'],
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantConfiguration;
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Support\Providers\ProviderCredentialKind;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
final class ExchangePowerShellCredentialReferenceResolver
|
||||
{
|
||||
public function resolve(ProviderConnection $connection): ExchangePowerShellGateResult
|
||||
{
|
||||
$credential = ProviderCredential::query()
|
||||
->where('provider_connection_id', (int) $connection->getKey())
|
||||
->first();
|
||||
|
||||
if (! $credential instanceof ProviderCredential) {
|
||||
return $this->blocked(
|
||||
reasonCode: ProviderReasonCodes::ProviderCredentialMissing,
|
||||
failureCode: 'credential_blocked_missing_reference',
|
||||
message: 'Exchange PowerShell requires a provider credential reference.',
|
||||
context: ['credential_state' => 'missing'],
|
||||
);
|
||||
}
|
||||
|
||||
$kind = $this->credentialKind($credential);
|
||||
$baseContext = [
|
||||
'credential_state' => 'blocked',
|
||||
'credential_kind' => $this->safeCredentialKindLabel($kind),
|
||||
'credential_reference_id' => (int) $credential->getKey(),
|
||||
'credential_material_persisted' => false,
|
||||
];
|
||||
|
||||
if ($kind === ProviderCredentialKind::ClientSecret->value) {
|
||||
return $this->blocked(
|
||||
reasonCode: ProviderReasonCodes::ProviderCredentialInvalid,
|
||||
failureCode: 'credential_blocked_secret_based_app',
|
||||
message: 'Secret-based app credentials are not supported for Exchange PowerShell production invocation.',
|
||||
context: $this->credentialContext($baseContext, 'secret_based_app_unsupported'),
|
||||
);
|
||||
}
|
||||
|
||||
if ($kind === ProviderCredentialKind::Certificate->value) {
|
||||
if (! $credential->last_rotated_at instanceof Carbon) {
|
||||
return $this->blocked(
|
||||
reasonCode: ProviderReasonCodes::ProviderCredentialMissing,
|
||||
failureCode: 'credential_blocked_certificate_missing',
|
||||
message: 'Certificate credential metadata is incomplete.',
|
||||
context: $this->credentialContext($baseContext, 'certificate_missing'),
|
||||
);
|
||||
}
|
||||
|
||||
if ($credential->expires_at instanceof Carbon && $credential->expires_at->isPast()) {
|
||||
return $this->blocked(
|
||||
reasonCode: ProviderReasonCodes::ProviderCredentialInvalid,
|
||||
failureCode: 'credential_blocked_certificate_expired',
|
||||
message: 'Certificate credential reference is expired.',
|
||||
context: $this->credentialContext($baseContext, 'certificate_expired'),
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->credentialKindSupported($kind)) {
|
||||
return ExchangePowerShellGateResult::allowed($this->credentialContext($baseContext, 'certificate_supported'));
|
||||
}
|
||||
|
||||
return $this->blocked(
|
||||
reasonCode: ProviderReasonCodes::ProviderCredentialInvalid,
|
||||
failureCode: 'credential_blocked_certificate_unsupported',
|
||||
message: 'Certificate credentials are not enabled for Exchange PowerShell production invocation.',
|
||||
context: $this->credentialContext($baseContext, 'certificate_unsupported'),
|
||||
);
|
||||
}
|
||||
|
||||
if ($kind === ProviderCredentialKind::Federated->value) {
|
||||
if (! $credential->last_rotated_at instanceof Carbon) {
|
||||
return $this->blocked(
|
||||
reasonCode: ProviderReasonCodes::ProviderCredentialMissing,
|
||||
failureCode: 'credential_blocked_federated_missing',
|
||||
message: 'Federated credential metadata is incomplete.',
|
||||
context: $this->credentialContext($baseContext, 'federated_missing'),
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->credentialKindSupported($kind)) {
|
||||
return ExchangePowerShellGateResult::allowed($this->credentialContext($baseContext, 'federated_supported'));
|
||||
}
|
||||
|
||||
return $this->blocked(
|
||||
reasonCode: ProviderReasonCodes::ProviderCredentialInvalid,
|
||||
failureCode: 'credential_blocked_federated_unsupported',
|
||||
message: 'Federated credentials are not enabled for Exchange PowerShell production invocation.',
|
||||
context: $this->credentialContext($baseContext, 'federated_unsupported'),
|
||||
);
|
||||
}
|
||||
|
||||
if ($kind === 'managed_identity') {
|
||||
if ($this->credentialKindSupported($kind)) {
|
||||
return ExchangePowerShellGateResult::allowed($this->credentialContext($baseContext, 'managed_identity_supported'));
|
||||
}
|
||||
|
||||
return $this->blocked(
|
||||
reasonCode: ProviderReasonCodes::ProviderCredentialInvalid,
|
||||
failureCode: 'credential_blocked_managed_identity_unsupported',
|
||||
message: 'Managed identity credentials are not enabled for Exchange PowerShell production invocation.',
|
||||
context: $this->credentialContext($baseContext, 'managed_identity_unsupported'),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->blocked(
|
||||
reasonCode: ProviderReasonCodes::ProviderCredentialInvalid,
|
||||
failureCode: 'credential_blocked_unknown_kind',
|
||||
message: 'Provider credential kind is not supported for Exchange PowerShell production invocation.',
|
||||
context: $this->credentialContext($baseContext, 'unknown_kind'),
|
||||
);
|
||||
}
|
||||
|
||||
private function credentialKind(ProviderCredential $credential): string
|
||||
{
|
||||
$kind = $credential->getRawOriginal('credential_kind');
|
||||
|
||||
if (! is_string($kind) || trim($kind) === '') {
|
||||
$kind = $credential->getRawOriginal('type');
|
||||
}
|
||||
|
||||
return is_string($kind) ? strtolower(trim($kind)) : '';
|
||||
}
|
||||
|
||||
private function credentialKindSupported(string $kind): bool
|
||||
{
|
||||
$supportedKinds = config('tenantpilot.exchange_powershell.credentials.supported_reference_kinds', []);
|
||||
|
||||
if (is_string($supportedKinds)) {
|
||||
$supportedKinds = explode(',', $supportedKinds);
|
||||
}
|
||||
|
||||
if (! is_array($supportedKinds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$supportedKinds = array_map(
|
||||
static fn (mixed $supportedKind): string => strtolower(trim((string) $supportedKind)),
|
||||
$supportedKinds,
|
||||
);
|
||||
|
||||
return in_array($kind, $supportedKinds, true);
|
||||
}
|
||||
|
||||
private function safeCredentialKindLabel(string $kind): string
|
||||
{
|
||||
return match ($kind) {
|
||||
ProviderCredentialKind::ClientSecret->value => 'secret_based_app',
|
||||
ProviderCredentialKind::Certificate->value => 'certificate',
|
||||
ProviderCredentialKind::Federated->value => 'federated',
|
||||
'managed_identity' => 'managed_identity',
|
||||
default => 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $baseContext
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function credentialContext(array $baseContext, string $state): array
|
||||
{
|
||||
return array_replace($baseContext, ['credential_state' => $state]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function blocked(string $reasonCode, string $failureCode, string $message, array $context): ExchangePowerShellGateResult
|
||||
{
|
||||
return ExchangePowerShellGateResult::blocked(
|
||||
reasonCode: $reasonCode,
|
||||
failureCode: $failureCode,
|
||||
message: $message,
|
||||
context: $context,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantConfiguration;
|
||||
|
||||
final readonly class ExchangePowerShellGateResult
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function __construct(
|
||||
public bool $allowed,
|
||||
public ?string $reasonCode = null,
|
||||
public ?string $failureCode = null,
|
||||
public ?string $message = null,
|
||||
public array $context = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public static function allowed(array $context = []): self
|
||||
{
|
||||
return new self(allowed: true, context: $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public static function blocked(string $reasonCode, string $failureCode, string $message, array $context = []): self
|
||||
{
|
||||
return new self(
|
||||
allowed: false,
|
||||
reasonCode: $reasonCode,
|
||||
failureCode: $failureCode,
|
||||
message: $message,
|
||||
context: $context,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -32,10 +32,14 @@ final class ExchangePowerShellInvocationGate
|
||||
|
||||
public const string FEATURE_CONFIG_PATH = 'tenantpilot.features.exchange_powershell_invocation';
|
||||
|
||||
public const string PRODUCTION_RUNNER_CONFIG_PATH = 'tenantpilot.features.exchange_powershell_production_runner';
|
||||
|
||||
public const string RUNNER_MODE_FAKE = 'fake';
|
||||
|
||||
public const string RUNNER_MODE_DISABLED = 'disabled';
|
||||
|
||||
public const string RUNNER_MODE_PRODUCTION = 'production';
|
||||
|
||||
public const string REDACTION_POLICY = 'strict_no_raw_output';
|
||||
|
||||
public function __construct(
|
||||
@ -149,9 +153,12 @@ private function initialContext(
|
||||
'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',
|
||||
'production_runner_gate' => [
|
||||
'operation_type' => self::OPERATION_TYPE,
|
||||
'config_path' => self::PRODUCTION_RUNNER_CONFIG_PATH,
|
||||
'enabled' => (bool) config(self::PRODUCTION_RUNNER_CONFIG_PATH, false),
|
||||
],
|
||||
'provider_identity_resolution_mode' => $this->providerIdentityResolutionMode($runnerMode),
|
||||
'target_scope' => [
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
@ -272,6 +279,22 @@ private function executeStartedRun(
|
||||
);
|
||||
}
|
||||
|
||||
if ($runnerMode === self::RUNNER_MODE_PRODUCTION && ! (bool) config(self::PRODUCTION_RUNNER_CONFIG_PATH, false)) {
|
||||
return $this->blockRun(
|
||||
run: $run,
|
||||
reasonCode: ProviderReasonCodes::ProviderBindingUnsupported,
|
||||
failureCode: 'execution_blocked_production_runner_disabled',
|
||||
message: 'Exchange PowerShell production runner gate is disabled.',
|
||||
context: [
|
||||
'production_runner_gate' => [
|
||||
'operation_type' => self::OPERATION_TYPE,
|
||||
'config_path' => self::PRODUCTION_RUNNER_CONFIG_PATH,
|
||||
'enabled' => false,
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$credentialSource = null;
|
||||
|
||||
if ($runnerMode === self::RUNNER_MODE_FAKE) {
|
||||
@ -281,6 +304,15 @@ private function executeStartedRun(
|
||||
'live_credentials_required' => false,
|
||||
],
|
||||
]);
|
||||
} elseif ($runnerMode === self::RUNNER_MODE_PRODUCTION) {
|
||||
$run = $this->mergeContext($run, [
|
||||
'credential_policy' => [
|
||||
'mode' => 'production_runner_reference_gate',
|
||||
'credential_source' => null,
|
||||
'live_credentials_required' => true,
|
||||
'credential_material_resolved_by_invocation_gate' => false,
|
||||
],
|
||||
]);
|
||||
} else {
|
||||
$identity = $this->identityResolver->resolve($providerConnection);
|
||||
|
||||
@ -656,7 +688,16 @@ private function normalizeRunnerMode(string $runnerMode): string
|
||||
|
||||
private function knownRunnerMode(string $runnerMode): bool
|
||||
{
|
||||
return in_array($runnerMode, [self::RUNNER_MODE_FAKE, self::RUNNER_MODE_DISABLED], true);
|
||||
return in_array($runnerMode, [self::RUNNER_MODE_FAKE, self::RUNNER_MODE_DISABLED, self::RUNNER_MODE_PRODUCTION], true);
|
||||
}
|
||||
|
||||
private function providerIdentityResolutionMode(string $runnerMode): string
|
||||
{
|
||||
return match ($runnerMode) {
|
||||
self::RUNNER_MODE_FAKE => 'fake_runner_bypass',
|
||||
self::RUNNER_MODE_PRODUCTION => 'production_runner_reference_gate',
|
||||
default => 'provider_identity_required',
|
||||
};
|
||||
}
|
||||
|
||||
private function safeCommandName(string $canonicalType, ?string $commandName): ?string
|
||||
@ -712,7 +753,21 @@ private function safeRunnerResultContext(ExchangePowerShellInvocationResult $res
|
||||
{
|
||||
$context = [];
|
||||
|
||||
foreach (['runner_mode', 'execution_enabled', 'failure_mode', 'retryable'] as $key) {
|
||||
foreach ([
|
||||
'runner_mode',
|
||||
'execution_enabled',
|
||||
'failure_mode',
|
||||
'retryable',
|
||||
'runtime_state',
|
||||
'credential_state',
|
||||
'permission_evidence_state',
|
||||
'command_builder_state',
|
||||
'output_state',
|
||||
'concurrency_state',
|
||||
'duration_ms',
|
||||
'item_count',
|
||||
'timeout_cleanup_attempted',
|
||||
] as $key) {
|
||||
$value = $result->context[$key] ?? null;
|
||||
|
||||
if (is_scalar($value) || $value === null) {
|
||||
|
||||
@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantConfiguration;
|
||||
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
|
||||
final class ExchangePowerShellOutputGuard
|
||||
{
|
||||
public function guard(
|
||||
ExchangePowerShellProcessResult $result,
|
||||
ExchangePowerShellCommandContract $contract,
|
||||
ExchangePowerShellRuntimePolicy $policy,
|
||||
): ExchangePowerShellInvocationResult {
|
||||
if ($result->timedOut) {
|
||||
return ExchangePowerShellInvocationResult::failed(
|
||||
reasonCode: ProviderReasonCodes::NetworkUnreachable,
|
||||
failureCode: 'execution_failed_timeout',
|
||||
message: 'Exchange PowerShell process timed out.',
|
||||
summaryCounts: $this->failureSummaryCounts(),
|
||||
context: [
|
||||
'output_state' => 'timeout',
|
||||
'timeout_cleanup_attempted' => true,
|
||||
'duration_ms' => $result->durationMs,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if ($result->exceptionClass !== null) {
|
||||
return ExchangePowerShellInvocationResult::failed(
|
||||
reasonCode: ProviderReasonCodes::UnknownError,
|
||||
failureCode: 'execution_failed_unexpected_exception',
|
||||
message: 'Exchange PowerShell process failed safely.',
|
||||
summaryCounts: $this->failureSummaryCounts(),
|
||||
context: [
|
||||
'output_state' => 'executor_exception',
|
||||
'duration_ms' => $result->durationMs,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (strlen($result->stdout) > $policy->stdoutMaxBytes) {
|
||||
return $this->shapeFailure('execution_failed_stdout_oversized', 'stdout_oversized', $result->durationMs);
|
||||
}
|
||||
|
||||
if (strlen($result->stderr) > $policy->stderrMaxBytes) {
|
||||
return $this->shapeFailure('execution_failed_stderr_oversized', 'stderr_oversized', $result->durationMs);
|
||||
}
|
||||
|
||||
if (! $this->isSafeUtf8Text($result->stdout) || ! $this->isSafeUtf8Text($result->stderr)) {
|
||||
return $this->shapeFailure('execution_failed_output_encoding', 'non_utf8_or_binary', $result->durationMs);
|
||||
}
|
||||
|
||||
if ($result->exitCode !== 0) {
|
||||
return ExchangePowerShellInvocationResult::failed(
|
||||
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
|
||||
failureCode: 'execution_failed_nonzero_exit',
|
||||
message: 'Exchange PowerShell process exited unsuccessfully.',
|
||||
summaryCounts: $this->failureSummaryCounts(),
|
||||
context: [
|
||||
'output_state' => 'nonzero_exit',
|
||||
'duration_ms' => $result->durationMs,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (trim($result->stderr) !== '') {
|
||||
return $this->shapeFailure('execution_failed_stderr_present', 'stderr_present', $result->durationMs);
|
||||
}
|
||||
|
||||
$stdout = trim($result->stdout);
|
||||
|
||||
if ($stdout === '') {
|
||||
return ExchangePowerShellInvocationResult::succeeded([], context: [
|
||||
'output_state' => 'empty_collection',
|
||||
'item_count' => 0,
|
||||
'duration_ms' => $result->durationMs,
|
||||
]);
|
||||
}
|
||||
|
||||
if (preg_match('/\AWARNING:/i', $stdout) === 1) {
|
||||
return $this->shapeFailure('execution_failed_output_shape_unsafe', 'warning_prefixed', $result->durationMs);
|
||||
}
|
||||
|
||||
$decoded = json_decode($stdout, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($decoded) || ! array_is_list($decoded)) {
|
||||
return $this->shapeFailure('execution_failed_shape_unsafe', 'malformed_json_collection', $result->durationMs);
|
||||
}
|
||||
|
||||
$items = [];
|
||||
|
||||
foreach ($decoded as $item) {
|
||||
if (! is_array($item)) {
|
||||
return $this->shapeFailure('execution_failed_shape_unsafe', 'malformed_collection_item', $result->durationMs);
|
||||
}
|
||||
|
||||
$items[] = $item;
|
||||
}
|
||||
|
||||
if (count($items) > $policy->itemLimit) {
|
||||
return $this->shapeFailure('execution_failed_item_limit_exceeded', 'item_limit_exceeded', $result->durationMs);
|
||||
}
|
||||
|
||||
return ExchangePowerShellInvocationResult::succeeded($items, context: [
|
||||
'output_state' => 'structured_collection',
|
||||
'item_count' => count($items),
|
||||
'duration_ms' => $result->durationMs,
|
||||
]);
|
||||
}
|
||||
|
||||
private function shapeFailure(string $failureCode, string $outputState, int $durationMs): ExchangePowerShellInvocationResult
|
||||
{
|
||||
return ExchangePowerShellInvocationResult::failed(
|
||||
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
|
||||
failureCode: $failureCode,
|
||||
message: 'Exchange PowerShell output was rejected safely.',
|
||||
summaryCounts: $this->failureSummaryCounts(),
|
||||
context: [
|
||||
'output_state' => $outputState,
|
||||
'duration_ms' => $durationMs,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function isSafeUtf8Text(string $value): bool
|
||||
{
|
||||
if ($value === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (function_exists('mb_check_encoding') && ! mb_check_encoding($value, 'UTF-8')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (preg_match('//u', $value) !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $value) !== 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function failureSummaryCounts(): array
|
||||
{
|
||||
return [
|
||||
'total' => 1,
|
||||
'processed' => 1,
|
||||
'succeeded' => 0,
|
||||
'failed' => 1,
|
||||
'skipped' => 0,
|
||||
'items' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantConfiguration;
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentPermission;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
|
||||
final class ExchangePowerShellPermissionEvidenceEvaluator
|
||||
{
|
||||
private const string PERMISSION_KEY = 'Exchange.ManageAsApp';
|
||||
|
||||
public function evaluate(ManagedEnvironment $environment, ProviderConnection $connection): ExchangePowerShellGateResult
|
||||
{
|
||||
$permission = ManagedEnvironmentPermission::query()
|
||||
->where('workspace_id', (int) $environment->workspace_id)
|
||||
->where('managed_environment_id', (int) $environment->getKey())
|
||||
->where('permission_key', self::PERMISSION_KEY)
|
||||
->orderByDesc('last_checked_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (! $permission instanceof ManagedEnvironmentPermission) {
|
||||
return $this->blocked('permission_evidence_blocked_missing', 'permission_evidence_state', 'missing');
|
||||
}
|
||||
|
||||
$details = is_array($permission->details) ? $permission->details : [];
|
||||
|
||||
if ((string) $permission->status !== 'granted') {
|
||||
return $this->blocked('permission_evidence_blocked_unvalidated', 'permission_evidence_state', 'unvalidated', $permission);
|
||||
}
|
||||
|
||||
if (! $permission->last_checked_at || $permission->last_checked_at->lt(now()->subDays(30))) {
|
||||
return $this->blocked('permission_evidence_blocked_stale', 'permission_evidence_state', 'stale', $permission);
|
||||
}
|
||||
|
||||
if ((int) ($details['workspace_id'] ?? 0) !== (int) $connection->workspace_id) {
|
||||
return $this->blocked('permission_evidence_blocked_wrong_workspace', 'permission_evidence_state', 'wrong_workspace', $permission);
|
||||
}
|
||||
|
||||
if ((int) ($details['managed_environment_id'] ?? 0) !== (int) $connection->managed_environment_id) {
|
||||
return $this->blocked('permission_evidence_blocked_wrong_environment', 'permission_evidence_state', 'wrong_environment', $permission);
|
||||
}
|
||||
|
||||
if ((string) ($details['provider'] ?? '') !== (string) $connection->provider) {
|
||||
return $this->blocked('permission_evidence_blocked_unsupported_provider', 'permission_evidence_state', 'unsupported_provider', $permission);
|
||||
}
|
||||
|
||||
if ((int) ($details['provider_connection_id'] ?? 0) !== (int) $connection->getKey()) {
|
||||
return $this->blocked('permission_evidence_blocked_wrong_provider_connection', 'permission_evidence_state', 'wrong_provider_connection', $permission);
|
||||
}
|
||||
|
||||
return ExchangePowerShellGateResult::allowed([
|
||||
'permission_evidence_state' => 'verified',
|
||||
'permission_key' => self::PERMISSION_KEY,
|
||||
'permission_evidence_id' => (int) $permission->getKey(),
|
||||
'last_checked_at' => $permission->last_checked_at?->toJSON(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function blocked(
|
||||
string $failureCode,
|
||||
string $stateKey,
|
||||
string $state,
|
||||
?ManagedEnvironmentPermission $permission = null,
|
||||
): ExchangePowerShellGateResult {
|
||||
return ExchangePowerShellGateResult::blocked(
|
||||
reasonCode: ProviderReasonCodes::ProviderPermissionMissing,
|
||||
failureCode: $failureCode,
|
||||
message: 'Verified Exchange PowerShell permission evidence is required.',
|
||||
context: array_filter([
|
||||
$stateKey => $state,
|
||||
'permission_key' => self::PERMISSION_KEY,
|
||||
'permission_evidence_id' => $permission instanceof ManagedEnvironmentPermission ? (int) $permission->getKey() : null,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantConfiguration;
|
||||
|
||||
final readonly class ExchangePowerShellProcessCommand
|
||||
{
|
||||
/**
|
||||
* @param list<string> $arguments
|
||||
*/
|
||||
public function __construct(
|
||||
public array $arguments,
|
||||
public int $timeoutSeconds,
|
||||
public int $stdoutMaxBytes,
|
||||
public int $stderrMaxBytes,
|
||||
) {}
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantConfiguration;
|
||||
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
|
||||
final class ExchangePowerShellProcessCommandBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ExchangePowerShellCommandContracts $contracts,
|
||||
) {}
|
||||
|
||||
public function build(
|
||||
ExchangePowerShellCommandContract $commandContract,
|
||||
ExchangePowerShellRuntimePolicy $policy,
|
||||
): ExchangePowerShellProcessCommandBuilderResult {
|
||||
$contract = $commandContract->toArray();
|
||||
$validation = $this->contracts->validateInvocation($contract, $commandContract->parameters());
|
||||
|
||||
if (! $validation['accepted']) {
|
||||
return $this->blocked('command_contract_rejected', [
|
||||
'command_builder_state' => 'contract_rejected',
|
||||
'command_validation' => [
|
||||
'reason_code' => is_string($validation['reason_code'] ?? null) ? $validation['reason_code'] : 'unknown',
|
||||
'rejected_parameter_count' => count($commandContract->parameterNames()),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$commandName = (string) ($contract['command_name'] ?? '');
|
||||
|
||||
if (! in_array($commandName, ['Get-TransportRule', 'Get-RemoteDomain', 'Get-InboundConnector'], true)) {
|
||||
return $this->blocked('command_contract_rejected', [
|
||||
'command_builder_state' => 'command_not_allowlisted',
|
||||
'command_validation' => [
|
||||
'reason_code' => 'command_not_allowlisted',
|
||||
'rejected_parameter_count' => 0,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($commandContract->parameterNames() !== []) {
|
||||
return $this->blocked('command_contract_rejected', [
|
||||
'command_builder_state' => 'parameter_policy_rejected',
|
||||
'command_validation' => [
|
||||
'reason_code' => 'unknown_parameter_rejected',
|
||||
'rejected_parameter_count' => count($commandContract->parameterNames()),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
return ExchangePowerShellProcessCommandBuilderResult::built(
|
||||
command: new ExchangePowerShellProcessCommand(
|
||||
arguments: [
|
||||
$policy->powershellBinary,
|
||||
'-NoLogo',
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
$commandName,
|
||||
],
|
||||
timeoutSeconds: $policy->invocationTimeoutSeconds,
|
||||
stdoutMaxBytes: $policy->stdoutMaxBytes,
|
||||
stderrMaxBytes: $policy->stderrMaxBytes,
|
||||
),
|
||||
context: [
|
||||
'command_builder_state' => 'fixed_allowlist_argument_vector',
|
||||
'command_name' => $commandName,
|
||||
'parameter_count' => 0,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function blocked(string $failureCode, array $context): ExchangePowerShellProcessCommandBuilderResult
|
||||
{
|
||||
return ExchangePowerShellProcessCommandBuilderResult::blocked(
|
||||
reasonCode: ProviderReasonCodes::ProviderBindingUnsupported,
|
||||
failureCode: $failureCode,
|
||||
message: 'Exchange PowerShell command construction was rejected.',
|
||||
context: $context,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantConfiguration;
|
||||
|
||||
final readonly class ExchangePowerShellProcessCommandBuilderResult
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function __construct(
|
||||
public bool $successful,
|
||||
public ?ExchangePowerShellProcessCommand $command = null,
|
||||
public ?string $reasonCode = null,
|
||||
public ?string $failureCode = null,
|
||||
public ?string $message = null,
|
||||
public array $context = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public static function built(ExchangePowerShellProcessCommand $command, array $context = []): self
|
||||
{
|
||||
return new self(successful: true, command: $command, context: $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public static function blocked(string $reasonCode, string $failureCode, string $message, array $context = []): self
|
||||
{
|
||||
return new self(
|
||||
successful: false,
|
||||
reasonCode: $reasonCode,
|
||||
failureCode: $failureCode,
|
||||
message: $message,
|
||||
context: $context,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantConfiguration;
|
||||
|
||||
interface ExchangePowerShellProcessExecutor
|
||||
{
|
||||
public function available(): bool;
|
||||
|
||||
public function run(ExchangePowerShellProcessCommand $command): ExchangePowerShellProcessResult;
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantConfiguration;
|
||||
|
||||
final readonly class ExchangePowerShellProcessResult
|
||||
{
|
||||
private function __construct(
|
||||
public int $exitCode,
|
||||
public string $stdout,
|
||||
public string $stderr,
|
||||
public bool $timedOut,
|
||||
public int $durationMs,
|
||||
public ?string $exceptionClass = null,
|
||||
) {}
|
||||
|
||||
public static function completed(int $exitCode, string $stdout = '', string $stderr = '', int $durationMs = 0): self
|
||||
{
|
||||
return new self(
|
||||
exitCode: $exitCode,
|
||||
stdout: $stdout,
|
||||
stderr: $stderr,
|
||||
timedOut: false,
|
||||
durationMs: max(0, $durationMs),
|
||||
);
|
||||
}
|
||||
|
||||
public static function timedOut(int $durationMs = 0): self
|
||||
{
|
||||
return new self(
|
||||
exitCode: 124,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
timedOut: true,
|
||||
durationMs: max(0, $durationMs),
|
||||
);
|
||||
}
|
||||
|
||||
public static function exception(string $exceptionClass, int $durationMs = 0): self
|
||||
{
|
||||
return new self(
|
||||
exitCode: 1,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
timedOut: false,
|
||||
durationMs: max(0, $durationMs),
|
||||
exceptionClass: $exceptionClass,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantConfiguration;
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Throwable;
|
||||
|
||||
final class ExchangePowerShellProductionRunner implements ExchangePowerShellCommandRunner
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ExchangePowerShellRuntimeReadinessChecker $runtimeReadiness,
|
||||
private readonly ExchangePowerShellCredentialReferenceResolver $credentials,
|
||||
private readonly ExchangePowerShellPermissionEvidenceEvaluator $permissions,
|
||||
private readonly ExchangePowerShellProcessCommandBuilder $commands,
|
||||
private readonly ExchangePowerShellProcessExecutor $executor,
|
||||
private readonly ExchangePowerShellOutputGuard $outputGuard,
|
||||
) {}
|
||||
|
||||
public function run(
|
||||
ExchangePowerShellCommandContract $commandContract,
|
||||
ExchangePowerShellInvocationContext $context,
|
||||
): ExchangePowerShellInvocationResult {
|
||||
if ($context->runnerMode !== ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION) {
|
||||
return $this->blocked('execution_blocked_runner_mode_invalid', ['runner_mode' => $context->runnerMode]);
|
||||
}
|
||||
|
||||
if (! (bool) config(ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH, false)) {
|
||||
return $this->blocked('execution_blocked_production_runner_disabled', [
|
||||
'runner_mode' => $context->runnerMode,
|
||||
'execution_enabled' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
$connection = ProviderConnection::query()
|
||||
->whereKey($context->providerConnectionId)
|
||||
->first();
|
||||
$environment = ManagedEnvironment::query()
|
||||
->whereKey($context->managedEnvironmentId)
|
||||
->first();
|
||||
|
||||
if (! $connection instanceof ProviderConnection || ! $environment instanceof ManagedEnvironment) {
|
||||
return $this->blocked('scope_blocked_provider_connection_missing', ['scope_state' => 'missing']);
|
||||
}
|
||||
|
||||
if ((int) $connection->workspace_id !== $context->workspaceId
|
||||
|| (int) $connection->managed_environment_id !== $context->managedEnvironmentId
|
||||
|| (int) $environment->workspace_id !== $context->workspaceId
|
||||
) {
|
||||
return $this->blocked('scope_blocked_provider_connection_mismatch', ['scope_state' => 'mismatch']);
|
||||
}
|
||||
|
||||
$credentialGate = $this->credentials->resolve($connection);
|
||||
|
||||
if (! $credentialGate->allowed) {
|
||||
return $this->blockedFromGate($credentialGate);
|
||||
}
|
||||
|
||||
$permissionGate = $this->permissions->evaluate($environment, $connection);
|
||||
|
||||
if (! $permissionGate->allowed) {
|
||||
return $this->blockedFromGate($permissionGate);
|
||||
}
|
||||
|
||||
$runtimePolicy = ExchangePowerShellRuntimePolicy::fromConfig();
|
||||
$runtimeGate = $this->runtimeReadiness->check($runtimePolicy);
|
||||
|
||||
if (! $runtimeGate->allowed) {
|
||||
return $this->blockedFromGate($runtimeGate);
|
||||
}
|
||||
|
||||
$build = $this->commands->build($commandContract, $runtimePolicy);
|
||||
|
||||
if (! $build->successful || ! $build->command instanceof ExchangePowerShellProcessCommand) {
|
||||
return $this->blockedFromBuild($build);
|
||||
}
|
||||
|
||||
$workspaceLock = Cache::lock('exchange-powershell:workspace:'.$context->workspaceId, $runtimePolicy->lockTtlSeconds);
|
||||
$providerLock = Cache::lock('exchange-powershell:provider:'.$context->providerConnectionId, $runtimePolicy->lockTtlSeconds);
|
||||
$workspaceAcquired = false;
|
||||
$providerAcquired = false;
|
||||
|
||||
try {
|
||||
$workspaceAcquired = $workspaceLock->get();
|
||||
|
||||
if (! $workspaceAcquired) {
|
||||
return $this->blocked('execution_blocked_concurrency_workspace', [
|
||||
'concurrency_state' => 'workspace_busy',
|
||||
]);
|
||||
}
|
||||
|
||||
$providerAcquired = $providerLock->get();
|
||||
|
||||
if (! $providerAcquired) {
|
||||
return $this->blocked('execution_blocked_concurrency_provider', [
|
||||
'concurrency_state' => 'provider_busy',
|
||||
]);
|
||||
}
|
||||
|
||||
$process = $this->executor->run($build->command);
|
||||
$result = $this->outputGuard->guard($process, $commandContract, $runtimePolicy);
|
||||
|
||||
return $this->mergeResultContext($result, array_filter([
|
||||
'runner_mode' => ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION,
|
||||
'execution_enabled' => true,
|
||||
'runtime_state' => $runtimeGate->context['runtime_state'] ?? null,
|
||||
'credential_state' => $credentialGate->context['credential_state'] ?? null,
|
||||
'permission_evidence_state' => $permissionGate->context['permission_evidence_state'] ?? null,
|
||||
'command_builder_state' => $build->context['command_builder_state'] ?? null,
|
||||
'concurrency_state' => 'released',
|
||||
'duration_ms' => $result->context['duration_ms'] ?? null,
|
||||
'item_count' => $result->context['item_count'] ?? null,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''));
|
||||
} catch (Throwable) {
|
||||
return ExchangePowerShellInvocationResult::failed(
|
||||
reasonCode: ProviderReasonCodes::UnknownError,
|
||||
failureCode: 'execution_failed_unexpected_exception',
|
||||
message: 'Exchange PowerShell production runner failed safely.',
|
||||
context: [
|
||||
'runner_mode' => ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION,
|
||||
'execution_enabled' => true,
|
||||
'failure_mode' => 'unexpected_exception',
|
||||
'concurrency_state' => 'release_attempted',
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
if ($providerAcquired) {
|
||||
optional($providerLock)->release();
|
||||
}
|
||||
|
||||
if ($workspaceAcquired) {
|
||||
optional($workspaceLock)->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function blocked(string $failureCode, array $context = []): ExchangePowerShellInvocationResult
|
||||
{
|
||||
return ExchangePowerShellInvocationResult::blocked(
|
||||
reasonCode: ProviderReasonCodes::ProviderBindingUnsupported,
|
||||
failureCode: $failureCode,
|
||||
message: 'Exchange PowerShell production runner was blocked.',
|
||||
context: $context,
|
||||
);
|
||||
}
|
||||
|
||||
private function blockedFromGate(ExchangePowerShellGateResult $gate): ExchangePowerShellInvocationResult
|
||||
{
|
||||
return ExchangePowerShellInvocationResult::blocked(
|
||||
reasonCode: $gate->reasonCode ?? ProviderReasonCodes::ProviderBindingUnsupported,
|
||||
failureCode: $gate->failureCode ?? 'execution_blocked_gate',
|
||||
message: $gate->message ?? 'Exchange PowerShell production runner gate blocked execution.',
|
||||
context: [
|
||||
'runner_mode' => ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION,
|
||||
'execution_enabled' => false,
|
||||
...$gate->context,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function blockedFromBuild(ExchangePowerShellProcessCommandBuilderResult $build): ExchangePowerShellInvocationResult
|
||||
{
|
||||
return ExchangePowerShellInvocationResult::blocked(
|
||||
reasonCode: $build->reasonCode ?? ProviderReasonCodes::ProviderBindingUnsupported,
|
||||
failureCode: $build->failureCode ?? 'command_contract_rejected',
|
||||
message: $build->message ?? 'Exchange PowerShell command construction was rejected.',
|
||||
context: [
|
||||
'runner_mode' => ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION,
|
||||
'execution_enabled' => false,
|
||||
...$build->context,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function mergeResultContext(ExchangePowerShellInvocationResult $result, array $context): ExchangePowerShellInvocationResult
|
||||
{
|
||||
if ($result->successful) {
|
||||
return ExchangePowerShellInvocationResult::succeeded(
|
||||
items: is_array($result->collection) ? $result->collection : [],
|
||||
summaryCounts: $result->summaryCounts,
|
||||
context: [
|
||||
...$result->context,
|
||||
...$context,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if ($result->blocked) {
|
||||
return ExchangePowerShellInvocationResult::blocked(
|
||||
reasonCode: $result->reasonCode ?? ProviderReasonCodes::ProviderBindingUnsupported,
|
||||
failureCode: $result->failureCode ?? 'execution_blocked',
|
||||
message: $result->message,
|
||||
context: [
|
||||
...$result->context,
|
||||
...$context,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ExchangePowerShellInvocationResult::failed(
|
||||
reasonCode: $result->reasonCode ?? ProviderReasonCodes::UnknownError,
|
||||
failureCode: $result->failureCode ?? 'execution_failed',
|
||||
message: $result->message,
|
||||
summaryCounts: $result->summaryCounts,
|
||||
context: [
|
||||
...$result->context,
|
||||
...$context,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantConfiguration;
|
||||
|
||||
final readonly class ExchangePowerShellRuntimePolicy
|
||||
{
|
||||
/**
|
||||
* @param list<string> $allowedEnvironments
|
||||
*/
|
||||
public function __construct(
|
||||
public bool $invocationEnabled,
|
||||
public bool $productionRunnerEnabled,
|
||||
public array $allowedEnvironments,
|
||||
public string $currentEnvironment,
|
||||
public string $powershellBinary,
|
||||
public bool $moduleCheckEnabled,
|
||||
public int $checkTimeoutSeconds,
|
||||
public int $invocationTimeoutSeconds,
|
||||
public int $stdoutMaxBytes,
|
||||
public int $stderrMaxBytes,
|
||||
public int $itemLimit,
|
||||
public int $lockTtlSeconds,
|
||||
public bool $tempFilesEnabled,
|
||||
public ?string $tempDirectory,
|
||||
) {}
|
||||
|
||||
public static function fromConfig(): self
|
||||
{
|
||||
$runtime = config('tenantpilot.exchange_powershell.runtime', []);
|
||||
$runtime = is_array($runtime) ? $runtime : [];
|
||||
|
||||
return new self(
|
||||
invocationEnabled: (bool) config(ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH, false),
|
||||
productionRunnerEnabled: (bool) config(ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH, false),
|
||||
allowedEnvironments: self::stringList($runtime['allowed_environments'] ?? ['local', 'testing']),
|
||||
currentEnvironment: app()->environment(),
|
||||
powershellBinary: self::nonEmptyString($runtime['powershell_binary'] ?? null, 'pwsh'),
|
||||
moduleCheckEnabled: (bool) ($runtime['module_check_enabled'] ?? true),
|
||||
checkTimeoutSeconds: max(0, (int) ($runtime['check_timeout_seconds'] ?? 5)),
|
||||
invocationTimeoutSeconds: max(0, (int) ($runtime['invocation_timeout_seconds'] ?? 60)),
|
||||
stdoutMaxBytes: max(0, (int) ($runtime['stdout_max_bytes'] ?? 524288)),
|
||||
stderrMaxBytes: max(0, (int) ($runtime['stderr_max_bytes'] ?? 65536)),
|
||||
itemLimit: max(0, (int) ($runtime['item_limit'] ?? 1000)),
|
||||
lockTtlSeconds: max(0, (int) ($runtime['lock_ttl_seconds'] ?? 300)),
|
||||
tempFilesEnabled: (bool) ($runtime['temp_files_enabled'] ?? false),
|
||||
tempDirectory: self::nullableString($runtime['temp_directory'] ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
public function environmentAllowed(): bool
|
||||
{
|
||||
return in_array($this->currentEnvironment, $this->allowedEnvironments, true);
|
||||
}
|
||||
|
||||
public function timeoutPolicyValid(): bool
|
||||
{
|
||||
return $this->checkTimeoutSeconds > 0
|
||||
&& $this->invocationTimeoutSeconds > 0
|
||||
&& $this->stdoutMaxBytes > 0
|
||||
&& $this->stderrMaxBytes > 0
|
||||
&& $this->itemLimit > 0
|
||||
&& $this->lockTtlSeconds > 0;
|
||||
}
|
||||
|
||||
public function tempPolicySafe(): bool
|
||||
{
|
||||
if (! $this->tempFilesEnabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->tempDirectory === null || $this->tempDirectory === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowedPrefix = rtrim(storage_path('app/tmp'), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
|
||||
$directory = rtrim($this->tempDirectory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
|
||||
|
||||
return str_starts_with($directory, $allowedPrefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function stringList(mixed $values): array
|
||||
{
|
||||
if (is_string($values)) {
|
||||
$values = explode(',', $values);
|
||||
}
|
||||
|
||||
if (! is_array($values)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
array_map(static fn (mixed $value): string => trim((string) $value), $values),
|
||||
static fn (string $value): bool => $value !== '',
|
||||
));
|
||||
}
|
||||
|
||||
private static function nonEmptyString(mixed $value, string $fallback): string
|
||||
{
|
||||
$value = is_string($value) ? trim($value) : '';
|
||||
|
||||
return $value !== '' ? $value : $fallback;
|
||||
}
|
||||
|
||||
private static function nullableString(mixed $value): ?string
|
||||
{
|
||||
$value = is_string($value) ? trim($value) : '';
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantConfiguration;
|
||||
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use Throwable;
|
||||
|
||||
final class ExchangePowerShellRuntimeReadinessChecker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ExchangePowerShellProcessExecutor $executor,
|
||||
) {}
|
||||
|
||||
public function check(?ExchangePowerShellRuntimePolicy $policy = null): ExchangePowerShellGateResult
|
||||
{
|
||||
$policy ??= ExchangePowerShellRuntimePolicy::fromConfig();
|
||||
|
||||
try {
|
||||
if (! $policy->invocationEnabled) {
|
||||
return $this->blocked('runtime_blocked_feature_disabled', [
|
||||
'runtime_state' => 'blocked_feature_disabled',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $policy->productionRunnerEnabled) {
|
||||
return $this->blocked('runtime_blocked_runner_disabled', [
|
||||
'runtime_state' => 'blocked_runner_disabled',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $policy->environmentAllowed()) {
|
||||
return $this->blocked('runtime_blocked_environment_not_allowed', [
|
||||
'runtime_state' => 'environment_not_allowed',
|
||||
'runtime_environment' => $policy->currentEnvironment,
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $policy->timeoutPolicyValid()) {
|
||||
return $this->blocked('runtime_blocked_timeout_policy_missing', [
|
||||
'runtime_state' => 'timeout_policy_missing',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $policy->tempPolicySafe()) {
|
||||
return $this->blocked('runtime_blocked_temp_policy_unsafe', [
|
||||
'runtime_state' => 'temp_policy_unsafe',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $this->executor->available()) {
|
||||
return $this->blocked('runtime_blocked_process_executor_missing', [
|
||||
'runtime_state' => 'process_executor_missing',
|
||||
]);
|
||||
}
|
||||
|
||||
$powershell = $this->executor->run(new ExchangePowerShellProcessCommand(
|
||||
arguments: [
|
||||
$policy->powershellBinary,
|
||||
'-NoLogo',
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
'$PSVersionTable.PSVersion.ToString()',
|
||||
],
|
||||
timeoutSeconds: $policy->checkTimeoutSeconds,
|
||||
stdoutMaxBytes: 4096,
|
||||
stderrMaxBytes: 4096,
|
||||
));
|
||||
|
||||
if ($powershell->timedOut || $powershell->exitCode !== 0 || trim($powershell->stdout) === '') {
|
||||
return $this->blocked('runtime_blocked_powershell_missing', [
|
||||
'runtime_state' => 'powershell_missing',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $policy->moduleCheckEnabled) {
|
||||
return ExchangePowerShellGateResult::allowed([
|
||||
'runtime_state' => 'ready',
|
||||
'module_state' => 'unchecked',
|
||||
]);
|
||||
}
|
||||
|
||||
$module = $this->executor->run(new ExchangePowerShellProcessCommand(
|
||||
arguments: [
|
||||
$policy->powershellBinary,
|
||||
'-NoLogo',
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
'Get-Module -ListAvailable -Name ExchangeOnlineManagement',
|
||||
],
|
||||
timeoutSeconds: $policy->checkTimeoutSeconds,
|
||||
stdoutMaxBytes: 4096,
|
||||
stderrMaxBytes: 4096,
|
||||
));
|
||||
|
||||
if ($module->timedOut || $module->exitCode !== 0) {
|
||||
return $this->blocked('runtime_blocked_exchange_module_unknown', [
|
||||
'runtime_state' => 'exchange_module_unknown',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! str_contains(trim($module->stdout), 'ExchangeOnlineManagement')) {
|
||||
return $this->blocked('runtime_blocked_exchange_module_missing', [
|
||||
'runtime_state' => 'exchange_module_missing',
|
||||
]);
|
||||
}
|
||||
|
||||
return ExchangePowerShellGateResult::allowed([
|
||||
'runtime_state' => 'ready',
|
||||
'module_state' => 'available',
|
||||
]);
|
||||
} catch (Throwable) {
|
||||
return ExchangePowerShellGateResult::blocked(
|
||||
reasonCode: ProviderReasonCodes::UnknownError,
|
||||
failureCode: 'runtime_failed_unexpected',
|
||||
message: 'Exchange PowerShell runtime readiness failed safely.',
|
||||
context: ['runtime_state' => 'unexpected_failure'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function blocked(string $failureCode, array $context): ExchangePowerShellGateResult
|
||||
{
|
||||
return ExchangePowerShellGateResult::blocked(
|
||||
reasonCode: ProviderReasonCodes::ProviderBindingUnsupported,
|
||||
failureCode: $failureCode,
|
||||
message: 'Exchange PowerShell runtime readiness is not satisfied.',
|
||||
context: $context,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantConfiguration;
|
||||
|
||||
final class FakeExchangePowerShellProcessExecutor implements ExchangePowerShellProcessExecutor
|
||||
{
|
||||
/**
|
||||
* @var list<array{arguments: list<string>, timeout_seconds: int}>
|
||||
*/
|
||||
public array $calls = [];
|
||||
|
||||
/**
|
||||
* @param list<ExchangePowerShellProcessResult> $results
|
||||
*/
|
||||
public function __construct(
|
||||
private bool $available = true,
|
||||
private array $results = [],
|
||||
) {}
|
||||
|
||||
public function setAvailable(bool $available): void
|
||||
{
|
||||
$this->available = $available;
|
||||
}
|
||||
|
||||
public function pushResult(ExchangePowerShellProcessResult $result): void
|
||||
{
|
||||
$this->results[] = $result;
|
||||
}
|
||||
|
||||
public function available(): bool
|
||||
{
|
||||
return $this->available;
|
||||
}
|
||||
|
||||
public function run(ExchangePowerShellProcessCommand $command): ExchangePowerShellProcessResult
|
||||
{
|
||||
$this->calls[] = [
|
||||
'arguments' => $command->arguments,
|
||||
'timeout_seconds' => $command->timeoutSeconds,
|
||||
];
|
||||
|
||||
return array_shift($this->results) ?? ExchangePowerShellProcessResult::completed(0, '[]');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantConfiguration;
|
||||
|
||||
use Symfony\Component\Process\Exception\ProcessTimedOutException;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Throwable;
|
||||
|
||||
final class SymfonyExchangePowerShellProcessExecutor implements ExchangePowerShellProcessExecutor
|
||||
{
|
||||
public function available(): bool
|
||||
{
|
||||
return class_exists(Process::class);
|
||||
}
|
||||
|
||||
public function run(ExchangePowerShellProcessCommand $command): ExchangePowerShellProcessResult
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
$process = null;
|
||||
|
||||
try {
|
||||
$process = new Process($command->arguments, timeout: $command->timeoutSeconds);
|
||||
$process->run();
|
||||
|
||||
return ExchangePowerShellProcessResult::completed(
|
||||
exitCode: $process->getExitCode() ?? 1,
|
||||
stdout: $process->getOutput(),
|
||||
stderr: $process->getErrorOutput(),
|
||||
durationMs: $this->durationMs($startedAt),
|
||||
);
|
||||
} catch (ProcessTimedOutException) {
|
||||
if ($process instanceof Process && $process->isRunning()) {
|
||||
$process->stop(0);
|
||||
}
|
||||
|
||||
return ExchangePowerShellProcessResult::timedOut($this->durationMs($startedAt));
|
||||
} catch (Throwable $exception) {
|
||||
return ExchangePowerShellProcessResult::exception(class_basename($exception), $this->durationMs($startedAt));
|
||||
}
|
||||
}
|
||||
|
||||
private function durationMs(float $startedAt): int
|
||||
{
|
||||
return (int) max(0, round((microtime(true) - $startedAt) * 1000));
|
||||
}
|
||||
}
|
||||
@ -596,6 +596,32 @@
|
||||
'features' => [
|
||||
'conditional_access' => true,
|
||||
'exchange_powershell_invocation' => (bool) env('TENANTPILOT_EXCHANGE_POWERSHELL_INVOCATION_ENABLED', false),
|
||||
'exchange_powershell_production_runner' => (bool) env('TENANTPILOT_EXCHANGE_POWERSHELL_PRODUCTION_RUNNER_ENABLED', false),
|
||||
],
|
||||
|
||||
'exchange_powershell' => [
|
||||
'credentials' => [
|
||||
'supported_reference_kinds' => array_values(array_filter(array_map(
|
||||
static fn (string $kind): string => trim($kind),
|
||||
explode(',', (string) env('TENANTPILOT_EXCHANGE_POWERSHELL_SUPPORTED_REFERENCE_KINDS', '')),
|
||||
))),
|
||||
],
|
||||
'runtime' => [
|
||||
'allowed_environments' => array_values(array_filter(array_map(
|
||||
static fn (string $environment): string => trim($environment),
|
||||
explode(',', (string) env('TENANTPILOT_EXCHANGE_POWERSHELL_ALLOWED_ENVIRONMENTS', 'local,testing')),
|
||||
))),
|
||||
'powershell_binary' => env('TENANTPILOT_EXCHANGE_POWERSHELL_BINARY', 'pwsh'),
|
||||
'module_check_enabled' => (bool) env('TENANTPILOT_EXCHANGE_POWERSHELL_MODULE_CHECK_ENABLED', true),
|
||||
'check_timeout_seconds' => (int) env('TENANTPILOT_EXCHANGE_POWERSHELL_CHECK_TIMEOUT_SECONDS', 5),
|
||||
'invocation_timeout_seconds' => (int) env('TENANTPILOT_EXCHANGE_POWERSHELL_INVOCATION_TIMEOUT_SECONDS', 60),
|
||||
'stdout_max_bytes' => (int) env('TENANTPILOT_EXCHANGE_POWERSHELL_STDOUT_MAX_BYTES', 524288),
|
||||
'stderr_max_bytes' => (int) env('TENANTPILOT_EXCHANGE_POWERSHELL_STDERR_MAX_BYTES', 65536),
|
||||
'item_limit' => (int) env('TENANTPILOT_EXCHANGE_POWERSHELL_ITEM_LIMIT', 1000),
|
||||
'lock_ttl_seconds' => (int) env('TENANTPILOT_EXCHANGE_POWERSHELL_LOCK_TTL_SECONDS', 300),
|
||||
'temp_files_enabled' => (bool) env('TENANTPILOT_EXCHANGE_POWERSHELL_TEMP_FILES_ENABLED', false),
|
||||
'temp_directory' => env('TENANTPILOT_EXCHANGE_POWERSHELL_TEMP_DIRECTORY'),
|
||||
],
|
||||
],
|
||||
|
||||
'bulk_operations' => [
|
||||
|
||||
@ -0,0 +1,727 @@
|
||||
<?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\ProviderCredential;
|
||||
use App\Models\TenantConfigurationResource;
|
||||
use App\Models\TenantConfigurationResourceEvidence;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Services\TenantConfiguration\DisabledExchangePowerShellCommandRunner;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellCommandRunner;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellCredentialReferenceResolver;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellInvocationGate;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellPermissionEvidenceEvaluator;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellProcessExecutor;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellProcessResult;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellProductionRunner;
|
||||
use App\Services\TenantConfiguration\FakeExchangePowerShellProcessExecutor;
|
||||
use App\Services\TenantConfiguration\ResourceTypeRegistry;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Providers\ProviderCredentialKind;
|
||||
use App\Support\Providers\ProviderCredentialSource;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
beforeEach(function (): void {
|
||||
app(ResourceTypeRegistry::class)->syncDefaults();
|
||||
config([
|
||||
ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => false,
|
||||
ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH => false,
|
||||
'tenantpilot.exchange_powershell.credentials.supported_reference_kinds' => [],
|
||||
'tenantpilot.exchange_powershell.runtime.allowed_environments' => [app()->environment()],
|
||||
'tenantpilot.exchange_powershell.runtime.module_check_enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('Spec432 keeps the disabled runner as the default binding and production flag default false', function (): void {
|
||||
$runner = app(ExchangePowerShellCommandRunner::class);
|
||||
|
||||
expect(config(ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH))->toBeFalse()
|
||||
->and($runner)->toBeInstanceOf(DisabledExchangePowerShellCommandRunner::class);
|
||||
});
|
||||
|
||||
it('Spec432 does not select the production runner behind the production flag alone', function (): void {
|
||||
config([ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH => true]);
|
||||
|
||||
$this->app->forgetInstance(ExchangePowerShellCommandRunner::class);
|
||||
|
||||
expect(app(ExchangePowerShellCommandRunner::class))->toBeInstanceOf(DisabledExchangePowerShellCommandRunner::class);
|
||||
});
|
||||
|
||||
it('Spec432 selects the production runner only when invocation and production gates are enabled', function (): void {
|
||||
config([
|
||||
ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true,
|
||||
ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH => true,
|
||||
]);
|
||||
|
||||
$this->app->forgetInstance(ExchangePowerShellCommandRunner::class);
|
||||
|
||||
expect(app(ExchangePowerShellCommandRunner::class))->toBeInstanceOf(ExchangePowerShellProductionRunner::class);
|
||||
});
|
||||
|
||||
it('Spec432 invocation feature flag blocks production mode even when production runner flag is true', function (): void {
|
||||
config([
|
||||
ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH => true,
|
||||
]);
|
||||
|
||||
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec432ProviderConnection($environment);
|
||||
$executor = spec432FakeExecutor();
|
||||
|
||||
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||
tenant: $environment,
|
||||
providerConnection: $connection,
|
||||
actor: $user,
|
||||
canonicalType: 'transportRule',
|
||||
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION,
|
||||
);
|
||||
|
||||
expect($executor->calls)->toBe([])
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||
->and(data_get($run->context, 'failure_code'))->toBe('execution_blocked_feature_disabled')
|
||||
->and(data_get($run->context, 'provider_identity_resolution_mode'))->toBe('production_runner_reference_gate');
|
||||
});
|
||||
|
||||
it('Spec432 production runner flag false blocks production mode before process execution', function (): void {
|
||||
config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]);
|
||||
|
||||
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec432ProviderConnection($environment);
|
||||
$executor = spec432FakeExecutor();
|
||||
|
||||
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||
tenant: $environment,
|
||||
providerConnection: $connection,
|
||||
actor: $user,
|
||||
canonicalType: 'remoteDomain',
|
||||
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION,
|
||||
);
|
||||
|
||||
expect($executor->calls)->toBe([])
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||
->and(data_get($run->context, 'failure_code'))->toBe('execution_blocked_production_runner_disabled')
|
||||
->and(data_get($run->context, 'production_runner_gate.enabled'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('Spec432 production mode blocks without a supported credential reference and does not resolve credential material', function (): void {
|
||||
config([
|
||||
ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true,
|
||||
ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH => true,
|
||||
]);
|
||||
|
||||
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec432ProviderConnection($environment);
|
||||
$executor = spec432FakeExecutor();
|
||||
|
||||
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||
tenant: $environment,
|
||||
providerConnection: $connection,
|
||||
actor: $user,
|
||||
canonicalType: 'transportRule',
|
||||
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION,
|
||||
);
|
||||
|
||||
expect($executor->calls)->toBe([])
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||
->and(data_get($run->context, 'failure_code'))->toBe('credential_blocked_missing_reference')
|
||||
->and(data_get($run->context, 'credential_policy.credential_material_resolved_by_invocation_gate'))->toBeFalse()
|
||||
->and(data_get($run->context, 'runner_result.credential_state'))->toBe('missing');
|
||||
});
|
||||
|
||||
it('Spec432 blocks secret-based app credentials without persisting credential payload or raw output tokens', function (): void {
|
||||
config([
|
||||
ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true,
|
||||
ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH => true,
|
||||
]);
|
||||
|
||||
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec432ProviderConnection($environment);
|
||||
spec432Credential($connection, ProviderCredentialKind::ClientSecret->value, [
|
||||
'payload' => [
|
||||
'client_id' => 'spec432-client',
|
||||
'client_secret' => 'spec432-secret-material',
|
||||
],
|
||||
]);
|
||||
$executor = spec432FakeExecutor();
|
||||
|
||||
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||
tenant: $environment,
|
||||
providerConnection: $connection,
|
||||
actor: $user,
|
||||
canonicalType: 'transportRule',
|
||||
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION,
|
||||
);
|
||||
|
||||
$persisted = json_encode([
|
||||
'context' => $run->context,
|
||||
'failure_summary' => $run->failure_summary,
|
||||
'summary_counts' => $run->summary_counts,
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
expect($executor->calls)->toBe([])
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||
->and(data_get($run->context, 'failure_code'))->toBe('credential_blocked_secret_based_app')
|
||||
->and(data_get($run->context, 'runner_result.credential_state'))->toBe('secret_based_app_unsupported')
|
||||
->and($persisted)
|
||||
->not->toContain('spec432-secret-material')
|
||||
->not->toContain('client_secret')
|
||||
->not->toContain('access_token')
|
||||
->not->toContain('authorization_header')
|
||||
->not->toContain('cookie');
|
||||
});
|
||||
|
||||
it('Spec432 admin consent alone still blocks without verified Exchange permission evidence', function (): void {
|
||||
config([
|
||||
ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true,
|
||||
ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH => true,
|
||||
]);
|
||||
|
||||
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec432ProviderConnection($environment, seedExchangePowerShellPermission: false);
|
||||
$executor = spec432FakeExecutor();
|
||||
|
||||
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||
tenant: $environment,
|
||||
providerConnection: $connection,
|
||||
actor: $user,
|
||||
canonicalType: 'transportRule',
|
||||
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION,
|
||||
);
|
||||
|
||||
expect($executor->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.missing_requirement_keys'))->toContain('provider.exchange_powershell_invocation');
|
||||
});
|
||||
|
||||
it('Spec432 verified permission evidence only advances to the next credential gate by default', function (): void {
|
||||
config([
|
||||
ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true,
|
||||
ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH => true,
|
||||
]);
|
||||
|
||||
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec432ProviderConnection($environment);
|
||||
spec432Credential($connection, ProviderCredentialKind::Certificate->value);
|
||||
$executor = spec432FakeExecutor();
|
||||
|
||||
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||
tenant: $environment,
|
||||
providerConnection: $connection,
|
||||
actor: $user,
|
||||
canonicalType: 'transportRule',
|
||||
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION,
|
||||
);
|
||||
|
||||
expect($executor->calls)->toBe([])
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||
->and(data_get($run->context, 'failure_code'))->toBe('credential_blocked_certificate_unsupported')
|
||||
->and(data_get($run->context, 'runner_result.credential_state'))->toBe('certificate_unsupported');
|
||||
});
|
||||
|
||||
it('Spec432 credential reference resolver covers fail-closed credential states without payload exposure', function (
|
||||
string $kind,
|
||||
array $overrides,
|
||||
string $expectedFailureCode,
|
||||
string $expectedCredentialState,
|
||||
): void {
|
||||
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec432ProviderConnection($environment);
|
||||
spec432Credential($connection, $kind, [
|
||||
'payload' => [
|
||||
'private_key' => 'spec432-private-key-material',
|
||||
'client_secret' => 'spec432-client-secret-material',
|
||||
'federated_token' => 'spec432-federated-token-material',
|
||||
],
|
||||
...$overrides,
|
||||
]);
|
||||
|
||||
$result = app(ExchangePowerShellCredentialReferenceResolver::class)->resolve($connection);
|
||||
$resultJson = json_encode($result, JSON_THROW_ON_ERROR);
|
||||
|
||||
expect($result->allowed)->toBeFalse()
|
||||
->and($result->failureCode)->toBe($expectedFailureCode)
|
||||
->and($result->context['credential_state'])->toBe($expectedCredentialState)
|
||||
->and($resultJson)
|
||||
->not->toContain('spec432-private-key-material')
|
||||
->not->toContain('spec432-client-secret-material')
|
||||
->not->toContain('spec432-federated-token-material')
|
||||
->not->toContain('client_secret');
|
||||
})->with([
|
||||
'certificate missing metadata' => [
|
||||
ProviderCredentialKind::Certificate->value,
|
||||
['last_rotated_at' => null],
|
||||
'credential_blocked_certificate_missing',
|
||||
'certificate_missing',
|
||||
],
|
||||
'certificate expired' => [
|
||||
ProviderCredentialKind::Certificate->value,
|
||||
['expires_at' => now()->subDay()],
|
||||
'credential_blocked_certificate_expired',
|
||||
'certificate_expired',
|
||||
],
|
||||
'certificate unsupported' => [
|
||||
ProviderCredentialKind::Certificate->value,
|
||||
[],
|
||||
'credential_blocked_certificate_unsupported',
|
||||
'certificate_unsupported',
|
||||
],
|
||||
'federated missing metadata' => [
|
||||
ProviderCredentialKind::Federated->value,
|
||||
['last_rotated_at' => null],
|
||||
'credential_blocked_federated_missing',
|
||||
'federated_missing',
|
||||
],
|
||||
'federated unsupported' => [
|
||||
ProviderCredentialKind::Federated->value,
|
||||
[],
|
||||
'credential_blocked_federated_unsupported',
|
||||
'federated_unsupported',
|
||||
],
|
||||
'managed identity unsupported' => [
|
||||
'managed_identity',
|
||||
[],
|
||||
'credential_blocked_managed_identity_unsupported',
|
||||
'managed_identity_unsupported',
|
||||
],
|
||||
'unknown kind' => [
|
||||
'unknown_kind',
|
||||
[],
|
||||
'credential_blocked_unknown_kind',
|
||||
'unknown_kind',
|
||||
],
|
||||
]);
|
||||
|
||||
it('Spec432 permission evidence evaluator fails closed for unvalidated stale and wrong-scope evidence', function (
|
||||
array $detailsOverrides,
|
||||
array $rowOverrides,
|
||||
string $expectedFailureCode,
|
||||
string $expectedEvidenceState,
|
||||
): void {
|
||||
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec432ProviderConnection($environment, seedExchangePowerShellPermission: false);
|
||||
|
||||
ManagedEnvironmentPermission::query()->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'permission_key' => 'Exchange.ManageAsApp',
|
||||
'status' => 'granted',
|
||||
'details' => [
|
||||
'source' => 'spec432-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(),
|
||||
...$detailsOverrides,
|
||||
],
|
||||
'last_checked_at' => now(),
|
||||
...$rowOverrides,
|
||||
]);
|
||||
|
||||
$result = app(ExchangePowerShellPermissionEvidenceEvaluator::class)->evaluate($environment, $connection);
|
||||
|
||||
expect($result->allowed)->toBeFalse()
|
||||
->and($result->failureCode)->toBe($expectedFailureCode)
|
||||
->and($result->context['permission_evidence_state'])->toBe($expectedEvidenceState);
|
||||
})->with([
|
||||
'unvalidated' => [
|
||||
[],
|
||||
['status' => 'missing'],
|
||||
'permission_evidence_blocked_unvalidated',
|
||||
'unvalidated',
|
||||
],
|
||||
'stale' => [
|
||||
[],
|
||||
['last_checked_at' => now()->subDays(31)],
|
||||
'permission_evidence_blocked_stale',
|
||||
'stale',
|
||||
],
|
||||
'wrong workspace' => [
|
||||
['workspace_id' => 999999],
|
||||
[],
|
||||
'permission_evidence_blocked_wrong_workspace',
|
||||
'wrong_workspace',
|
||||
],
|
||||
'wrong environment' => [
|
||||
['managed_environment_id' => 999999],
|
||||
[],
|
||||
'permission_evidence_blocked_wrong_environment',
|
||||
'wrong_environment',
|
||||
],
|
||||
'unsupported provider' => [
|
||||
['provider' => 'other'],
|
||||
[],
|
||||
'permission_evidence_blocked_unsupported_provider',
|
||||
'unsupported_provider',
|
||||
],
|
||||
'wrong provider connection' => [
|
||||
['provider_connection_id' => 999999],
|
||||
[],
|
||||
'permission_evidence_blocked_wrong_provider_connection',
|
||||
'wrong_provider_connection',
|
||||
],
|
||||
]);
|
||||
|
||||
it('Spec432 supported reference kind plus verified evidence can complete through fake process without evidence promotion', function (): void {
|
||||
config([
|
||||
ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true,
|
||||
ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH => true,
|
||||
'tenantpilot.exchange_powershell.credentials.supported_reference_kinds' => ['certificate'],
|
||||
]);
|
||||
|
||||
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec432ProviderConnection($environment);
|
||||
spec432Credential($connection, ProviderCredentialKind::Certificate->value, [
|
||||
'payload' => [
|
||||
'private_key' => 'spec432-private-key-material',
|
||||
'certificate_password' => 'spec432-certificate-password',
|
||||
],
|
||||
]);
|
||||
$executor = spec432FakeExecutor([
|
||||
ExchangePowerShellProcessResult::completed(0, "7.4.0\n"),
|
||||
ExchangePowerShellProcessResult::completed(0, "ExchangeOnlineManagement\n"),
|
||||
ExchangePowerShellProcessResult::completed(0, '[{"id":"rule-1","password":"raw-output-secret"}]', durationMs: 14),
|
||||
]);
|
||||
|
||||
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||
tenant: $environment,
|
||||
providerConnection: $connection,
|
||||
actor: $user,
|
||||
canonicalType: 'transportRule',
|
||||
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION,
|
||||
);
|
||||
|
||||
$persisted = json_encode([
|
||||
'context' => $run->context,
|
||||
'failure_summary' => $run->failure_summary,
|
||||
'summary_counts' => $run->summary_counts,
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
expect($executor->calls)->toHaveCount(3)
|
||||
->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, 'runner_result.runner_mode'))->toBe('production')
|
||||
->and(data_get($run->context, 'runner_result.runtime_state'))->toBe('ready')
|
||||
->and(data_get($run->context, 'runner_result.credential_state'))->toBe('certificate_supported')
|
||||
->and(data_get($run->context, 'runner_result.permission_evidence_state'))->toBe('verified')
|
||||
->and(TenantConfigurationResource::query()->count())->toBe(0)
|
||||
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0)
|
||||
->and($persisted)
|
||||
->not->toContain('raw-output-secret')
|
||||
->not->toContain('spec432-private-key-material')
|
||||
->not->toContain('spec432-certificate-password')
|
||||
->not->toContain('raw_shell_stdout')
|
||||
->not->toContain('raw_shell_stderr');
|
||||
|
||||
$workspaceLock = Cache::lock('exchange-powershell:workspace:'.(int) $environment->workspace_id, 10);
|
||||
$providerLock = Cache::lock('exchange-powershell:provider:'.(int) $connection->getKey(), 10);
|
||||
|
||||
expect($workspaceLock->get())->toBeTrue()
|
||||
->and($providerLock->get())->toBeTrue();
|
||||
|
||||
$providerLock->release();
|
||||
$workspaceLock->release();
|
||||
});
|
||||
|
||||
it('Spec432 concurrency lock blocks conflicting production execution before process command execution and releases owned locks', function (): void {
|
||||
config([
|
||||
ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true,
|
||||
ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH => true,
|
||||
'tenantpilot.exchange_powershell.credentials.supported_reference_kinds' => ['certificate'],
|
||||
]);
|
||||
|
||||
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec432ProviderConnection($environment);
|
||||
spec432Credential($connection, ProviderCredentialKind::Certificate->value);
|
||||
$executor = spec432FakeExecutor([
|
||||
ExchangePowerShellProcessResult::completed(0, "7.4.0\n"),
|
||||
ExchangePowerShellProcessResult::completed(0, "ExchangeOnlineManagement\n"),
|
||||
]);
|
||||
$workspaceLock = Cache::lock('exchange-powershell:workspace:'.(int) $environment->workspace_id, 300);
|
||||
|
||||
expect($workspaceLock->get())->toBeTrue();
|
||||
|
||||
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||
tenant: $environment,
|
||||
providerConnection: $connection,
|
||||
actor: $user,
|
||||
canonicalType: 'transportRule',
|
||||
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION,
|
||||
);
|
||||
|
||||
expect($executor->calls)->toHaveCount(2)
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||
->and(data_get($run->context, 'failure_code'))->toBe('execution_blocked_concurrency_workspace')
|
||||
->and(data_get($run->context, 'runner_result.concurrency_state'))->toBe('workspace_busy');
|
||||
|
||||
$workspaceLock->release();
|
||||
});
|
||||
|
||||
it('Spec432 provider concurrency lock blocks conflicting production execution and releases the workspace lock', function (): void {
|
||||
config([
|
||||
ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true,
|
||||
ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH => true,
|
||||
'tenantpilot.exchange_powershell.credentials.supported_reference_kinds' => ['certificate'],
|
||||
]);
|
||||
|
||||
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec432ProviderConnection($environment);
|
||||
spec432Credential($connection, ProviderCredentialKind::Certificate->value);
|
||||
$executor = spec432FakeExecutor([
|
||||
ExchangePowerShellProcessResult::completed(0, "7.4.0\n"),
|
||||
ExchangePowerShellProcessResult::completed(0, "ExchangeOnlineManagement\n"),
|
||||
]);
|
||||
$providerLock = Cache::lock('exchange-powershell:provider:'.(int) $connection->getKey(), 300);
|
||||
$workspaceProbe = Cache::lock('exchange-powershell:workspace:'.(int) $environment->workspace_id, 10);
|
||||
|
||||
expect($providerLock->get())->toBeTrue();
|
||||
|
||||
try {
|
||||
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||
tenant: $environment,
|
||||
providerConnection: $connection,
|
||||
actor: $user,
|
||||
canonicalType: 'transportRule',
|
||||
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION,
|
||||
);
|
||||
|
||||
$workspaceReleased = $workspaceProbe->get();
|
||||
} finally {
|
||||
if (isset($workspaceReleased) && $workspaceReleased) {
|
||||
$workspaceProbe->release();
|
||||
}
|
||||
|
||||
$providerLock->release();
|
||||
}
|
||||
|
||||
expect($executor->calls)->toHaveCount(2)
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||
->and(data_get($run->context, 'failure_code'))->toBe('execution_blocked_concurrency_provider')
|
||||
->and(data_get($run->context, 'runner_result.concurrency_state'))->toBe('provider_busy')
|
||||
->and($workspaceReleased)->toBeTrue();
|
||||
});
|
||||
|
||||
it('Spec432 releases production locks after timeout and executor exception paths', function (
|
||||
ExchangePowerShellProcessResult $processResult,
|
||||
string $expectedFailureCode,
|
||||
): void {
|
||||
config([
|
||||
ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true,
|
||||
ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH => true,
|
||||
'tenantpilot.exchange_powershell.credentials.supported_reference_kinds' => ['certificate'],
|
||||
]);
|
||||
|
||||
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec432ProviderConnection($environment);
|
||||
spec432Credential($connection, ProviderCredentialKind::Certificate->value);
|
||||
spec432FakeExecutor([
|
||||
ExchangePowerShellProcessResult::completed(0, "7.4.0\n"),
|
||||
ExchangePowerShellProcessResult::completed(0, "ExchangeOnlineManagement\n"),
|
||||
$processResult,
|
||||
]);
|
||||
|
||||
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||
tenant: $environment,
|
||||
providerConnection: $connection,
|
||||
actor: $user,
|
||||
canonicalType: 'transportRule',
|
||||
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION,
|
||||
);
|
||||
|
||||
$workspaceLock = Cache::lock('exchange-powershell:workspace:'.(int) $environment->workspace_id, 10);
|
||||
$providerLock = Cache::lock('exchange-powershell:provider:'.(int) $connection->getKey(), 10);
|
||||
|
||||
expect($run->outcome)->toBe(OperationRunOutcome::Failed->value)
|
||||
->and(data_get($run->context, 'failure_code'))->toBe($expectedFailureCode)
|
||||
->and($workspaceLock->get())->toBeTrue()
|
||||
->and($providerLock->get())->toBeTrue();
|
||||
|
||||
$providerLock->release();
|
||||
$workspaceLock->release();
|
||||
})->with([
|
||||
'timeout' => [ExchangePowerShellProcessResult::timedOut(60000), 'execution_failed_timeout'],
|
||||
'executor exception result' => [ExchangePowerShellProcessResult::exception('RuntimeException'), 'execution_failed_unexpected_exception'],
|
||||
]);
|
||||
|
||||
it('Spec432 preserves provider scope and RBAC boundaries before production execution', function (): void {
|
||||
config([
|
||||
ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true,
|
||||
ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH => true,
|
||||
]);
|
||||
|
||||
[$readonlyUser, $environment] = createMinimalUserWithTenant(role: 'readonly', workspaceRole: 'readonly');
|
||||
$connection = spec432ProviderConnection($environment);
|
||||
|
||||
expect(fn () => app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||
tenant: $environment,
|
||||
providerConnection: $connection,
|
||||
actor: $readonlyUser,
|
||||
canonicalType: 'transportRule',
|
||||
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION,
|
||||
))->toThrow(AuthorizationException::class)
|
||||
->and(OperationRun::query()->count())->toBe(0);
|
||||
|
||||
[$owner, $ownerEnvironment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$otherEnvironment = ManagedEnvironment::factory()->create([
|
||||
'workspace_id' => (int) $ownerEnvironment->workspace_id,
|
||||
]);
|
||||
$otherConnection = spec432ProviderConnection($otherEnvironment);
|
||||
|
||||
expect(fn () => app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||
tenant: $ownerEnvironment,
|
||||
providerConnection: $otherConnection,
|
||||
actor: $owner,
|
||||
canonicalType: 'transportRule',
|
||||
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION,
|
||||
))->toThrow(NotFoundHttpException::class);
|
||||
|
||||
$outsiderEnvironment = ManagedEnvironment::factory()->create();
|
||||
$outsiderConnection = spec432ProviderConnection($outsiderEnvironment);
|
||||
ManagedEnvironmentMembership::query()
|
||||
->where('managed_environment_id', (int) $outsiderEnvironment->getKey())
|
||||
->where('user_id', (int) $owner->getKey())
|
||||
->delete();
|
||||
app(ManagedEnvironmentAccessScopeResolver::class)->clearCache();
|
||||
|
||||
expect(fn () => app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||
tenant: $outsiderEnvironment,
|
||||
providerConnection: $outsiderConnection,
|
||||
actor: $owner,
|
||||
canonicalType: 'transportRule',
|
||||
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION,
|
||||
))->toThrow(NotFoundHttpException::class);
|
||||
});
|
||||
|
||||
it('Spec432 introduces no UI routes jobs migrations tenant id or trigger surface', function (): void {
|
||||
$runtimeFiles = [
|
||||
app_path('Services/TenantConfiguration/ExchangePowerShellProductionRunner.php'),
|
||||
app_path('Services/TenantConfiguration/ExchangePowerShellRuntimeReadinessChecker.php'),
|
||||
app_path('Services/TenantConfiguration/ExchangePowerShellCredentialReferenceResolver.php'),
|
||||
app_path('Services/TenantConfiguration/ExchangePowerShellPermissionEvidenceEvaluator.php'),
|
||||
app_path('Services/TenantConfiguration/ExchangePowerShellProcessCommandBuilder.php'),
|
||||
app_path('Services/TenantConfiguration/ExchangePowerShellOutputGuard.php'),
|
||||
];
|
||||
$runtimeSource = collect($runtimeFiles)
|
||||
->map(fn (string $file): string => file_get_contents($file) ?: '')
|
||||
->implode("\n");
|
||||
|
||||
expect($runtimeSource)
|
||||
->not->toContain('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();
|
||||
});
|
||||
|
||||
function spec432FakeExecutor(array $results = []): FakeExchangePowerShellProcessExecutor
|
||||
{
|
||||
$executor = new FakeExchangePowerShellProcessExecutor(results: $results);
|
||||
app()->instance(ExchangePowerShellProcessExecutor::class, $executor);
|
||||
app()->forgetInstance(ExchangePowerShellCommandRunner::class);
|
||||
|
||||
return $executor;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
function spec432ProviderConnection(
|
||||
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) {
|
||||
spec432SeedExchangePowerShellPermission($environment, $connection);
|
||||
}
|
||||
|
||||
return $connection;
|
||||
}
|
||||
|
||||
function spec432SeedExchangePowerShellPermission(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' => 'spec432-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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
function spec432Credential(ProviderConnection $connection, string $kind, array $overrides = []): ProviderCredential
|
||||
{
|
||||
$storedKind = in_array($kind, ProviderCredentialKind::values(), true)
|
||||
? $kind
|
||||
: ProviderCredentialKind::ClientSecret->value;
|
||||
|
||||
$credential = ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'type' => $storedKind,
|
||||
'credential_kind' => $storedKind,
|
||||
'source' => ProviderCredentialSource::DedicatedManual->value,
|
||||
'last_rotated_at' => now(),
|
||||
'expires_at' => now()->addYear(),
|
||||
...$overrides,
|
||||
]);
|
||||
|
||||
if ($storedKind !== $kind) {
|
||||
DB::table('provider_credentials')
|
||||
->where('id', (int) $credential->getKey())
|
||||
->update([
|
||||
'type' => $kind,
|
||||
'credential_kind' => $kind,
|
||||
]);
|
||||
}
|
||||
|
||||
return $credential;
|
||||
}
|
||||
@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellCommandContract;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellOutputGuard;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellProcessCommand;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellProcessCommandBuilder;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellProcessExecutor;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellProcessResult;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellRuntimePolicy;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellRuntimeReadinessChecker;
|
||||
use App\Services\TenantConfiguration\FakeExchangePowerShellProcessExecutor;
|
||||
|
||||
it('Spec432 runtime readiness returns sanitized blockers without real PowerShell requirements', function (
|
||||
ExchangePowerShellRuntimePolicy $policy,
|
||||
FakeExchangePowerShellProcessExecutor $executor,
|
||||
string $expectedFailureCode,
|
||||
): void {
|
||||
$result = (new ExchangePowerShellRuntimeReadinessChecker($executor))->check($policy);
|
||||
|
||||
expect($result->allowed)->toBeFalse()
|
||||
->and($result->failureCode)->toBe($expectedFailureCode);
|
||||
})->with([
|
||||
'feature disabled' => fn (): array => [
|
||||
spec432RuntimePolicy(invocationEnabled: false),
|
||||
new FakeExchangePowerShellProcessExecutor,
|
||||
'runtime_blocked_feature_disabled',
|
||||
],
|
||||
'runner disabled' => fn (): array => [
|
||||
spec432RuntimePolicy(productionRunnerEnabled: false),
|
||||
new FakeExchangePowerShellProcessExecutor,
|
||||
'runtime_blocked_runner_disabled',
|
||||
],
|
||||
'environment not allowed' => fn (): array => [
|
||||
spec432RuntimePolicy(allowedEnvironments: ['production']),
|
||||
new FakeExchangePowerShellProcessExecutor,
|
||||
'runtime_blocked_environment_not_allowed',
|
||||
],
|
||||
'timeout policy missing' => fn (): array => [
|
||||
spec432RuntimePolicy(checkTimeoutSeconds: 0),
|
||||
new FakeExchangePowerShellProcessExecutor,
|
||||
'runtime_blocked_timeout_policy_missing',
|
||||
],
|
||||
'temp policy unsafe' => fn (): array => [
|
||||
spec432RuntimePolicy(tempFilesEnabled: true, tempDirectory: '/tmp/tenantpilot-unsafe'),
|
||||
new FakeExchangePowerShellProcessExecutor,
|
||||
'runtime_blocked_temp_policy_unsafe',
|
||||
],
|
||||
'process executor missing' => fn (): array => [
|
||||
spec432RuntimePolicy(),
|
||||
new FakeExchangePowerShellProcessExecutor(available: false),
|
||||
'runtime_blocked_process_executor_missing',
|
||||
],
|
||||
'powershell missing' => fn (): array => [
|
||||
spec432RuntimePolicy(),
|
||||
new FakeExchangePowerShellProcessExecutor(results: [
|
||||
ExchangePowerShellProcessResult::completed(1, '', 'not found'),
|
||||
]),
|
||||
'runtime_blocked_powershell_missing',
|
||||
],
|
||||
'module missing' => fn (): array => [
|
||||
spec432RuntimePolicy(),
|
||||
new FakeExchangePowerShellProcessExecutor(results: [
|
||||
ExchangePowerShellProcessResult::completed(0, "7.4.0\n"),
|
||||
ExchangePowerShellProcessResult::completed(0, ''),
|
||||
]),
|
||||
'runtime_blocked_exchange_module_missing',
|
||||
],
|
||||
'module unknown' => fn (): array => [
|
||||
spec432RuntimePolicy(),
|
||||
new FakeExchangePowerShellProcessExecutor(results: [
|
||||
ExchangePowerShellProcessResult::completed(0, "7.4.0\n"),
|
||||
ExchangePowerShellProcessResult::completed(1, '', 'module error'),
|
||||
]),
|
||||
'runtime_blocked_exchange_module_unknown',
|
||||
],
|
||||
]);
|
||||
|
||||
it('Spec432 runtime readiness uses fixed non-invasive argument vectors', function (): void {
|
||||
$executor = new FakeExchangePowerShellProcessExecutor(results: [
|
||||
ExchangePowerShellProcessResult::completed(0, "7.4.0\n"),
|
||||
ExchangePowerShellProcessResult::completed(0, "ExchangeOnlineManagement\n"),
|
||||
]);
|
||||
|
||||
$result = (new ExchangePowerShellRuntimeReadinessChecker($executor))->check(spec432RuntimePolicy());
|
||||
|
||||
$arguments = json_encode($executor->calls, JSON_THROW_ON_ERROR);
|
||||
|
||||
expect($result->allowed)->toBeTrue()
|
||||
->and($executor->calls)->toHaveCount(2)
|
||||
->and($arguments)
|
||||
->toContain('-NoLogo')
|
||||
->toContain('-NoProfile')
|
||||
->toContain('-NonInteractive')
|
||||
->toContain('$PSVersionTable.PSVersion.ToString()')
|
||||
->toContain('Get-Module -ListAvailable -Name ExchangeOnlineManagement')
|
||||
->not->toContain('Select-Object')
|
||||
->not->toContain('| Select-Object')
|
||||
->not->toContain('Connect-ExchangeOnline')
|
||||
->not->toContain('Install-Module')
|
||||
->not->toContain('Invoke-Expression')
|
||||
->not->toContain('Start-Process')
|
||||
->not->toContain('Get-ChildItem Env:')
|
||||
->not->toContain('Read-Host');
|
||||
});
|
||||
|
||||
it('Spec432 runtime readiness catches unexpected executor failures safely', function (): void {
|
||||
$executor = new class implements ExchangePowerShellProcessExecutor
|
||||
{
|
||||
public function available(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function run(ExchangePowerShellProcessCommand $command): ExchangePowerShellProcessResult
|
||||
{
|
||||
throw new RuntimeException('executor boom with client_secret=unsafe');
|
||||
}
|
||||
};
|
||||
|
||||
$result = (new ExchangePowerShellRuntimeReadinessChecker($executor))->check(spec432RuntimePolicy());
|
||||
|
||||
expect($result->allowed)->toBeFalse()
|
||||
->and($result->failureCode)->toBe('runtime_failed_unexpected')
|
||||
->and(json_encode($result->context, JSON_THROW_ON_ERROR))->not->toContain('client_secret');
|
||||
});
|
||||
|
||||
it('Spec432 command builder creates only fixed argument-vector commands from verified contracts', function (string $canonicalType, string $commandName): void {
|
||||
$contracts = new ExchangePowerShellCommandContracts;
|
||||
$verifiedContract = spec432VerifiedContract($canonicalType);
|
||||
|
||||
$result = (new ExchangePowerShellProcessCommandBuilder($contracts))->build($verifiedContract, spec432RuntimePolicy());
|
||||
|
||||
expect($result->successful)->toBeTrue()
|
||||
->and($result->command)->toBeInstanceOf(ExchangePowerShellProcessCommand::class)
|
||||
->and($result->command?->arguments)->toBeArray()
|
||||
->and($result->command?->arguments[0])->toBe('pwsh')
|
||||
->and($result->command?->arguments)->toContain('-NoLogo')
|
||||
->and($result->command?->arguments)->toContain('-NoProfile')
|
||||
->and($result->command?->arguments)->toContain('-NonInteractive')
|
||||
->and($result->command?->arguments[5])->toBe($commandName)
|
||||
->and($result->command?->arguments[5])->not->toContain('Set-')
|
||||
->and($result->command?->arguments[5])->not->toContain('New-')
|
||||
->and($result->command?->arguments[5])->not->toContain('Remove-')
|
||||
->and($result->command?->arguments[5])->not->toContain('|')
|
||||
->and($result->command?->arguments[5])->not->toContain(';')
|
||||
->and($result->command?->arguments[5])->not->toContain('$')
|
||||
->and($result->command?->arguments[5])->not->toContain('>')
|
||||
->and($result->command?->arguments[5])->not->toContain('ConvertTo-Json')
|
||||
->and($result->context['command_builder_state'])->toBe('fixed_allowlist_argument_vector');
|
||||
})->with([
|
||||
'transport rule' => ['transportRule', 'Get-TransportRule'],
|
||||
'remote domain' => ['remoteDomain', 'Get-RemoteDomain'],
|
||||
'inbound connector' => ['inboundConnector', 'Get-InboundConnector'],
|
||||
]);
|
||||
|
||||
it('Spec432 command contracts reject mutation commands shell fragments and unknown parameters before command building', function (mixed $commandName, string $expectedReason): void {
|
||||
$contracts = new ExchangePowerShellCommandContracts;
|
||||
|
||||
expect($contracts->validateCommandName($commandName)['reason_code'])->toBe($expectedReason);
|
||||
})->with([
|
||||
'set command' => ['Set-TransportRule', 'mutation_command_rejected'],
|
||||
'new command' => ['New-TransportRule', 'mutation_command_rejected'],
|
||||
'remove command' => ['Remove-TransportRule', 'mutation_command_rejected'],
|
||||
'enable command' => ['Enable-TransportRule', 'mutation_command_rejected'],
|
||||
'disable command' => ['Disable-TransportRule', 'mutation_command_rejected'],
|
||||
'update command' => ['Update-TransportRule', 'mutation_command_rejected'],
|
||||
'start command' => ['Start-TransportRule', 'mutation_command_rejected'],
|
||||
'stop command' => ['Stop-TransportRule', 'mutation_command_rejected'],
|
||||
'invoke command' => ['Invoke-Expression', 'unsafe_command_text_rejected'],
|
||||
'search command' => ['Search-TransportRule', 'mutation_command_rejected'],
|
||||
'export command' => ['Export-TransportRule', 'mutation_command_rejected'],
|
||||
'import command' => ['Import-TransportRule', 'mutation_command_rejected'],
|
||||
'semicolon chain' => ['Get-TransportRule; Get-RemoteDomain', 'unsafe_command_text_rejected'],
|
||||
'pipe chain' => ['Get-TransportRule | Out-File x', 'unsafe_command_text_rejected'],
|
||||
'redirection' => ['Get-TransportRule > x', 'unsafe_command_text_rejected'],
|
||||
'script fragment' => ['Get-TransportRule $(Get-Process)', 'unsafe_command_text_rejected'],
|
||||
'alias unknown' => ['gtr', 'command_not_allowlisted'],
|
||||
]);
|
||||
|
||||
it('Spec432 verified command contract rejects unknown parameters without exposing parameter values', function (): void {
|
||||
$contracts = new ExchangePowerShellCommandContracts;
|
||||
$contract = $contracts->contractForCanonicalType('transportRule');
|
||||
|
||||
expect($contract)->toBeArray();
|
||||
|
||||
$validation = $contracts->validateInvocation($contract, ['client_secret_super_token' => 'unsafe']);
|
||||
|
||||
expect($validation['accepted'])->toBeFalse()
|
||||
->and($validation['reason_code'])->toBe('unknown_parameter_rejected')
|
||||
->and(json_encode($validation, JSON_THROW_ON_ERROR))
|
||||
->not->toContain('unsafe');
|
||||
});
|
||||
|
||||
it('Spec432 output guard accepts structured JSON collections and summarizes without raw payload persistence', function (): void {
|
||||
$result = (new ExchangePowerShellOutputGuard)->guard(
|
||||
ExchangePowerShellProcessResult::completed(0, '[{"id":"rule-1","secret":"raw-output-secret"}]', durationMs: 12),
|
||||
spec432VerifiedContract('transportRule'),
|
||||
spec432RuntimePolicy(),
|
||||
);
|
||||
|
||||
expect($result->successful)->toBeTrue()
|
||||
->and($result->collection)->toHaveCount(1)
|
||||
->and($result->context['output_state'])->toBe('structured_collection')
|
||||
->and($result->context['item_count'])->toBe(1);
|
||||
});
|
||||
|
||||
it('Spec432 output guard fails safely for unsafe process outputs', function (
|
||||
ExchangePowerShellProcessResult $processResult,
|
||||
string $expectedFailureCode,
|
||||
): void {
|
||||
$result = (new ExchangePowerShellOutputGuard)->guard(
|
||||
$processResult,
|
||||
spec432VerifiedContract('transportRule'),
|
||||
spec432RuntimePolicy(stdoutMaxBytes: 64, stderrMaxBytes: 64, itemLimit: 1),
|
||||
);
|
||||
|
||||
expect($result->successful)->toBeFalse()
|
||||
->and($result->failureCode)->toBe($expectedFailureCode)
|
||||
->and($result->summaryCounts)->toMatchArray([
|
||||
'total' => 1,
|
||||
'processed' => 1,
|
||||
'succeeded' => 0,
|
||||
'failed' => 1,
|
||||
'skipped' => 0,
|
||||
'items' => 0,
|
||||
])
|
||||
->and(json_encode($result->context, JSON_THROW_ON_ERROR))
|
||||
->not->toContain('secret-output');
|
||||
})->with([
|
||||
'warning prefix' => [ExchangePowerShellProcessResult::completed(0, "WARNING: secret-output\n[]"), 'execution_failed_output_shape_unsafe'],
|
||||
'nonzero with stdout' => [ExchangePowerShellProcessResult::completed(1, '[{"id":"rule-1"}]'), 'execution_failed_nonzero_exit'],
|
||||
'timeout' => [ExchangePowerShellProcessResult::timedOut(60000), 'execution_failed_timeout'],
|
||||
'oversized stdout' => [ExchangePowerShellProcessResult::completed(0, str_repeat('A', 128)), 'execution_failed_stdout_oversized'],
|
||||
'oversized stderr' => [ExchangePowerShellProcessResult::completed(0, '[]', str_repeat('B', 128)), 'execution_failed_stderr_oversized'],
|
||||
'binary output' => [ExchangePowerShellProcessResult::completed(0, "\x00\x01"), 'execution_failed_output_encoding'],
|
||||
'scalar output' => [ExchangePowerShellProcessResult::completed(0, '42'), 'execution_failed_shape_unsafe'],
|
||||
'malformed text' => [ExchangePowerShellProcessResult::completed(0, 'raw secret-output transcript'), 'execution_failed_shape_unsafe'],
|
||||
'too many items' => [ExchangePowerShellProcessResult::completed(0, '[{"id":"one"},{"id":"two"}]'), 'execution_failed_item_limit_exceeded'],
|
||||
'executor exception' => [ExchangePowerShellProcessResult::exception('RuntimeException'), 'execution_failed_unexpected_exception'],
|
||||
]);
|
||||
|
||||
function spec432RuntimePolicy(
|
||||
bool $invocationEnabled = true,
|
||||
bool $productionRunnerEnabled = true,
|
||||
array $allowedEnvironments = [],
|
||||
int $checkTimeoutSeconds = 5,
|
||||
int $invocationTimeoutSeconds = 60,
|
||||
int $stdoutMaxBytes = 524288,
|
||||
int $stderrMaxBytes = 65536,
|
||||
int $itemLimit = 1000,
|
||||
int $lockTtlSeconds = 300,
|
||||
bool $tempFilesEnabled = false,
|
||||
?string $tempDirectory = null,
|
||||
): ExchangePowerShellRuntimePolicy {
|
||||
return new ExchangePowerShellRuntimePolicy(
|
||||
invocationEnabled: $invocationEnabled,
|
||||
productionRunnerEnabled: $productionRunnerEnabled,
|
||||
allowedEnvironments: $allowedEnvironments !== [] ? $allowedEnvironments : [app()->environment()],
|
||||
currentEnvironment: app()->environment(),
|
||||
powershellBinary: 'pwsh',
|
||||
moduleCheckEnabled: true,
|
||||
checkTimeoutSeconds: $checkTimeoutSeconds,
|
||||
invocationTimeoutSeconds: $invocationTimeoutSeconds,
|
||||
stdoutMaxBytes: $stdoutMaxBytes,
|
||||
stderrMaxBytes: $stderrMaxBytes,
|
||||
itemLimit: $itemLimit,
|
||||
lockTtlSeconds: $lockTtlSeconds,
|
||||
tempFilesEnabled: $tempFilesEnabled,
|
||||
tempDirectory: $tempDirectory,
|
||||
);
|
||||
}
|
||||
|
||||
function spec432VerifiedContract(string $canonicalType): ExchangePowerShellCommandContract
|
||||
{
|
||||
$contracts = new ExchangePowerShellCommandContracts;
|
||||
$contract = $contracts->contractForCanonicalType($canonicalType);
|
||||
|
||||
if (! is_array($contract)) {
|
||||
throw new RuntimeException('Missing Spec432 command contract.');
|
||||
}
|
||||
|
||||
return ExchangePowerShellCommandContract::fromVerifiedArray($contract, $contracts, []);
|
||||
}
|
||||
@ -0,0 +1,200 @@
|
||||
# Requirements Checklist: Exchange PowerShell Production Runner Boundary and Runtime Gate
|
||||
|
||||
**Purpose**: Preparation and implementation-readiness checklist for Spec 432.
|
||||
**Created**: 2026-07-07
|
||||
**Feature**: `specs/432-exchange-powershell-production-runner-boundary-runtime-gate/spec.md`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [x] Spec 431 is PASS or PASS WITH CONDITIONS with no merge-blocking findings.
|
||||
- [x] Spec 431 provider-operation start bypass is closed.
|
||||
- [x] Spec 431 capability/admin-consent issue is closed.
|
||||
- [x] Spec 431 invalid parameter persistence is sanitized.
|
||||
- [x] Spec 431 disabled runner remains default.
|
||||
- [x] Spec 431 no evidence, no UI, no migration, no `tenant_id`, and no mini-platform proof exists.
|
||||
- [x] OperationRun/provider operation/capability path is registered.
|
||||
|
||||
## Scope
|
||||
|
||||
- [x] Only `transportRule` is included from Exchange transport rules.
|
||||
- [x] Only `remoteDomain` is included from Exchange remote domains.
|
||||
- [x] Only `inboundConnector` is included from Exchange connectors.
|
||||
- [x] No `outboundConnector`.
|
||||
- [x] No `acceptedDomain`.
|
||||
- [x] No `organizationConfig`.
|
||||
- [x] No `mailboxPlan`.
|
||||
- [x] No `sharingPolicy`.
|
||||
- [x] No Teams.
|
||||
- [x] No Exchange Admin API.
|
||||
- [x] No mutation commands.
|
||||
- [x] No evidence.
|
||||
- [x] No compare/render.
|
||||
- [x] No certification.
|
||||
- [x] No restore.
|
||||
- [x] No customer claim.
|
||||
- [x] No UI.
|
||||
- [x] No migration.
|
||||
- [x] No jobs/schedules/listeners that can invoke production runner.
|
||||
|
||||
## Runner Binding
|
||||
|
||||
- [x] Disabled runner remains default.
|
||||
- [x] Production runner is not selected by default.
|
||||
- [x] Explicit production-runner config flag exists and defaults false.
|
||||
- [x] Existing invocation gate is required.
|
||||
- [x] Production-runner gate is required.
|
||||
- [x] Missing credential evidence blocks.
|
||||
- [x] Missing permission evidence blocks.
|
||||
- [x] Config-cache-style behavior is tested.
|
||||
|
||||
## Runtime Readiness
|
||||
|
||||
- [x] Runtime readiness checker exists.
|
||||
- [x] PowerShell availability check is non-invasive.
|
||||
- [x] Exchange module check is non-invasive.
|
||||
- [x] No Microsoft connection.
|
||||
- [x] No tenant session import.
|
||||
- [x] No module installation or download.
|
||||
- [x] No env dump.
|
||||
- [x] No interactive prompt.
|
||||
- [x] Runtime environment allowlist is checked.
|
||||
- [x] Timeout policy is checked.
|
||||
- [x] Process executor availability is checked.
|
||||
- [x] Temp policy is checked if temp files are used.
|
||||
|
||||
## Credential Handling
|
||||
|
||||
- [x] Credential resolver exists.
|
||||
- [x] Credential handling is reference-only.
|
||||
- [x] `client_secret` blocks by default.
|
||||
- [x] Certificate state is tested.
|
||||
- [x] Federated state is tested.
|
||||
- [x] Managed-identity state is tested.
|
||||
- [x] Missing credential blocks.
|
||||
- [x] Expired credential blocks.
|
||||
- [x] Inaccessible credential blocks.
|
||||
- [x] Unsupported credential blocks.
|
||||
- [x] Unknown credential kind blocks.
|
||||
- [x] Credential material is not stored/logged.
|
||||
|
||||
## Permission Evidence
|
||||
|
||||
- [x] Admin consent alone is not sufficient.
|
||||
- [x] Missing Exchange permission evidence blocks.
|
||||
- [x] Unvalidated evidence blocks.
|
||||
- [x] Stale evidence blocks.
|
||||
- [x] Wrong workspace evidence blocks.
|
||||
- [x] Wrong managed environment evidence blocks.
|
||||
- [x] Wrong provider connection evidence blocks.
|
||||
- [x] Unsupported evidence blocks.
|
||||
- [x] Verified evidence only allows the next gate.
|
||||
- [x] No evidence/readiness/customer claim promotion occurs from permission proof.
|
||||
|
||||
## Process / Command Safety
|
||||
|
||||
- [x] Process executor abstraction exists.
|
||||
- [x] Fake process executor is used in tests.
|
||||
- [x] Argument vector / structured process call is used.
|
||||
- [x] Raw shell strings are rejected.
|
||||
- [x] Pipes, script fragments, semicolons, and redirection are rejected.
|
||||
- [x] Aliases and profile loading are rejected.
|
||||
- [x] Operator-supplied command text is rejected.
|
||||
- [x] Mutation commands are rejected.
|
||||
- [x] Unknown parameters are rejected.
|
||||
- [x] Invalid parameter names are not persisted raw.
|
||||
|
||||
## Output Guards
|
||||
|
||||
- [x] Oversized stdout blocks safely.
|
||||
- [x] Oversized stderr blocks safely.
|
||||
- [x] Non-JSON/scalar output blocks safely.
|
||||
- [x] Warning-prefixed output is handled safely.
|
||||
- [x] Non-UTF8/binary output blocks safely.
|
||||
- [x] Non-zero exit with stdout blocks safely.
|
||||
- [x] Timeout cleanup is tested.
|
||||
- [x] Concurrency lock cleanup is tested.
|
||||
- [x] Temp files are absent or cleaned safely.
|
||||
- [x] Raw stdout/stderr is not persisted.
|
||||
|
||||
## OperationRun
|
||||
|
||||
- [x] Public invocation returns OperationRun or safe blocker.
|
||||
- [x] `provider_connection_id` remains context-only.
|
||||
- [x] No `provider_connection_id` migration.
|
||||
- [x] `managed_environment_id` uses existing repo pattern.
|
||||
- [x] Safe summary counts only.
|
||||
- [x] Safe failure category only.
|
||||
- [x] No raw stdout/stderr.
|
||||
- [x] No provider payload.
|
||||
- [x] No secrets.
|
||||
- [x] Transient envelopes are not persisted.
|
||||
- [x] Status/outcome transitions remain service-owned.
|
||||
|
||||
## 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.
|
||||
- [x] No report output.
|
||||
- [x] No Review Pack output.
|
||||
|
||||
## Product Surface / Triggers
|
||||
|
||||
- [x] No routes.
|
||||
- [x] No Filament pages.
|
||||
- [x] No Livewire components.
|
||||
- [x] No navigation.
|
||||
- [x] No UI action.
|
||||
- [x] No dashboard/readiness badge.
|
||||
- [x] No asset changes.
|
||||
- [x] No global search changes.
|
||||
- [x] No job trigger.
|
||||
- [x] No schedule trigger.
|
||||
- [x] No listener trigger.
|
||||
- [x] Browser proof is `N/A - no rendered UI surface changed`.
|
||||
- [x] Human Product Sanity is N/A.
|
||||
|
||||
## Architecture
|
||||
|
||||
- [x] Uses Spec 431 OperationRun path.
|
||||
- [x] Uses provider operation/capability path.
|
||||
- [x] Uses Spec 430 Coverage v2 source contracts.
|
||||
- [x] No migration.
|
||||
- [x] No `tenant_id` in Spec 432 changed runtime artifacts.
|
||||
- [x] No Exchange mini-platform.
|
||||
- [x] No legacy shim.
|
||||
- [x] No fallback reader.
|
||||
- [x] No dual write.
|
||||
- [x] No customer-facing readiness vocabulary.
|
||||
|
||||
## Validation
|
||||
|
||||
- [x] Focused Spec 432 unit tests pass.
|
||||
- [x] Focused Spec 432 feature tests pass.
|
||||
- [x] Spec 431 regressions pass.
|
||||
- [x] Spec 430 regressions pass.
|
||||
- [x] Selected Spec 426/427 no-promotion/source-contract regressions pass.
|
||||
- [x] Selected Spec 417/419/420 identity/registry/generic evidence regressions pass.
|
||||
- [x] Pint passes.
|
||||
- [x] `git diff --check` passes.
|
||||
- [x] `git status --short` is documented.
|
||||
|
||||
## Final Candidate Gate
|
||||
|
||||
Choose one during implementation close-out:
|
||||
|
||||
- [x] PASS
|
||||
- [x] PASS WITH CONDITIONS
|
||||
- [x] FAIL
|
||||
|
||||
PASS requires disabled default, explicit production-runner gate, non-invasive runtime checks, safe credential handling, verified permission-evidence requirement or safe block, process executor abstraction, no-shell command construction, output guards, timeout/concurrency cleanup, sanitized OperationRun context, no evidence, no product promotion, no UI/trigger surface, no migration, no `tenant_id`, no mini-platform, and focused tests/regressions passing.
|
||||
|
||||
PASS WITH CONDITIONS is allowed only for bounded follow-ups that do not weaken runner binding, runtime safety, credential safety, permission evidence, OperationRun ownership, redaction, provider scope, no-evidence posture, no-claim posture, no-trigger posture, or no-mini-platform posture.
|
||||
|
||||
FAIL if production runner activates by default, client-secret or admin-consent-only enables live invocation, raw shell strings or mutation commands can execute, live invocation bypasses OperationRun/provider scope, credential material or raw output persists, evidence/product promotion appears, routes/UI/jobs/schedules/listeners are added, migration or `tenant_id` appears without amendment, or tests do not prove runner boundary and safe-block behavior.
|
||||
@ -0,0 +1,114 @@
|
||||
# Implementation Report: Exchange PowerShell Production Runner Boundary and Runtime Gate
|
||||
|
||||
## Result
|
||||
|
||||
- **Gate result**: PASS.
|
||||
- **Active spec**: `specs/432-exchange-powershell-production-runner-boundary-runtime-gate/`.
|
||||
- **Branch**: `432-exchange-powershell-production-runner-boundary-runtime-gate`.
|
||||
- **HEAD at implementation start**: `9374260a feat: add Exchange PowerShell invocation gate (#498)`.
|
||||
- **Initial dirty state**: only untracked active Spec 432 package.
|
||||
- **Final dirty state**: application/config/test changes plus the active Spec 432 package and this report.
|
||||
- **Analysis/fix iterations**: 4. Iteration 1 found one bounded in-scope timeout-cleanup hardening issue in the Symfony process executor. Iteration 2 added missing direct credential and permission-evidence matrix coverage. Iteration 3 fixed the invalid-enum fixture by using a raw DB corruption fixture for unexpected stored credential kinds. Iteration 4 closed the final manual review findings: production runner binding now requires both invocation and production gates, command construction no longer generates a pipeline/script conversion string, provider-lock conflict coverage was added, the requirements checklist was closed, and Pint was rerun after formatting.
|
||||
|
||||
## Activated Skills and Gates
|
||||
|
||||
- `spec-kit-implementation-loop`: active implementation workflow.
|
||||
- TenantPilot repo gates: spec readiness, workspace-scope safety, RBAC/action safety, OperationRun truth, provider freshness semantics.
|
||||
- `pest-testing`: Pest 4 unit/feature coverage.
|
||||
- Browser skill: not activated; no rendered UI surface changed.
|
||||
|
||||
## Implementation Summary
|
||||
|
||||
- Added explicit `tenantpilot.features.exchange_powershell_production_runner`, default `false`.
|
||||
- Kept `ExchangePowerShellCommandRunner` defaulting to `DisabledExchangePowerShellCommandRunner`; production runner is selected only when both the invocation flag and production-runner flag are enabled.
|
||||
- Added a production runner boundary with runtime readiness, credential-reference, permission-evidence, provider-scope, command-builder, argument-vector process, output, timeout, concurrency, cleanup, and redaction gates.
|
||||
- Added non-invasive runtime readiness checks for PowerShell availability and ExchangeOnlineManagement module presence through a fakeable process executor.
|
||||
- Added reference-only credential handling. Secret-based app credentials block by default; certificate/federated/managed identity remain fail-closed unless explicitly configured as supported reference kinds.
|
||||
- Added verified Exchange permission evidence evaluation using the existing `ManagedEnvironmentPermission` `Exchange.ManageAsApp` path.
|
||||
- Preserved OperationRun-only execution truth and context-only `provider_connection_id`; no new column, migration, evidence row, route, UI, job, schedule, listener, or `tenant_id`.
|
||||
|
||||
## Prerequisite Proof
|
||||
|
||||
- Spec 430 proof retained: only `transportRule`, `remoteDomain`, and `inboundConnector` command contracts are allowed.
|
||||
- Spec 431 proof retained: invocation remains OperationRun-owned, fake runner remains testable, direct provider start gate remains blocked, disabled default remains valid, no evidence/UI/migration/tenant-id promotion exists.
|
||||
- Completed historical specs were not rewritten.
|
||||
|
||||
## Runner and Credential Matrix
|
||||
|
||||
| Case | Result |
|
||||
| --- | --- |
|
||||
| Default config | Disabled runner binding |
|
||||
| Invocation flag false + production flag true | OperationRun blocked before process execution |
|
||||
| Production flag false + invocation flag true | OperationRun blocked before runner/process execution |
|
||||
| No credential reference | Blocked, sanitized `credential_state=missing` |
|
||||
| Secret-based app credential | Blocked, no credential payload/material persisted |
|
||||
| Certificate reference default | Blocked as unsupported |
|
||||
| Certificate reference explicitly supported for test | Advances to runtime/process/output gates |
|
||||
| Missing Exchange permission evidence | Blocked by provider capability before runner execution |
|
||||
| Verified Exchange permission evidence | Advances only to next gate; does not promote evidence/customer claims |
|
||||
|
||||
## Target and No-Promotion Matrix
|
||||
|
||||
| Target | Command | Scope |
|
||||
| --- | --- | --- |
|
||||
| `transportRule` | `Get-TransportRule` | read-only, internal runner boundary |
|
||||
| `remoteDomain` | `Get-RemoteDomain` | read-only, internal runner boundary |
|
||||
| `inboundConnector` | `Get-InboundConnector` | read-only, internal runner boundary |
|
||||
|
||||
No Exchange evidence rows, normalized payloads, comparable/renderable/certified/restore-ready states, Review Pack output, reports, readiness badges, customer claims, routes, navigation, global search, assets, jobs, schedules, listeners, migrations, or Exchange mini-platform were introduced.
|
||||
|
||||
## Safety Proof
|
||||
|
||||
- Runtime checks use fixed argument vectors with `-NoLogo`, `-NoProfile`, and `-NonInteractive`.
|
||||
- Readiness checks do not run `Connect-ExchangeOnline`, install modules, import tenant sessions, dump environment variables, or prompt interactively.
|
||||
- Command construction is from verified contracts only; mutation families, unknown commands, shell fragments, pipelines, redirection, aliases, and unknown parameters are rejected before process execution.
|
||||
- Production process commands pass only the allowlisted `Get-*` command name as an argument-vector item; JSON serialization/pipeline scripting remains out of scope and live non-JSON output fails safely through the output guard.
|
||||
- Process execution accepts structured command objects only.
|
||||
- Output guard rejects non-zero exits, timeouts, oversized stdout/stderr, non-UTF8/binary output, scalar/text/malformed JSON, warning-prefixed output, and item-count overflow.
|
||||
- Timeout cleanup calls process stop when Symfony raises a timeout exception.
|
||||
- Provider and workspace locks are released after success, blocked, failed, timeout, provider-lock conflict, workspace-lock conflict, or executor-exception-result paths covered by focused tests.
|
||||
- Stale-lock handling uses the existing cache lock TTL; no separate stale-lock subsystem was introduced because the repo has no narrower Exchange runner pattern and a new subsystem would exceed this slice.
|
||||
- Temp files are not used.
|
||||
- Summary counts remain flat numeric allowed keys: `total`, `processed`, `succeeded`, `failed`, `skipped`, `items`.
|
||||
- No new summary key was introduced, so `OperationSummaryKeys::all()` was unchanged.
|
||||
|
||||
## Product Surface Contract Close-Out
|
||||
|
||||
- **No-legacy posture**: canonical addition only; no fallback readers, aliases, hidden routes, duplicate UI, or legacy shims.
|
||||
- **Product Surface Impact**: N/A - no rendered product surface changed.
|
||||
- **UI Surface Impact**: N/A - no rendered UI surface changed.
|
||||
- **Browser proof**: N/A - no rendered UI surface changed.
|
||||
- **Human Product Sanity**: N/A - no product surface changed.
|
||||
- **Visible complexity outcome**: neutral; complexity is backend safety boundary only.
|
||||
- **Livewire v4 compliance**: unchanged repo baseline; no Livewire code added.
|
||||
- **Provider registration location**: no Filament/panel provider change; Laravel providers remain in `apps/platform/bootstrap/providers.php`.
|
||||
- **Global search**: no Filament resources or global search behavior changed.
|
||||
- **Destructive/high-impact actions**: no UI actions added; production runner remains backend-gated and read-only `Get-*` only.
|
||||
- **Asset strategy**: no assets; `filament:assets` not required for this slice.
|
||||
- **Deployment impact**: no migrations, queues, schedules, storage, or assets. New env gates default disabled:
|
||||
- `TENANTPILOT_EXCHANGE_POWERSHELL_PRODUCTION_RUNNER_ENABLED=false`
|
||||
- runtime limit/env vars under `TENANTPILOT_EXCHANGE_POWERSHELL_*`
|
||||
- supported reference kinds default empty.
|
||||
|
||||
## Validation
|
||||
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec432 --compact`
|
||||
- **Result**: local Sail harness failed before tests with Symfony Process signal 9; non-Docker fallback was used for validation.
|
||||
- `cd apps/platform && php artisan test --filter=Spec432 --compact`
|
||||
- **Result**: PASS, 72 tests, 424 assertions.
|
||||
- `cd apps/platform && php artisan test --filter='Spec431|Spec430|Spec427ExchangeTeamsNoEvidencePromotion|Spec427ExchangeTeamsNoCompareRenderCertification|Spec426ExchangeTeamsNoTenantId|Spec426ExchangeTeamsNoMiniPlatform|Spec417IdentityNoLegacyNoUiActivation|Spec419M365RegistryExpansion|Spec420M365NoOverclaim|ProviderCapabilityRegistryTest' --compact`
|
||||
- **Result**: PASS, 115 tests, 1422 assertions, 1 PostgreSQL-only skip.
|
||||
- `cd apps/platform && php artisan config:cache && php artisan config:clear`
|
||||
- **Result**: PASS.
|
||||
- `cd apps/platform && ./vendor/bin/pint --dirty --format agent`
|
||||
- **Result**: PASS after formatting the two new Spec 432 test files.
|
||||
- `cd apps/platform && ./vendor/bin/pint --dirty --test --format agent`
|
||||
- **Result**: PASS.
|
||||
- `git diff --check`
|
||||
- **Result**: PASS.
|
||||
|
||||
## Deferred Work
|
||||
|
||||
- Live Exchange Online success remains a non-goal.
|
||||
- Evidence promotion, compare/render/certification/restore/customer claims, and customer-visible readiness are follow-up specs.
|
||||
- Broader Exchange/Teams targets remain out of scope.
|
||||
@ -0,0 +1,321 @@
|
||||
# Implementation Plan: Exchange PowerShell Production Runner Boundary and Runtime Gate
|
||||
|
||||
**Branch**: `432-exchange-powershell-production-runner-boundary-runtime-gate` | **Date**: 2026-07-07 | **Spec**: `specs/432-exchange-powershell-production-runner-boundary-runtime-gate/spec.md`
|
||||
**Input**: Feature specification from `specs/432-exchange-powershell-production-runner-boundary-runtime-gate/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Implement a production-runner boundary and runtime gate for the existing Exchange PowerShell invocation path covering exactly `transportRule`, `remoteDomain`, and `inboundConnector`. Disabled runner remains the default. Production runner selection requires explicit invocation and production-runner config gates plus runtime readiness, credential reference, Exchange permission evidence, provider scope, OperationRun ownership, command safety, process execution safety, output safety, timeout/concurrency controls, and redaction. Live invocation may remain blocked when supported credential or verified permission evidence is absent. No evidence promotion, UI, routes, jobs, schedules, listeners, migrations, 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; Symfony Process may be used if already available through Laravel dependencies
|
||||
**Storage**: PostgreSQL via existing `operation_runs` only; no migration
|
||||
**Testing**: Pest 4 focused unit and feature tests
|
||||
**Validation Lanes**: fast-feedback/confidence focused tests, selected regressions, browser N/A
|
||||
**Target Platform**: Laravel Sail local, Dokploy staging/production with production runner disabled by default
|
||||
**Project Type**: Laravel monolith under `apps/platform`
|
||||
**Performance Goals**: No unbounded process execution; per-invocation timeout and concurrency locks must bound runtime work
|
||||
**Constraints**: No raw shell strings, no Microsoft calls during readiness checks, no module install, no credential material persistence, no provider payload persistence, no rendered UI
|
||||
**Scale/Scope**: Exactly three Exchange target types and one existing invocation operation
|
||||
|
||||
## Preflight Findings
|
||||
|
||||
- Current prep branch before Spec Kit execution: `platform-dev`.
|
||||
- HEAD before Spec Kit execution: `9374260a feat: add Exchange PowerShell invocation gate (#498)`.
|
||||
- Initial dirty state before Spec Kit execution: clean.
|
||||
- Spec Kit helper created branch: `432-exchange-powershell-production-runner-boundary-runtime-gate`.
|
||||
- Existing related packages: `specs/430-exchange-powershell-adapter-contract-slice-1/` and `specs/431-exchange-powershell-invocation-operation-registration-gate/`.
|
||||
- No existing `specs/432-*` package was found before preparation.
|
||||
- Spec 430 implementation report proves the three command contracts and no live provider/evidence/UI/migration scope.
|
||||
- Spec 431 implementation report proves OperationRun/provider operation registration, `exchange_powershell_invoke` capability, fake runner proof, disabled runner default, sanitized context, no evidence, no UI, no migration, and no `tenant_id`.
|
||||
- Current `AppServiceProvider` binds `ExchangePowerShellCommandRunner` to `DisabledExchangePowerShellCommandRunner`.
|
||||
- Current config has `tenantpilot.features.exchange_powershell_invocation` defaulting false through `TENANTPILOT_EXCHANGE_POWERSHELL_INVOCATION_ENABLED`.
|
||||
- Current `OperationSummaryKeys::all()` includes generic keys such as `total`, `processed`, `succeeded`, `failed`, `skipped`, and `items`; use these unless a new key is explicitly justified and tested.
|
||||
- `RunFailureSanitizer` already normalizes provider/auth/permission/timeout-like failures and redacts secret-like strings.
|
||||
|
||||
## 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 runtime and operation safety boundary.
|
||||
- **Native vs custom classification summary**: N/A.
|
||||
- **Shared-family relevance**: OperationRun/provider operation shared contracts only; no rendered UI shared family.
|
||||
- **State layers in scope**: OperationRun context, summary counts, failure reasons, runtime/credential/permission gate states.
|
||||
- **Audience modes in scope**: internal operator/reviewer only; no customer surface.
|
||||
- **Decision/diagnostic/raw hierarchy plan**: No product view. Raw process/provider output is forbidden from persistence.
|
||||
- **Raw/support gating plan**: Raw output and credential material are never stored.
|
||||
- **One-primary-action / duplicate-truth control**: N/A.
|
||||
- **Handling modes by drift class or surface**: N/A.
|
||||
- **Repository-signal treatment**: service-provider binding changes are backend service-container behavior only; panel provider registration remains unchanged.
|
||||
- **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; Laravel panel providers remain in `apps/platform/bootstrap/providers.php`.
|
||||
- **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 and no new links are added.
|
||||
- **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 for product surfaces.
|
||||
- **Implementation report target**: `specs/432-exchange-powershell-production-runner-boundary-runtime-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 panel providers remain in `apps/platform/bootstrap/providers.php`.
|
||||
- **Global search posture**: no Filament resource/global search surface changed.
|
||||
- **Destructive/high-impact action posture**: no UI action added; production runner remains backend-gated and read-only command only.
|
||||
- **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 migrations, queues, scheduler, storage, assets, or browser build. New env/config gate for production runner defaults disabled and must be documented in the implementation report if added.
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes.
|
||||
- **Systems touched**: `ExchangePowerShellInvocationGate`, `ExchangePowerShellCommandRunner`, `DisabledExchangePowerShellCommandRunner`, `OperationRunService`, `ProviderOperationTrustedStarter`, `ProviderCapabilityEvaluator`, `ProviderCapabilityRegistry`, `ProviderOperationRegistry`, `OperationSummaryKeys`, `SummaryCountsNormalizer`, `RunFailureSanitizer`, provider credential/identity infrastructure, and config under `tenantpilot.features`.
|
||||
- **Shared abstractions reused**: OperationRun lifecycle, provider operation/capability registries, SummaryCountsNormalizer, RunFailureSanitizer, Spec 430 command contracts, Spec 431 invocation gate.
|
||||
- **New abstraction introduced? why?**: Production runner, runtime readiness checker, credential resolver/evaluator, permission evidence evaluator, process executor, command builder, and runtime policy may be introduced because process execution and credential safety require explicit testable boundaries.
|
||||
- **Why the existing abstraction was sufficient or insufficient**: Existing paths are sufficient for operation truth, provider scope, and fake/disabled runner proof. They are insufficient for production process execution, non-invasive runtime checks, credential/permission evidence gating, no-shell command construction, output guards, timeout/concurrency cleanup, and runner selection.
|
||||
- **Bounded deviation / spread control**: No generic PowerShell platform, no multi-provider process framework, no evidence writer, no UI. Keep new services under the repo-canonical TenantConfiguration service path unless implementation finds an existing narrower home.
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes, backend OperationRun lifecycle and sanitized context only.
|
||||
- **Central contract reused**: `OperationRunService`, `OperationSummaryKeys`, `SummaryCountsNormalizer`, `RunFailureSanitizer`, and Spec 431 invocation path.
|
||||
- **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 command names, ExchangeOnlineManagement module readiness, Exchange permission evidence, PowerShell process command construction, response/output shape, and command safety.
|
||||
- **Platform-core seams**: OperationRun lifecycle, provider connection/scope, provider operation/capability gating, summary/failure sanitizer, credential-reference metadata, and config gating.
|
||||
- **Neutral platform terms / contracts preserved**: operation, provider connection, capability, managed environment, workspace, runtime readiness, credential reference, permission evidence, process executor, summary counts, failure reason, runner mode.
|
||||
- **Retained provider-specific semantics and why**: Exchange command/runtime semantics stay in provider-owned TenantConfiguration runner-boundary services because Spec 432 is explicitly Exchange PowerShell only.
|
||||
- **Bounded extraction or follow-up path**: document-in-feature; verified credential/permission evidence support, evidence promotion, compare/render/certification, Teams runtime, and customer output remain separate follow-up specs.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
- Inventory-first: no inventory, snapshot, backup, or evidence truth is changed.
|
||||
- Read/write separation: only read-only Exchange `Get-*` commands are in scope; mutation commands are rejected.
|
||||
- Graph contract path: no Microsoft Graph call path changed; no Graph call added.
|
||||
- Deterministic capabilities: provider/actor capability mappings remain central and tested through Spec 431 path.
|
||||
- RBAC-UX: non-member workspace/environment access is 404; member without capability is 403 or repo-equivalent; readonly cannot invoke.
|
||||
- Workspace isolation: workspace and managed environment are mandatory scope inputs.
|
||||
- Tenant isolation: provider connection and permission evidence must match managed environment before process execution.
|
||||
- Feature gates: existing invocation feature flag remains false by default; new production-runner flag must default false.
|
||||
- Credential source: use existing provider credential/identity reference truth; no new credential store.
|
||||
- Run observability: public invocation returns or references OperationRun/safe blocker through the existing invocation path.
|
||||
- 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 gate states and metadata only; no raw output, credentials, tokens, transcripts, or payloads.
|
||||
- Test governance: focused unit/feature tests with fake process executor and fake runtime-readiness seams; no browser lane and no CI dependency on host PowerShell or ExchangeOnlineManagement.
|
||||
- Proportionality: new runtime boundaries are justified by credential safety, shell/process safety, provider scope, and no-promotion truth.
|
||||
- No premature abstraction: no generic PowerShell framework or multi-provider process platform.
|
||||
- Persisted truth: no new tables or persisted evidence truth.
|
||||
- Behavioral state: blocker/failure codes change execution handling and remain bounded to runner safety.
|
||||
- Provider boundary: Exchange-specific terms remain provider-owned and do not become platform-core customer truth.
|
||||
- Product Surface Contract: N/A - no rendered product surface changed.
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Unit for binding/runtime/credential/permission/process/output/sanitizer behavior; Feature for OperationRun/provider scope/no evidence/no trigger/no migration/no `tenant_id`.
|
||||
- **Affected validation lanes**: fast-feedback/confidence focused tests and selected regressions; browser N/A.
|
||||
- **Why this lane mix is the narrowest sufficient proof**: The change is backend service/runtime safety behavior with no rendered UI and no schema.
|
||||
- **Narrowest proving command(s)**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec432 --compact`
|
||||
- selected Spec 431 and Spec 430 regression tests
|
||||
- selected Spec 426/427/417/419/420 no-promotion, identity, registry, and generic evidence regressions
|
||||
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
- `git diff --check`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: workspace, managed environment, provider connection, credential reference, permission evidence, OperationRun, and fake process executor setup. Keep helpers explicit and feature-local unless reused intentionally.
|
||||
- **Expensive defaults or shared helper growth introduced?**: no planned defaults.
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none planned; PostgreSQL-only lock proof must be named if implementation requires it.
|
||||
- **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 expected.
|
||||
- **Review-stop questions**: verify disabled default, no live invocation without all gates, no shell strings, no Microsoft calls during readiness, no credential material, no raw output, no evidence, no UI/trigger surface, no migration, no `tenant_id`, no mini-platform.
|
||||
- **Escalation path**: reject-or-split if implementation attempts live evidence, UI, routes/jobs/schedules/listeners, migrations, broader Exchange/Teams scope, or customer claims.
|
||||
- **Active feature PR close-out entry**: backend runner-boundary safety; no rendered UI surface changed.
|
||||
- **Why no dedicated follow-up spec is needed**: Routine runner-boundary test upkeep stays inside Spec 432. Credential/permission evidence support and evidence promotion are separate follow-up specs only when needed.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/432-exchange-powershell-production-runner-boundary-runtime-gate/
|
||||
|-- spec.md
|
||||
|-- plan.md
|
||||
|-- tasks.md
|
||||
|-- checklists/
|
||||
| `-- requirements.md
|
||||
`-- implementation-report.md
|
||||
```
|
||||
|
||||
### Source Code (likely affected by later implementation)
|
||||
|
||||
```text
|
||||
apps/platform/app/Providers/AppServiceProvider.php
|
||||
apps/platform/config/tenantpilot.php
|
||||
apps/platform/app/Services/TenantConfiguration/ExchangePowerShellInvocationGate.php
|
||||
apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCommandRunner.php
|
||||
apps/platform/app/Services/TenantConfiguration/DisabledExchangePowerShellCommandRunner.php
|
||||
apps/platform/app/Services/TenantConfiguration/ExchangePowerShellInvocationContext.php
|
||||
apps/platform/app/Services/TenantConfiguration/ExchangePowerShellInvocationResult.php
|
||||
apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCommandContract.php
|
||||
apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCommandContracts.php
|
||||
apps/platform/app/Services/TenantConfiguration/ExchangePowerShellProductionRunner.php
|
||||
apps/platform/app/Services/TenantConfiguration/ExchangePowerShellRuntimeReadinessChecker.php
|
||||
apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCredentialReferenceResolver.php
|
||||
apps/platform/app/Services/TenantConfiguration/ExchangePowerShellPermissionEvidenceEvaluator.php
|
||||
apps/platform/app/Services/TenantConfiguration/ExchangePowerShellProcessCommandBuilder.php
|
||||
apps/platform/app/Services/TenantConfiguration/ExchangePowerShellProcessExecutor.php
|
||||
apps/platform/app/Services/TenantConfiguration/FakeExchangePowerShellProcessExecutor.php
|
||||
apps/platform/app/Services/TenantConfiguration/ExchangePowerShellRuntimePolicy.php
|
||||
apps/platform/app/Support/OpsUx/RunFailureSanitizer.php
|
||||
apps/platform/app/Support/OpsUx/OperationSummaryKeys.php
|
||||
apps/platform/app/Support/OpsUx/SummaryCountsNormalizer.php
|
||||
apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityEvaluator.php
|
||||
apps/platform/tests/Unit/Support/TenantConfiguration/
|
||||
apps/platform/tests/Feature/TenantConfiguration/
|
||||
apps/platform/tests/Unit/OpsUx/
|
||||
apps/platform/tests/Feature/Guards/
|
||||
```
|
||||
|
||||
Forbidden source changes:
|
||||
|
||||
```text
|
||||
apps/platform/routes/**
|
||||
apps/platform/resources/views/**
|
||||
apps/platform/app/Filament/**
|
||||
apps/platform/app/Livewire/**
|
||||
apps/platform/database/migrations/**
|
||||
new Exchange-specific evidence tables
|
||||
jobs/schedules/listeners that invoke the production runner
|
||||
customer report or Review Pack output
|
||||
```
|
||||
|
||||
**Structure Decision**: Use the existing TenantConfiguration and OpsUx support paths. Do not create a new Exchange subsystem or base folder without explicit approval.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
| --- | --- | --- |
|
||||
| New runner boundary services | Live process execution requires explicit runtime, credential, permission, process, output, and redaction gates. | Extending the fake/disabled runner only would not prove production execution safety. |
|
||||
| New process executor abstraction | Tests must prove execution behavior without running PowerShell and without shell strings. | Direct Symfony Process use inside the runner would make fake testing and shell-string guard proof weaker. |
|
||||
| New failure states | Operators/reviewers need actionable blocked/failed reasons, and code needs behavior-specific handling. | Collapsing every failure to `unknown_error` would hide unsafe runtime, credential, permission, output, and concurrency conditions. |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Prevent false confidence that Exchange evidence is ready while allowing a safe production-runner boundary to be implemented and tested.
|
||||
- **Existing structure is insufficient because**: Spec 431 has only disabled/fake execution and no production process, credential, permission, runtime readiness, output, timeout, or concurrency proof.
|
||||
- **Narrowest correct implementation**: Add a safely blocked/gated production boundary for the three existing Exchange command contracts.
|
||||
- **Ownership cost created**: Focused service/test surface that future evidence specs must maintain and use.
|
||||
- **Alternative intentionally rejected**: Enabling live execution inside an evidence promotion spec, or using admin consent/feature flag alone as runtime permission proof.
|
||||
- **Release truth**: Current-release safety truth before evidence promotion.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 0 - Preflight
|
||||
|
||||
- Capture branch, HEAD, and dirty state.
|
||||
- Confirm Spec 431 PASS and no open blocking bypass/capability/redaction findings.
|
||||
- Confirm current disabled runner binding and invocation feature flag.
|
||||
- Confirm current credential infrastructure and provider permission evidence path.
|
||||
- Confirm summary keys and sanitizer behavior.
|
||||
- Confirm no evidence/UI/migration/job/schedule/listener scope.
|
||||
|
||||
### Phase 1 - Runner Binding and Config
|
||||
|
||||
- Preserve disabled runner as default.
|
||||
- Add production-runner config flag under a repo-canonical `tenantpilot.features` or nested Exchange PowerShell config path.
|
||||
- Prove default false behavior and repo-canonical config-cache binding stability, using actual `config:cache` when viable or a documented service-container/config-resolved simulation when that is the repo-safe test path.
|
||||
- Production runner may be selected only when invocation gate and production-runner gate are enabled and all other gates pass.
|
||||
|
||||
### Phase 2 - Runtime Readiness
|
||||
|
||||
- Add non-invasive runtime readiness checker.
|
||||
- Check configured PowerShell executable availability without profile loading or shell strings; automated tests must fake the executor/path-check seam.
|
||||
- Check ExchangeOnlineManagement module availability only through a non-invasive fixed command or safe unknown/missing state; automated tests must not require the real module.
|
||||
- Validate runtime environment allowlist, timeout policy, process executor, and temp policy.
|
||||
- Forbid Microsoft connection, module install, env dump, tenant session import, and interactive prompts.
|
||||
|
||||
### Phase 3 - Credential and Permission Gates
|
||||
|
||||
- Add safe credential reference resolver/evaluator.
|
||||
- Block client-secret credentials by default.
|
||||
- Test certificate, federated, managed-identity, missing, expired, inaccessible, unsupported, and unknown states.
|
||||
- Add Exchange permission evidence evaluator.
|
||||
- Preserve admin-consent-not-sufficient behavior.
|
||||
- If no canonical verified evidence exists, fail closed and document Spec 433 as follow-up.
|
||||
|
||||
### Phase 4 - Process Executor and Command Builder
|
||||
|
||||
- Add process executor abstraction and fake executor.
|
||||
- Add command builder using argument vectors or structured process calls.
|
||||
- Build only from Spec 430 command contracts.
|
||||
- Reject shell strings, pipelines, script fragments, redirection, aliases, profile loading, mutation command families, and unknown parameters.
|
||||
|
||||
### Phase 5 - Production Runner Boundary
|
||||
|
||||
- Add production runner service behind the binding/config gates.
|
||||
- Require OperationRun context, runtime readiness, credential reference, permission evidence, provider scope, and redaction policy before process execution.
|
||||
- Return transient runner envelopes only.
|
||||
- Persist no provider payload, stdout, stderr, transcript, or credential material.
|
||||
|
||||
### Phase 6 - Output, Timeout, Concurrency, and Cleanup Guards
|
||||
|
||||
- Guard stdout/stderr byte limits, item limits, UTF-8, structured shape, warning-prefix, non-zero exit, scalar/text output, binary output, and unexpected exceptions.
|
||||
- Enforce per-invocation timeout and process termination/cleanup.
|
||||
- Enforce provider/workspace concurrency locks and cleanup on success/failure/timeout/exception.
|
||||
- Prefer no temp files; if used, prove safe content and cleanup.
|
||||
|
||||
### Phase 7 - OperationRun, Summary, Redaction, and No-Promotion
|
||||
|
||||
- Store only safe OperationRun context.
|
||||
- Reuse existing summary keys unless a narrow tested key is unavoidable.
|
||||
- Add or map sanitized failure reasons.
|
||||
- Prove redaction across context, summaries, failures, runner envelopes, process output, exceptions, logs if testable, and temp files if any.
|
||||
- Prove no evidence, compare/render/certification, restore, customer claim, UI, trigger surface, migration, `tenant_id`, legacy shim, fallback reader, or Exchange mini-platform.
|
||||
|
||||
### Phase 8 - Regression and Report
|
||||
|
||||
- Run focused Spec 432 tests.
|
||||
- Run selected Spec 431/430/426/427/417/419/420 regressions.
|
||||
- Run Pint and `git diff --check`.
|
||||
- Complete `implementation-report.md` with runner/credential/target/no-promotion matrices and validation results.
|
||||
|
||||
## Rollout and Deployment Considerations
|
||||
|
||||
- New production-runner config must default disabled in all environments.
|
||||
- No migration, queue, scheduler, storage, asset, or browser build impact is planned.
|
||||
- Dokploy/staging validation is required before any future spec enables live invocation outside test/fake contexts.
|
||||
- If implementation adds environment variables, the implementation report must list names, defaults, and staging/production validation requirements.
|
||||
|
||||
## Risk Controls
|
||||
|
||||
- Hard stop if production runner can activate by default.
|
||||
- Hard stop if client-secret credentials or admin consent alone enable live invocation.
|
||||
- Hard stop if raw PowerShell strings, mutation commands, or unknown parameters can execute.
|
||||
- Hard stop if raw output, secrets, provider payload, or transcripts persist.
|
||||
- Hard stop if evidence or customer/product claims are promoted.
|
||||
- Hard stop if routes, UI, jobs, schedules, listeners, migrations, or `tenant_id` are introduced without amending the spec.
|
||||
@ -0,0 +1,418 @@
|
||||
# Feature Specification: Exchange PowerShell Production Runner Boundary and Runtime Gate
|
||||
|
||||
**Feature Branch**: `432-exchange-powershell-production-runner-boundary-runtime-gate`
|
||||
**Created**: 2026-07-07
|
||||
**Status**: Implemented / Validated
|
||||
**Input**: User-provided Spec 432 draft for an Exchange PowerShell production-runner boundary and runtime gate.
|
||||
|
||||
## Preparation Selection
|
||||
|
||||
- **Selected candidate**: Spec 432 - Exchange PowerShell Production Runner Boundary and Runtime Gate.
|
||||
- **Source location**: User-provided attachment `pasted-text.txt`, titled `Spec 432 - Exchange PowerShell Production Runner Boundary & Runtime Gate`.
|
||||
- **Why selected**: `docs/product/spec-candidates.md` states that the active automatic next-best-prep queue is empty, but the user supplied an explicit P0 manual candidate. The candidate is the narrow next step after implemented Specs 430 and 431 and prevents future evidence work from bypassing OperationRun, provider-scope, credential, runtime, and redaction gates.
|
||||
- **Roadmap relationship**: Continues the Exchange/Teams Coverage v2 sequence after Spec 429, Spec 430, and Spec 431. It prepares the production-runner safety boundary before any Exchange evidence, compare, render, certification, restore, or customer claim promotion.
|
||||
- **Completed-spec guardrail result**: Specs 430 and 431 are completed implementation context and were not modified. Spec 430 proves the command-contract slice for `transportRule`, `remoteDomain`, and `inboundConnector`. Spec 431 proves OperationRun/provider operation registration, capability gating, fake-runner behavior, disabled default runner, no evidence, no UI, no migration, and no `tenant_id`. No existing `specs/432-*` package existed before this preparation.
|
||||
- **Smallest viable implementation slice**: Define a production-runner boundary that remains disabled by default and can only proceed after explicit config, runtime readiness, supported credential reference, verified Exchange permission evidence, provider scope, OperationRun invocation, command-construction, process-executor, output, timeout, concurrency, and redaction gates all pass. A valid implementation may still block live invocation because credential or permission proof is absent.
|
||||
- **Feature description fed into Spec Kit**: Prepare the Exchange PowerShell production-runner boundary and runtime gate for the three Spec 430 contracts, preserving disabled default behavior, adding explicit production-runner config and safety gates, proving non-invasive runtime and process execution boundaries, and forbidding evidence promotion, product claims, UI, routes, jobs, schedules, listeners, migrations, and `tenant_id`.
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: TenantPilot now has Exchange PowerShell command contracts and an OperationRun-owned fake invocation path, but it does not yet have a production-runner boundary that proves real process execution cannot be accidentally enabled, unsafe, or mistaken for Exchange evidence readiness.
|
||||
- **Today's failure**: A later evidence or capture spec could accidentally turn the disabled runner into live PowerShell execution without proving runtime readiness, credential kind handling, Exchange permission evidence, no-shell command construction, output safety, timeout/concurrency cleanup, OperationRun redaction, and no-promotion boundaries.
|
||||
- **User-visible improvement**: Operators and reviewers gain a trustworthy internal safety claim: Exchange PowerShell production execution is either gated by all required controls or blocked safely with sanitized OperationRun state. TenantPilot does not falsely imply Exchange evidence readiness.
|
||||
- **Smallest enterprise-capable version**: Add only the runner-boundary and runtime gates around the existing Spec 431 invocation path for `transportRule`, `remoteDomain`, and `inboundConnector`. Live invocation may remain blocked until a later credential/permission evidence spec proves support.
|
||||
- **Explicit non-goals**: No evidence rows, no provider output persistence, no content-backed/comparable/renderable/certified/restore/customer state, no UI, no routes, no Filament or Livewire components, no jobs, no schedules, no listeners that can invoke the runner, no migrations, no Teams, no Exchange Admin API, no mutation commands, no customer report, and no `tenant_id`.
|
||||
- **Permanent complexity imported**: Likely adds a production runner class, runtime readiness checker, credential reference resolver/evaluator, permission evidence evaluator, process executor abstraction, command builder, runtime policy object, sanitizer mappings, and focused tests. No new tables or customer-facing semantic framework are planned.
|
||||
- **Why now**: Spec 431 closed the internal OperationRun/fake-runner gate. The next safe prerequisite before any Exchange evidence promotion is proving that a production runner cannot be selected or execute unless all runtime, credential, permission, scope, and output gates pass.
|
||||
- **Why not local**: This cannot live only inside a later capture service because the runner-selection, credential, runtime, provider-scope, process, output, and OperationRun redaction boundaries must protect every future live invocation path.
|
||||
- **Approval class**: Core Enterprise.
|
||||
- **Red flags triggered**: New abstractions and new failure/state vocabulary. Defense: the abstractions are security/runtime boundaries required to prevent shell execution, credential leakage, provider-scope bypass, and false evidence claims. Failure states have execution consequences and remain bounded to the runner gate.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
||||
- **Decision**: approve as a narrow production-runner boundary and runtime-gating slice.
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: canonical-view / environment-bound operation execution boundary.
|
||||
- **Primary Routes**: N/A - no routes or rendered UI surfaces.
|
||||
- **Data Ownership**: Existing `OperationRun` remains execution truth with existing workspace and managed-environment ownership. `provider_connection_id` remains sanitized context metadata only. No new persisted entity or migration.
|
||||
- **RBAC**: Workspace and managed-environment entitlement are required. Non-member or non-entitled scope returns 404. Member without the required invocation capability returns 403 or the repo-equivalent authorization failure. Readonly actors cannot invoke. Provider connection and permission evidence 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**: Production-runner gating must reject cross-workspace provider connections, cross-environment provider connections, and wrong-scope Exchange permission evidence before any process execution.
|
||||
|
||||
## 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**: Live Exchange PowerShell production execution is not shipped. The default binding is disabled, and no production data or external API consumer depends on a previous production runner.
|
||||
|
||||
## 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 explicitly forbids routes, Filament pages, Livewire components, navigation, buttons, dashboards, readiness badges, customer routes, reports, Review Pack output, global search changes, and asset changes. Service-provider binding changes are backend service-container behavior only and must not change panel provider registration or rendered product access.
|
||||
|
||||
## 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 internal/audit truth and no new links are added.
|
||||
- **Canonical status vocabulary**: N/A - no product-facing statuses changed.
|
||||
- **Visible complexity impact**: neutral for product surfaces.
|
||||
- **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, visible complexity outcome, and no completed-spec rewrite assertion.
|
||||
|
||||
## 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 provider-operation execution safety. No rendered interaction family is changed.
|
||||
- **Systems touched**: `ExchangePowerShellInvocationGate`, `ExchangePowerShellCommandRunner`, `DisabledExchangePowerShellCommandRunner`, `OperationRunService`, `ProviderOperationTrustedStarter`, `ProviderCapabilityEvaluator`, `OperationSummaryKeys`, `SummaryCountsNormalizer`, `RunFailureSanitizer`, `ProviderConnection`, provider credential infrastructure, and config binding under `tenantpilot.features`.
|
||||
- **Existing pattern(s) to extend**: Existing OperationRun/provider operation/capability architecture, summary normalization, failure sanitizer, disabled runner default binding, provider credential/identity resolution, and Coverage v2 command contracts.
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: OperationRun lifecycle and provider operation/capability contracts. No UI presenter or renderer is added.
|
||||
- **Why the existing shared path is sufficient or insufficient**: Existing paths provide operation truth, provider scope, capability gating, and disabled/fake-runner boundaries. They are insufficient for production execution because they do not yet prove runtime readiness, credential kind handling, permission evidence, process execution, output guards, timeout/concurrency cleanup, or production-runner selection rules.
|
||||
- **Allowed deviation and why**: A narrow production runner, runtime checker, process executor, command builder, and credential/permission evaluators are allowed because shell/process execution and credential safety require explicit testable boundaries.
|
||||
- **Consistency impact**: Invocation continues to use the Spec 431 OperationRun path; summary counts remain flat numeric allowed keys; failure reasons remain sanitized; provider connection remains scoped context; no customer claim is added.
|
||||
- **Review focus**: Verify no raw shell strings, no mutation commands, no provider payload persistence, no credential material logging, no evidence promotion, and no route/job/schedule/listener trigger.
|
||||
|
||||
## 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 lifecycle and sanitized context only. No rendered start surface or deep link is added.
|
||||
- **Shared OperationRun UX contract/layer reused**: Existing OperationRun lifecycle, `OperationRunService`, `OperationSummaryKeys`, `SummaryCountsNormalizer`, `RunFailureSanitizer`, and Spec 431 invocation gate path.
|
||||
- **Delegated start/completion UX behaviors**: N/A - no toast, DB notification policy change, browser event, run link, artifact link, or rendered surface.
|
||||
- **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 a run reaches terminal status; feature-local completion notifications are forbidden.
|
||||
- **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. Exchange PowerShell command/runtime semantics are provider-owned; OperationRun, provider connection, capability, scope, and summary/failure safety are platform-core seams.
|
||||
- **Seams affected**: service binding, runner interface, runtime readiness, credential reference evaluation, Exchange permission evidence evaluation, provider operation/capability gating, OperationRun context, failure reason mapping, process execution, and command construction.
|
||||
- **Neutral platform terms preserved or introduced**: operation, provider connection, managed environment, workspace, capability, runtime readiness, credential reference, permission evidence, process executor, summary counts, failure reason, runner mode.
|
||||
- **Provider-specific semantics retained and why**: Exchange command names, ExchangeOnlineManagement module checks, and Exchange permission evidence are retained inside the provider-owned runner boundary because this slice is explicitly Exchange PowerShell only.
|
||||
- **Why this does not deepen provider coupling accidentally**: Platform-core remains limited to operation/provider/capability/summary/sanitizer integration. Exchange-specific command and runtime details remain in `TenantConfiguration` runner-boundary services and are not promoted into customer vocabulary, compare semantics, or platform ownership truth.
|
||||
- **Follow-up path**: document-in-feature. Credential/permission evidence support, evidence promotion, compare/render/certification, Teams runtime, and customer output are 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 432 introduces structural runtime safety boundaries. These are allowed only as the narrowest safe shape for production process execution.
|
||||
|
||||
| Concept | Existing mechanism extended | Why needed now | Narrowness control |
|
||||
| --- | --- | --- | --- |
|
||||
| Production runner service | `ExchangePowerShellCommandRunner` binding | Proves a production boundary can exist without becoming default or unsafe. | Disabled runner remains default; production runner selected only by explicit gates. |
|
||||
| Runtime readiness checker | Existing config/service path | Prevents live process execution when PowerShell, module, environment, timeout, executor, or temp policy is unsafe. | Non-invasive checks only; no Microsoft connection, no module install, no env dump. |
|
||||
| Credential reference resolver | Existing provider credential/identity infrastructure | Prevents unsupported or unsafe credential kinds from reaching process execution. | Reference-only metadata; client secret blocks by default; no credential material returned. |
|
||||
| Permission evidence evaluator | Existing provider capability/permission evidence path | Prevents admin-consent-only from implying Exchange PowerShell runtime permission. | If a repo-canonical verified Exchange permission evidence source exists, verified evidence may advance only to the next gate. If no source exists, the evaluator must fail closed until Spec 433; no new evidence persistence required. |
|
||||
| Process executor abstraction | Runner interface/test fake path | Makes command execution fake-testable and blocks shell-string construction. | Argument vectors only; no generic scripting platform. |
|
||||
| Command builder/runtime policy | Spec 430 command contracts | Prevents mutation commands, unknown parameters, shell tokens, and arbitrary command text. | Exactly three command contracts. |
|
||||
| Output/timeout/concurrency guards | OperationRun/failure sanitizer path | Prevents raw stdout/stderr, binary/scalar output, runaway processes, and parallel execution leaks. | Bounded failures and locks only; no provider payload persistence. |
|
||||
| Failure reason mappings | `RunFailureSanitizer` and provider reason codes | Gives actionable, sanitized OperationRun blockers. | Add only behavior-changing runner-boundary reasons when existing codes cannot express them. |
|
||||
|
||||
Answers required by BLOAT-001:
|
||||
|
||||
1. **Current operator problem**: TenantPilot must not imply Exchange runtime or evidence readiness until live execution is safely gated and blocked by default.
|
||||
2. **Why existing structure is insufficient**: Spec 431 has a disabled/fake runner but no production process, runtime readiness, credential kind, permission evidence, no-shell, output, timeout, or concurrency proof.
|
||||
3. **Narrowest correct implementation**: Add a safely blocked production-runner boundary for the three existing contracts only.
|
||||
4. **Ownership cost**: Maintains focused runner-boundary services and tests that future Exchange evidence specs must use.
|
||||
5. **Rejected alternative**: Implement live capture/evidence promotion directly in the next evidence spec or enable the production runner based on admin consent alone.
|
||||
6. **Current-release truth or future-release preparation**: Current-release safety truth required before any live Exchange evidence capture.
|
||||
|
||||
Forbidden proportionality outcomes:
|
||||
|
||||
- no new persisted entity or table
|
||||
- no Exchange evidence table
|
||||
- no Exchange mini-platform
|
||||
- no broad provider framework
|
||||
- no UI surface or customer readiness state
|
||||
- no live invocation default
|
||||
- no compatibility shim or fallback reader
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Unit tests for runtime readiness, credential/permission evaluators, command builder, process executor, output guards, summary/failure/redaction; Feature tests for service binding, OperationRun safety, provider scope/RBAC, no evidence, no UI/trigger, no migration, and no `tenant_id`.
|
||||
- **Validation lane(s)**: fast-feedback and confidence focused Pest lanes; selected Spec 430/431/426/427/417/419/420 regressions; browser N/A.
|
||||
- **Why this classification and these lanes are sufficient**: The change is backend service/runtime safety behavior with no rendered UI and no database schema.
|
||||
- **New or expanded test families**: New Spec 432 unit and feature families under existing TenantConfiguration/provider/OpsUx test locations.
|
||||
- **Fixture / helper cost impact**: May require explicit workspace, managed environment, provider connection, credential reference, permission evidence, fake process executor, and OperationRun setup. Helpers must stay feature-local or opt-in.
|
||||
- **Heavy-family visibility / justification**: No browser or heavy-governance family planned. If implementation requires PostgreSQL-only locking proof, it must be named explicitly in the implementation report.
|
||||
- **Special surface test profile**: N/A - no rendered UI surface changed.
|
||||
- **Standard-native relief or required special coverage**: backend-only; browser and Human Product Sanity N/A.
|
||||
- **Reviewer handoff**: Reviewers must confirm lane fit, no hidden browser/surface cost, no broad helper defaults, and exact validation commands/pass counts in the implementation report.
|
||||
- **Budget / baseline / trend impact**: none expected unless process/locking tests materially expand runtime.
|
||||
- **Escalation needed**: `reject-or-split` if implementation attempts evidence promotion, UI, route/job/schedule/listener triggers, migrations, broader command types, or live invocation without credential and permission proof.
|
||||
- **Active feature PR close-out entry**: Guardrail / backend runner-boundary safety / no rendered UI surface changed.
|
||||
- **Planned validation commands**:
|
||||
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec432 --compact`
|
||||
- selected Spec 431 and Spec 430 regressions
|
||||
- selected Spec 426/427/417/419/420 no-promotion, identity, registry, and generic evidence regressions
|
||||
- `git diff --check`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Disabled Default and Explicit Production Gate (Priority: P1)
|
||||
|
||||
As a platform reviewer, I need the disabled Exchange PowerShell runner to remain the default and any production runner to require explicit config plus invocation gates, so production execution cannot be enabled by accident.
|
||||
|
||||
**Why this priority**: Runner selection is the root safety boundary. If the production runner can activate by default, every other gate is weaker.
|
||||
|
||||
**Independent Test**: Binding/config tests prove default binding remains disabled, production flag defaults false, invocation flag false blocks, production flag false blocks, and repo-canonical config-cache proof preserves the disabled default. Use actual `config:cache` when viable in the lane; otherwise use a service-container/config-resolved simulation and document the choice.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** default config, **When** the service container resolves `ExchangePowerShellCommandRunner`, **Then** it resolves the disabled runner or returns `execution_blocked_runner_disabled`.
|
||||
2. **Given** production-runner config is false, **When** invocation is requested, **Then** production execution does not start.
|
||||
3. **Given** production-runner config is true but credential or permission evidence is missing, **When** invocation is requested, **Then** live execution remains blocked.
|
||||
|
||||
### User Story 2 - Runtime, Credential, and Permission Gates (Priority: P1)
|
||||
|
||||
As a security reviewer, I need runtime readiness, credential kind, and Exchange permission evidence gates before any process execution, so client secrets, admin consent alone, wrong scope, stale evidence, or unsafe runtime cannot invoke Exchange PowerShell.
|
||||
|
||||
**Why this priority**: These gates prevent credential leakage, false readiness, wrong-scope provider calls, and unsafe production host execution.
|
||||
|
||||
**Independent Test**: Unit and feature tests prove non-invasive readiness checks, unsupported credential blocking, missing/wrong/stale permission evidence blocking, and verified evidence allowing only the next gate when a repo-canonical verified evidence source exists. If no source exists, tests prove fail-closed permission evaluation and document Spec 433 follow-up.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** only admin consent evidence, **When** Exchange PowerShell invocation is evaluated, **Then** it blocks as unvalidated or missing Exchange permission evidence.
|
||||
2. **Given** a client-secret credential reference, **When** the production runner gate evaluates credentials, **Then** it blocks by default.
|
||||
3. **Given** PowerShell or ExchangeOnlineManagement readiness is missing or unknown, **When** readiness is checked, **Then** the check does not connect to Microsoft or install modules and returns a sanitized blocked/unknown state.
|
||||
4. **Given** no repo-canonical verified Exchange permission evidence source exists, **When** permission evidence is evaluated, **Then** the evaluator fails closed and records Spec 433 as the follow-up instead of fabricating evidence.
|
||||
|
||||
### User Story 3 - No-Shell Process Boundary and Output Safety (Priority: P1)
|
||||
|
||||
As an implementation reviewer, I need command execution represented as an argument-vector process boundary with fake process tests, output guards, timeouts, concurrency locks, and redaction, so no raw shell string, transcript, binary output, or oversized output can leak or become evidence.
|
||||
|
||||
**Why this priority**: Any future live invocation must prove process execution is safe before evidence promotion can be considered.
|
||||
|
||||
**Independent Test**: Unit tests use a fake process executor to simulate success, empty output, warning-prefixed output, non-zero exit with stdout, timeout, oversized stdout/stderr, binary/non-UTF8 output, scalar output, and unexpected exceptions.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a mutation command or raw shell token, **When** command construction runs, **Then** it rejects the request before process execution and persists only sanitized counts/reasons.
|
||||
2. **Given** non-zero process exit with stdout, **When** output handling runs, **Then** the invocation fails safely and raw stdout is not persisted.
|
||||
3. **Given** a timeout or concurrency conflict, **When** execution is attempted, **Then** the process is terminated or blocked, locks are cleaned up, and OperationRun receives a sanitized failure.
|
||||
|
||||
### User Story 4 - OperationRun Safety and No Product Promotion (Priority: P1)
|
||||
|
||||
As a release reviewer, I need the production-runner boundary to remain internal and no-promotion only, so a safe runner boundary cannot be mistaken for Exchange evidence, readiness, compare, render, restore, or customer claims.
|
||||
|
||||
**Why this priority**: The correct PASS state may still block live invocation. The product must not treat boundary existence as provider evidence.
|
||||
|
||||
**Independent Test**: Feature/guard tests prove no evidence rows, no raw/normalized payload persistence, no content-backed/comparable/renderable/certified/restore/customer state, no UI/routes/jobs/schedules/listeners, no migration, and no `tenant_id` in changed runtime artifacts.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a blocked or successful gated runner attempt, **When** persistence is inspected, **Then** no evidence rows or raw provider payloads exist.
|
||||
2. **Given** OperationRun context, **When** redaction tests inspect it, **Then** it contains only safe runtime, credential, permission, runner, target type, summary, and provider connection metadata.
|
||||
3. **Given** the codebase after implementation, **When** route/UI/job/schedule/listener scans run, **Then** no product or trigger surface can start the production runner.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Missing production-runner config flag or false value.
|
||||
- Invocation feature flag false even when production-runner flag is true.
|
||||
- Unsupported runtime environment.
|
||||
- PowerShell executable missing.
|
||||
- ExchangeOnlineManagement module missing or unknown.
|
||||
- Timeout policy missing or invalid.
|
||||
- Process executor unavailable.
|
||||
- Client secret credential reference.
|
||||
- Certificate credential missing, expired, inaccessible, or unsupported.
|
||||
- Federated or managed-identity credential unsupported by repo/deployment.
|
||||
- Admin consent without Exchange-specific permission evidence.
|
||||
- Permission evidence scoped to the wrong workspace, managed environment, or provider connection.
|
||||
- Warning text before structured payload.
|
||||
- Non-JSON, scalar, binary, non-UTF8, or oversized output.
|
||||
- Non-zero exit with stdout.
|
||||
- Timeout, stale lock, concurrency conflict, and cleanup failure.
|
||||
- Secret-like strings in exception messages, stdout/stderr fixtures, context, or logs.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST preserve the disabled Exchange PowerShell runner as the default service binding.
|
||||
- **FR-002**: System MUST add an explicit production-runner config gate that defaults disabled and is separate from the existing invocation feature flag.
|
||||
- **FR-003**: System MUST require both invocation and production-runner gates before a production runner can be selected or reached.
|
||||
- **FR-004**: System MUST allow a valid PASS state where the production boundary exists but live invocation remains blocked due to missing supported credential evidence or missing verified Exchange permission evidence.
|
||||
- **FR-005**: System MUST add a non-invasive runtime readiness check for configured PowerShell availability, Exchange module availability or unknown/missing state, runtime environment allowlist, timeout policy, process executor availability, and temp policy if temp files are used. Automated tests for readiness MUST use fake executor/path-check seams and must not depend on host PowerShell or ExchangeOnlineManagement availability.
|
||||
- **FR-006**: Runtime readiness MUST NOT connect to Microsoft, run `Connect-ExchangeOnline`, import a tenant session, install modules, download modules, dump environment variables, run interactive prompts, execute arbitrary shell strings, or create evidence.
|
||||
- **FR-007**: System MUST resolve credentials by safe reference metadata only and MUST NOT expose credential material to OperationRun context, logs, exceptions, runner envelopes, process input, or test fixtures.
|
||||
- **FR-008**: Client-secret credentials MUST block Exchange PowerShell production invocation by default unless the spec is amended with explicit secure support.
|
||||
- **FR-009**: Certificate, federated, managed-identity, missing, expired, inaccessible, unsupported, and unknown credential states MUST be tested separately and block unless repo support is explicitly implemented and proven.
|
||||
- **FR-010**: System MUST require verified Exchange-specific permission evidence before live invocation. Admin consent alone MUST NOT evaluate as sufficient. If no repo-canonical verified evidence source exists, the only valid Spec 432 behavior is fail-closed evaluation, not synthesized or newly persisted evidence.
|
||||
- **FR-011**: Permission evidence MUST be scoped to the same workspace, managed environment, and provider connection; missing, unvalidated, stale, wrong-scope, and unsupported evidence MUST block.
|
||||
- **FR-012**: System MUST use a process executor abstraction so tests never invoke real PowerShell.
|
||||
- **FR-013**: Process execution MUST use argument vectors or structured process calls and MUST reject raw shell strings, pipes, script fragments, redirection, semicolon command chaining, aliases, operator-supplied command text, profile loading, and file-write tokens.
|
||||
- **FR-014**: Command construction MUST be generated only from Spec 430 command contracts and must allow only `Get-TransportRule`, `Get-RemoteDomain`, and `Get-InboundConnector`.
|
||||
- **FR-015**: Mutation command families such as `Set-*`, `New-*`, `Remove-*`, `Enable-*`, `Disable-*`, `Update-*`, `Start-*`, `Stop-*`, `Invoke-*`, `Search-*`, `Export-*`, and `Import-*` MUST be rejected.
|
||||
- **FR-016**: Invalid parameter names MUST NOT be persisted raw; only safe counts and sanitized reason codes may persist.
|
||||
- **FR-017**: Output handling MUST enforce stdout/stderr byte limits, item limits, UTF-8 validation, structured payload validation, warning-prefix handling, non-zero exit handling, timeout handling, binary output handling, and scalar/text output handling.
|
||||
- **FR-018**: Timeout and concurrency controls MUST prevent unbounded execution and must clean up locks/processes after success, failure, timeout, and unexpected exceptions.
|
||||
- **FR-019**: OperationRun context MUST store only safe metadata: runner mode, runtime readiness state, credential state, permission evidence state, requested resource types, requested command contracts, provider connection id as context-only metadata, duration, safe summary counts, and sanitized failure categories.
|
||||
- **FR-020**: OperationRun context, summary counts, failure context, runner envelopes, process output, exception messages, and logs MUST redact secrets, tokens, credential material, raw stdout/stderr, PowerShell transcripts, raw provider payloads, mail/message/file content, and other secret-like strings.
|
||||
- **FR-021**: Summary counts MUST use existing allowed keys where possible. Any new key must be added deliberately to `OperationSummaryKeys::all()` and covered by tests proving preservation and unknown-key drop behavior.
|
||||
- **FR-022**: Public invocation MUST return or reference an `OperationRun` or safe blocker and must keep transient provider envelopes inside service/test boundaries.
|
||||
- **FR-023**: Server-side provider scope/RBAC checks MUST reject non-member workspace access as 404, missing managed-environment entitlement as 404, missing execution capability as 403 or repo-equivalent, readonly actors, cross-workspace providers, cross-environment providers, and wrong-scope permission evidence.
|
||||
- **FR-024**: System MUST create no evidence rows, raw evidence payload, normalized evidence payload, content-backed state, comparable state, renderable state, certified state, restore-ready state, customer-ready state, report output, or Review Pack output.
|
||||
- **FR-025**: System MUST add no route, Filament page, Livewire component, navigation item, UI action, dashboard/readiness badge, global search change, asset, job trigger, schedule trigger, or listener trigger that can invoke the production runner.
|
||||
- **FR-026**: System MUST add no migration, `operation_runs.provider_connection_id` column, Exchange-specific evidence table, `tenant_id`, legacy shim, fallback reader, dual write, or Exchange mini-platform.
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- **NFR-001**: All runtime gates must fail closed with sanitized reason codes/messages.
|
||||
- **NFR-002**: Tests must use fake process execution and must not call Microsoft, Exchange Online, or real PowerShell modules.
|
||||
- **NFR-003**: Any temporary file use must be avoided by default. If unavoidable, temp files must contain no secrets/provider payloads, use a safe configured directory, and be cleaned on success, failure, timeout, and exception.
|
||||
- **NFR-004**: Runner-boundary tests must stay focused and avoid creating heavy shared fixture defaults.
|
||||
- **NFR-005**: Deployment defaults must keep the production runner disabled in local, staging, and production unless explicitly configured after validation.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
N/A - no Filament Resource, RelationManager, Page, action, table, form, panel provider, or rendered UI surface is changed.
|
||||
|
||||
## Key Entities / Runtime Concepts
|
||||
|
||||
- **ExchangePowerShellCommandRunner**: Existing runner interface. Default binding remains disabled; production runner may be introduced only behind explicit gates.
|
||||
- **ExchangePowerShellProductionRunner**: Proposed gated production runner boundary. It may execute only after all runtime, credential, permission, scope, command, process, output, timeout, concurrency, and redaction gates pass.
|
||||
- **ExchangePowerShellRuntimeReadinessChecker**: Proposed non-invasive readiness checker for runtime availability and policy.
|
||||
- **ExchangePowerShellCredentialReferenceResolver**: Proposed safe metadata-only credential reference evaluator.
|
||||
- **ExchangePowerShellPermissionEvidenceEvaluator**: Proposed evaluator for Exchange-specific permission proof, allowed to fail closed until a later spec adds verified evidence support.
|
||||
- **ExchangePowerShellProcessExecutor**: Proposed argument-vector process abstraction with a fake executor for tests.
|
||||
- **ExchangePowerShellProcessCommandBuilder**: Proposed builder that maps Spec 430 contracts to safe process arguments without raw shell strings.
|
||||
- **OperationRun**: Existing execution truth. It stores only sanitized context/summary/failure metadata and never provider payloads.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Default service binding resolves to the disabled runner or returns `execution_blocked_runner_disabled` with default config.
|
||||
- **SC-002**: Focused tests prove production runner selection requires both invocation and production-runner gates plus runtime, credential, permission, provider, OperationRun, command, process, output, timeout, concurrency, and redaction gates.
|
||||
- **SC-003**: Focused tests prove client-secret credentials, admin consent alone, missing/wrong/stale permission evidence, unsafe runtime, raw shell strings, mutation commands, unsafe output, timeout, and concurrency conflicts block safely.
|
||||
- **SC-004**: Focused tests prove no raw stdout/stderr/provider payload/secrets persist in OperationRun context, summaries, failures, logs, or temporary artifacts.
|
||||
- **SC-005**: Guard tests prove no evidence rows, no product promotion, no UI/routes/jobs/schedules/listeners, no migrations, no `tenant_id`, and no Exchange mini-platform are introduced.
|
||||
- **SC-006**: Implementation report records branch/HEAD, dirty state, prerequisite proof, runner matrices, validation commands, pass counts, deferred work, Product Surface N/A, and deployment impact.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### AC1. Runner Binding
|
||||
|
||||
- Disabled runner remains default.
|
||||
- Production runner is never selected by default.
|
||||
- Production runner requires explicit config and invocation gate.
|
||||
- Production runner blocks without supported credential reference.
|
||||
- Production runner blocks without verified Exchange permission evidence.
|
||||
|
||||
### AC2. Runtime Readiness
|
||||
|
||||
- Runtime readiness checker exists.
|
||||
- Readiness checks are non-invasive.
|
||||
- No Microsoft connection, tenant session import, module installation, or env dump occurs.
|
||||
- Missing/unknown runtime, module, timeout policy, process executor, temp policy, or environment allowlist blocks safely.
|
||||
|
||||
### AC3. Credential and Permission Evidence
|
||||
|
||||
- Credential resolver is reference-only.
|
||||
- Client secret blocks by default.
|
||||
- Certificate/federated/managed-identity states are tested and block unless explicitly supported.
|
||||
- Admin consent alone is not sufficient.
|
||||
- Missing, unvalidated, stale, wrong-scope, and unsupported Exchange permission evidence blocks.
|
||||
|
||||
### AC4. Process and Command Safety
|
||||
|
||||
- Process executor abstraction exists.
|
||||
- Tests use fake executor.
|
||||
- Execution uses argument vector / structured process calls.
|
||||
- Raw shell strings, pipes, script fragments, redirection, aliases, profile loading, operator-supplied command text, mutation commands, and unknown parameters are rejected.
|
||||
|
||||
### AC5. Output / Timeout / Concurrency
|
||||
|
||||
- Oversized stdout/stderr, scalar/text output, warning-prefixed unsafe output, binary/non-UTF8 output, non-zero exit with stdout, timeout, and unexpected exceptions fail safely.
|
||||
- Per-provider and per-workspace concurrency locks block conflicting execution and clean up on success, failure, timeout, and exception.
|
||||
|
||||
### AC6. OperationRun / Redaction / No Promotion
|
||||
|
||||
- Public invocation returns OperationRun or safe blocker.
|
||||
- OperationRun context is sanitized.
|
||||
- `provider_connection_id` remains context-only.
|
||||
- No raw output, secrets, provider payloads, evidence rows, product promotion, UI, trigger surface, migration, `tenant_id`, fallback reader, or Exchange mini-platform appears.
|
||||
|
||||
## Risks
|
||||
|
||||
- Production-runner selection could accidentally bypass disabled defaults if config binding is too eager.
|
||||
- Credential evidence could be confused with provider admin consent or existing generic provider readiness.
|
||||
- Runtime readiness could become invasive if implemented with module import or connection commands.
|
||||
- Process executor abstractions could drift into a generic scripting platform.
|
||||
- Output guards could accidentally treat malformed or warning-prefixed output as success.
|
||||
- Summary/failure reason additions could become unbounded status vocabulary.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Spec 431 remains implemented and merged in current repo truth.
|
||||
- `tenant_configuration.exchange_powershell_invocation` is the canonical operation type.
|
||||
- `ExchangePowerShellCommandRunner` stays the runner interface unless implementation discovers a repo-canonical equivalent.
|
||||
- The implementation may choose a fail-closed permission evaluator that never returns verified evidence until a later Spec 433 adds real evidence support. In that path, verified-evidence pass-through proof is explicitly N/A and the implementation report must point to the fail-closed tests instead.
|
||||
- No live Microsoft tenant or Exchange Online credentials are required for Spec 432 validation.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None blocking preparation. Implementation must verify whether the repo already has a canonical credential-kind metadata shape and permission evidence source to reuse; if not, Spec 432 should implement a fail-closed evaluator and defer verified evidence to Spec 433.
|
||||
|
||||
## Follow-up Spec Candidates
|
||||
|
||||
- Spec 433 - Exchange Credential / Permission Evidence Support, if verified evidence cannot be produced safely in Spec 432.
|
||||
- Exchange content-backed evidence promotion for the three included types.
|
||||
- Exchange comparable/renderable promotion.
|
||||
- Teams PowerShell adapter contract and invocation/evidence slice.
|
||||
- M365 customer output and claim guard after evidence and compare semantics are proven.
|
||||
@ -0,0 +1,192 @@
|
||||
# Tasks: Exchange PowerShell Production Runner Boundary and Runtime Gate
|
||||
|
||||
**Input**: Design documents from `specs/432-exchange-powershell-production-runner-boundary-runtime-gate/`
|
||||
**Prerequisites**: `spec.md`, `plan.md`, Spec 430 implementation report, Spec 431 implementation report, current repo source truth
|
||||
**Tests**: Required. Runtime behavior changes must use Pest 4 focused unit/feature tests and selected regressions. Browser proof is `N/A - no rendered UI surface changed`.
|
||||
|
||||
## 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, provider setup, process fake setup, and context defaults stay cheap by default or isolated behind explicit opt-ins.
|
||||
- [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 431 implementation report proves PASS/PASS WITH CONDITIONS with no open blocking bypass, capability, raw-parameter, runner-boundary, no-evidence, no-UI, no-migration, or no-tenant findings.
|
||||
- [x] T003 Confirm Spec 430 implementation report proves command contracts for exactly `transportRule`, `remoteDomain`, and `inboundConnector`.
|
||||
- [x] T004 Confirm current disabled runner binding in `apps/platform/app/Providers/AppServiceProvider.php`.
|
||||
- [x] T005 Confirm current invocation feature flag in `apps/platform/config/tenantpilot.php`.
|
||||
- [x] T006 Confirm current credential and provider identity infrastructure to reuse before introducing any resolver.
|
||||
- [x] T007 Confirm current provider permission evidence path and whether verified Exchange permission evidence exists; if not, plan fail-closed evaluator behavior.
|
||||
- [x] T008 Confirm current allowed summary keys in `apps/platform/app/Support/OpsUx/OperationSummaryKeys.php` and unknown-key drop behavior.
|
||||
- [x] T009 Confirm no routes, Filament pages, Livewire components, migrations, jobs, schedules, or listeners are in scope.
|
||||
|
||||
## Phase 2: Runner Binding and Production Config Gate
|
||||
|
||||
- [x] T010 Add or confirm production-runner config path, default false, using repo-canonical config placement.
|
||||
- [x] T011 Preserve `ExchangePowerShellCommandRunner` default binding to `DisabledExchangePowerShellCommandRunner`.
|
||||
- [x] T012 Add binding/config tests proving default config selects or returns disabled runner behavior.
|
||||
- [x] T013 Add tests proving production-runner flag false blocks production execution.
|
||||
- [x] T014 Add tests proving invocation feature flag false blocks production execution even if production-runner flag is true.
|
||||
- [x] T015 Add repo-canonical config-cache proof: use actual `config:cache` when viable in the test lane, otherwise simulate cached/config-resolved state through service-container config resolution and document the choice.
|
||||
- [x] T016 Add tests proving production-runner flag true still blocks without supported credential reference.
|
||||
- [x] T017 Add tests proving production-runner flag true still blocks without verified Exchange permission evidence.
|
||||
|
||||
## Phase 3: Runtime Readiness Checker
|
||||
|
||||
- [x] T018 Add `ExchangePowerShellRuntimeReadinessChecker` or repo-canonical equivalent.
|
||||
- [x] T019 Add runtime policy/config support for allowed environments, timeout policy, process executor availability, and optional temp policy.
|
||||
- [x] T020 Add non-invasive PowerShell availability check using a fixed argument-vector command or safe configured path check; automated tests must fake the executor/path-check seam.
|
||||
- [x] T021 Add non-invasive ExchangeOnlineManagement module availability check or safe `module_unknown` / `module_missing` outcome; automated tests must not require the real module.
|
||||
- [x] T022 Add tests proving readiness checks do not connect to Microsoft, run `Connect-ExchangeOnline`, import tenant sessions, install/download modules, dump env, or prompt interactively.
|
||||
- [x] T023 Add tests for `runtime_blocked_feature_disabled`, `runtime_blocked_runner_disabled`, `runtime_blocked_environment_not_allowed`, `runtime_blocked_powershell_missing`, `runtime_blocked_exchange_module_missing`, `runtime_blocked_exchange_module_unknown`, `runtime_blocked_timeout_policy_missing`, `runtime_blocked_process_executor_missing`, `runtime_blocked_temp_policy_unsafe`, and `runtime_failed_unexpected` or repo-equivalent sanitized outcomes.
|
||||
|
||||
## Phase 4: Credential Reference Resolver
|
||||
|
||||
- [x] T024 Add `ExchangePowerShellCredentialReferenceResolver` or repo-canonical equivalent that uses existing provider credential/identity reference metadata.
|
||||
- [x] T025 Ensure resolver returns only safe credential state metadata and never credential material.
|
||||
- [x] T026 Add test proving missing credential reference blocks.
|
||||
- [x] T027 Add test proving client-secret credential blocks by default.
|
||||
- [x] T028 Add tests for certificate missing, expired, inaccessible, unsupported, and supported-if-implemented states.
|
||||
- [x] T029 Add tests for federated missing/unsupported and managed-identity missing/unsupported states unless repo support is explicitly implemented.
|
||||
- [x] T030 Add tests proving unknown credential kind blocks.
|
||||
- [x] T031 Add redaction tests proving no private key, certificate password, token, client secret, authorization header, cookie, or credential payload reaches OperationRun context, logs, or runner envelopes.
|
||||
|
||||
## Phase 5: Exchange Permission Evidence Gate
|
||||
|
||||
- [x] T032 Add `ExchangePowerShellPermissionEvidenceEvaluator` or repo-canonical equivalent.
|
||||
- [x] T033 Preserve admin-consent-not-sufficient behavior from Spec 431 provider capability correction.
|
||||
- [x] T034 Add test proving admin consent alone blocks as missing/unvalidated Exchange permission evidence.
|
||||
- [x] T035 Add tests proving missing, unvalidated, stale, unsupported, wrong-workspace, wrong-environment, and wrong-provider-connection evidence blocks.
|
||||
- [x] T036 If a repo-canonical verified Exchange permission evidence source exists, add test proving verified evidence allows only the next gate and does not promote evidence/readiness/customer claims; otherwise mark this proof N/A in `implementation-report.md` and rely on T037 fail-closed proof.
|
||||
- [x] T037 If no repo-canonical verified evidence source exists, implement a fail-closed evaluator and document Spec 433 as follow-up in `implementation-report.md`.
|
||||
|
||||
## Phase 6: Process Executor Abstraction
|
||||
|
||||
- [x] T038 Add `ExchangePowerShellProcessExecutor` or repo-canonical equivalent.
|
||||
- [x] T039 Add fake process executor for tests.
|
||||
- [x] T040 Ensure executor accepts argument vectors or structured command objects, not raw shell strings.
|
||||
- [x] T041 Add tests proving raw shell strings, semicolon command chaining, pipes, redirection, script fragments, aliases, profile loading, file-write tokens, and operator-supplied command text are rejected before process execution.
|
||||
- [x] T042 Add fake executor cases for success JSON/structured output, empty output, warning text before payload, non-zero exit with stdout, non-zero exit with stderr, timeout, oversized stdout/stderr, non-UTF8/binary output, malformed scalar output, and unexpected exception.
|
||||
|
||||
## Phase 7: Command Builder
|
||||
|
||||
- [x] T043 Add `ExchangePowerShellProcessCommandBuilder` or repo-canonical equivalent.
|
||||
- [x] T044 Build only from `ExchangePowerShellCommandContract` / `ExchangePowerShellCommandContracts`.
|
||||
- [x] T045 Allow exactly `Get-TransportRule` for `transportRule`, `Get-RemoteDomain` for `remoteDomain`, and `Get-InboundConnector` for `inboundConnector`.
|
||||
- [x] T046 Reject `Set-*`, `New-*`, `Remove-*`, `Enable-*`, `Disable-*`, `Update-*`, `Start-*`, `Stop-*`, `Invoke-*`, `Search-*`, `Export-*`, `Import-*`, and unknown command names.
|
||||
- [x] T047 Reject unknown parameter names and persist only safe rejected-parameter count plus sanitized reason code.
|
||||
- [x] T048 Add tests proving command-builder output is an argument vector or fixed structured process call and contains no user-supplied raw command text.
|
||||
|
||||
## Phase 8: Production Runner Boundary
|
||||
|
||||
- [x] T049 Add `ExchangePowerShellProductionRunner` or repo-canonical equivalent behind explicit binding/config gates.
|
||||
- [x] T050 Require OperationRun context before production runner execution.
|
||||
- [x] T051 Require runtime readiness, credential reference, permission evidence, provider scope, and redaction policy before process execution.
|
||||
- [x] T052 Ensure production runner returns transient result envelopes only.
|
||||
- [x] T053 Ensure no provider payload, raw stdout/stderr, transcript, or credential material is persisted.
|
||||
- [x] T054 Add tests proving production runner blocks until all gates pass.
|
||||
- [x] T055 Add tests proving production runner remains safe when live invocation is blocked by credential or permission evidence absence.
|
||||
|
||||
## Phase 9: Output Guards
|
||||
|
||||
- [x] T056 Add output byte limits for stdout and stderr.
|
||||
- [x] T057 Add item count limit for structured output.
|
||||
- [x] T058 Add UTF-8 and binary output validation.
|
||||
- [x] T059 Add structured payload validation and scalar/text output failure handling.
|
||||
- [x] T060 Add warning-prefix handling that either strips a known safe warning envelope without persisting it or fails as shape unsafe.
|
||||
- [x] T061 Add non-zero exit handling that fails safely even when stdout is present.
|
||||
- [x] T062 Add tests for oversized stdout, oversized stderr, non-JSON/scalar output, warning-prefixed output, non-UTF8/binary output, non-zero exit with stdout, and raw-output non-persistence.
|
||||
|
||||
## Phase 10: Timeout, Concurrency, and Cleanup
|
||||
|
||||
- [x] T063 Add per-invocation timeout and process termination/cleanup behavior.
|
||||
- [x] T064 Add per-provider concurrency lock.
|
||||
- [x] T065 Add per-workspace concurrency lock.
|
||||
- [x] T066 Add stale-lock handling only if an existing repo pattern exists; otherwise document why it is not implemented.
|
||||
- [x] T067 Add tests proving timeout returns sanitized failure and attempts termination.
|
||||
- [x] T068 Add tests proving concurrency blocks conflicting runs.
|
||||
- [x] T069 Add tests proving lock cleanup on success, failure, timeout, and exception.
|
||||
- [x] T070 Prefer no temp files; if temp files are used, add tests proving safe directory, no payload/secret content, and cleanup after success/failure/timeout/exception.
|
||||
|
||||
## Phase 11: OperationRun Context, Summary, Failure, and Redaction
|
||||
|
||||
- [x] T071 Store runner mode, runtime readiness state, credential state, permission evidence state, requested resource types, requested command contracts, provider connection context, duration, and safe failure category in OperationRun context only when sanitized.
|
||||
- [x] T072 Keep `provider_connection_id` context-only and add no `operation_runs.provider_connection_id` column.
|
||||
- [x] T073 Use existing summary keys such as `total`, `processed`, `succeeded`, `failed`, `skipped`, and `items` where possible.
|
||||
- [x] T074 If a new summary key is unavoidable, update `OperationSummaryKeys::all()` and add tests proving preservation and unknown-key drop behavior.
|
||||
- [x] T075 Add tests proving summary count values are flat numeric-only and contain no raw output or secrets.
|
||||
- [x] T076 Add or map sanitized failure reasons for runtime, credential, permission, timeout, concurrency, output-size, output-encoding, shape, nonzero-exit, authentication, authorization, and unexpected failures.
|
||||
- [x] T077 Add redaction tests for OperationRun context, summary counts, failure context, runner envelopes, process output, exception messages, logs if testable, and temp files if any.
|
||||
|
||||
## Phase 12: Provider Scope and RBAC
|
||||
|
||||
- [x] T078 Add feature tests proving non-member workspace access returns 404.
|
||||
- [x] T079 Add feature tests proving missing managed-environment entitlement returns 404.
|
||||
- [x] T080 Add feature tests proving member without execution capability returns 403 or repo-equivalent.
|
||||
- [x] T081 Add feature tests proving readonly actor cannot invoke.
|
||||
- [x] T082 Add tests proving cross-workspace provider connection is rejected.
|
||||
- [x] T083 Add tests proving cross-environment provider connection is rejected.
|
||||
- [x] T084 Add tests proving wrong-scope permission evidence is rejected before process execution.
|
||||
|
||||
## Phase 13: No Evidence, No Product Promotion, No Trigger Surface
|
||||
|
||||
- [x] T085 Add tests proving no `TenantConfigurationResourceEvidence` or repo-equivalent evidence rows are created.
|
||||
- [x] T086 Add tests proving no raw or normalized evidence payload is persisted.
|
||||
- [x] T087 Add tests proving no content-backed, comparable, renderable, certified, restore-ready, customer-ready, report, Review Pack, or customer claim state appears.
|
||||
- [x] T088 Add guard scans/tests proving no route, Filament page, Livewire component, navigation item, global search change, asset change, dashboard/readiness badge, or customer route is introduced.
|
||||
- [x] T089 Add guard scans/tests proving no job, schedule, or listener can invoke the production runner.
|
||||
- [x] T090 Add guard scans/tests proving no migration, `tenant_id`, Exchange-specific evidence table, legacy shim, fallback reader, dual write, or Exchange mini-platform is introduced in Spec 432 changed runtime artifacts.
|
||||
|
||||
## Phase 14: Regression and Validation
|
||||
|
||||
- [x] T091 Run focused Spec 432 unit and feature tests.
|
||||
- [x] T092 Run Spec 431 invocation gate/security regression tests.
|
||||
- [x] T093 Run Spec 430 adapter contract regression tests.
|
||||
- [x] T094 Run selected Spec 426 and Spec 427 no-promotion/source-contract regressions.
|
||||
- [x] T095 Run selected Spec 417 identity, Spec 419 registry, and Spec 420 generic evidence regressions where available.
|
||||
- [x] T096 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||
- [x] T097 Run `git diff --check`.
|
||||
- [x] T098 Capture final `git status --short`.
|
||||
|
||||
## Phase 15: Implementation Report
|
||||
|
||||
- [x] T099 Create `specs/432-exchange-powershell-production-runner-boundary-runtime-gate/implementation-report.md`.
|
||||
- [x] T100 Record candidate gate result, branch and HEAD, dirty state before/after, files changed, and activated skills/gates.
|
||||
- [x] T101 Record Spec 431 prerequisite proof and any PASS WITH CONDITIONS details.
|
||||
- [x] T102 Record runner binding proof, disabled default proof, production-runner config proof, and repo-canonical config-cache proof path.
|
||||
- [x] T103 Record runtime readiness proof and non-invasive module check proof.
|
||||
- [x] T104 Record credential reference proof, client-secret blocked proof, and certificate/federated/managed-identity state proof.
|
||||
- [x] T105 Record permission evidence proof and admin-consent-not-sufficient proof.
|
||||
- [x] T106 Record command construction, process executor, argument-vector/no-shell, output guard, timeout/concurrency, and temp-file proof.
|
||||
- [x] T107 Record OperationRun safety, summary keys, failure sanitizer, redaction, provider scope, no evidence, no compare/render/certification, no restore/customer claim, no UI/route/job/schedule/listener, no migration, no `tenant_id`, and no mini-platform proof.
|
||||
- [x] T108 Record Livewire v4 compliance, provider registration location, global search posture, destructive/high-impact action posture, asset strategy, browser/no-browser result, deployment impact, visible complexity outcome, no completed-spec rewrite assertion, tests run, deferred work, and follow-up candidates.
|
||||
- [x] T109 Include runner, credential, target, and no-promotion matrices.
|
||||
|
||||
## Explicit Non-Goals During Implementation
|
||||
|
||||
- [x] No live Exchange Online success requirement.
|
||||
- [x] No Microsoft connection during readiness checks.
|
||||
- [x] No runtime module installation.
|
||||
- [x] No client-secret live support unless the spec is amended.
|
||||
- [x] No admin-consent-only live support.
|
||||
- [x] No evidence persistence or promotion.
|
||||
- [x] No compare/render/certification/restore/customer claim.
|
||||
- [x] No UI/routes/navigation/global search/assets.
|
||||
- [x] No jobs/schedules/listeners that invoke the runner.
|
||||
- [x] No migrations or `tenant_id`.
|
||||
- [x] No Teams, Exchange Admin API, outboundConnector, acceptedDomain, organizationConfig, mailboxPlan, or sharingPolicy.
|
||||
- [x] No Exchange mini-platform, legacy shim, fallback reader, or dual write.
|
||||
|
||||
## Dependencies and Execution Order
|
||||
|
||||
- Phase 1 preflight blocks all implementation.
|
||||
- Runner binding/config gates block production runner work.
|
||||
- Runtime, credential, permission, process executor, and command builder phases block production runner execution.
|
||||
- Output, timeout, concurrency, OperationRun safety, redaction, RBAC, and no-promotion guards must pass before validation.
|
||||
- Regression and implementation report tasks close the feature.
|
||||
Loading…
Reference in New Issue
Block a user