TenantAtlas/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellRuntimePolicy.php
ahmido f4e342121a 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
2026-07-07 18:34:18 +00:00

116 lines
4.0 KiB
PHP

<?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;
}
}