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
728 lines
31 KiB
PHP
728 lines
31 KiB
PHP
<?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;
|
|
}
|