287 lines
11 KiB
PHP
287 lines
11 KiB
PHP
<?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;
|
|
use Illuminate\Support\Carbon;
|
|
use Throwable;
|
|
|
|
final class ExchangePowerShellPermissionEvidenceEvaluator
|
|
{
|
|
private const string PERMISSION_KEY = 'Exchange.ManageAsApp';
|
|
|
|
private const string TRUSTED_EVIDENCE_SOURCE = 'provider_verification';
|
|
|
|
private const string EVIDENCE_EVALUATOR = 'exchange_powershell_permission_evidence';
|
|
|
|
private const string EVIDENCE_EVALUATOR_VERSION = 'v1';
|
|
|
|
public function evaluate(ManagedEnvironment $environment, ProviderConnection $connection): ExchangePowerShellGateResult
|
|
{
|
|
if (trim((string) $connection->provider) !== 'microsoft') {
|
|
return $this->blocked(
|
|
failureCode: 'permission_evidence_blocked_unsupported_provider',
|
|
stateKey: 'permission_evidence_state',
|
|
state: 'unsupported_provider',
|
|
context: ['provider' => 'unsupported'],
|
|
);
|
|
}
|
|
|
|
$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 : [];
|
|
$status = $this->normalizedStatus($permission->status);
|
|
|
|
if ($status !== 'granted') {
|
|
return match ($status) {
|
|
'missing', 'absent', 'revoked' => $this->blocked('permission_evidence_blocked_absent', 'permission_evidence_state', 'absent', $permission),
|
|
'unknown', 'error' => $this->blocked('permission_evidence_blocked_unknown', 'permission_evidence_state', 'unknown', $permission),
|
|
default => $this->blocked('permission_evidence_blocked_unvalidated', 'permission_evidence_state', 'unvalidated', $permission),
|
|
};
|
|
}
|
|
|
|
$metadata = $this->validatedEvidenceMetadata($details);
|
|
|
|
if ($metadata === null) {
|
|
return $this->blocked('permission_evidence_blocked_unvalidated', 'permission_evidence_state', 'unvalidated', $permission);
|
|
}
|
|
|
|
if ($this->permissionEvidenceIsStale($permission, $metadata)) {
|
|
return $this->blocked('permission_evidence_blocked_stale', 'permission_evidence_state', 'stale', $permission);
|
|
}
|
|
|
|
$scopeIds = $this->validatedScopeIds($details);
|
|
|
|
if ($scopeIds === null) {
|
|
return $this->blocked('permission_evidence_blocked_unvalidated', 'permission_evidence_state', 'unvalidated', $permission);
|
|
}
|
|
|
|
if ($scopeIds['workspace_id'] !== (int) $connection->workspace_id) {
|
|
return $this->blocked('permission_evidence_blocked_wrong_workspace', 'permission_evidence_state', 'wrong_workspace', $permission);
|
|
}
|
|
|
|
if ($scopeIds['managed_environment_id'] !== (int) $connection->managed_environment_id) {
|
|
return $this->blocked('permission_evidence_blocked_wrong_environment', 'permission_evidence_state', 'wrong_environment', $permission);
|
|
}
|
|
|
|
if (trim((string) ($details['provider'] ?? '')) !== 'microsoft'
|
|
|| trim((string) ($details['provider'] ?? '')) !== trim((string) $connection->provider)
|
|
) {
|
|
return $this->blocked('permission_evidence_blocked_unsupported_provider', 'permission_evidence_state', 'unsupported_provider', $permission);
|
|
}
|
|
|
|
if ($scopeIds['provider_connection_id'] !== (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(),
|
|
'permission_currentness_days' => $this->currentnessDays(),
|
|
'permission_evidence_source' => $metadata['source'],
|
|
'permission_observed_at' => $metadata['observed_at'],
|
|
'permission_verified_at' => $metadata['verified_at'],
|
|
'permission_evidence_evaluator' => $metadata['evaluator'],
|
|
'permission_evidence_evaluator_version' => $metadata['evaluator_version'],
|
|
]);
|
|
}
|
|
|
|
private function currentnessDays(): int
|
|
{
|
|
$configured = config('tenantpilot.exchange_powershell.permission_evidence.currentness_days', 30);
|
|
|
|
return max(1, (int) $configured);
|
|
}
|
|
|
|
private function normalizedStatus(mixed $status): string
|
|
{
|
|
if (! is_string($status) || trim($status) === '') {
|
|
return 'unknown';
|
|
}
|
|
|
|
$status = strtolower(trim($status));
|
|
|
|
return in_array($status, ['granted', 'missing', 'absent', 'revoked', 'blocked', 'unknown', 'error'], true)
|
|
? $status
|
|
: 'unknown';
|
|
}
|
|
|
|
/**
|
|
* @param array{source: string, observed_at: string, verified_at: string, evaluator: string, evaluator_version: string} $metadata
|
|
*/
|
|
private function permissionEvidenceIsStale(ManagedEnvironmentPermission $permission, array $metadata): bool
|
|
{
|
|
$freshSince = now()->subDays($this->currentnessDays());
|
|
$observedAt = $this->safeTimestamp($metadata['observed_at']);
|
|
$verifiedAt = $this->safeTimestamp($metadata['verified_at']);
|
|
|
|
return ! $permission->last_checked_at
|
|
|| $permission->last_checked_at->lt($freshSince)
|
|
|| ! $observedAt instanceof Carbon
|
|
|| ! $verifiedAt instanceof Carbon
|
|
|| $observedAt->lt($freshSince)
|
|
|| $verifiedAt->lt($freshSince);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $details
|
|
* @return array{source: string, observed_at: string, verified_at: string, evaluator: string, evaluator_version: string}|null
|
|
*/
|
|
private function validatedEvidenceMetadata(array $details): ?array
|
|
{
|
|
$source = $this->safeString($details['source'] ?? null);
|
|
|
|
if ($source !== self::TRUSTED_EVIDENCE_SOURCE) {
|
|
return null;
|
|
}
|
|
|
|
$observedAt = $this->safeTimestamp($details['observed_at'] ?? null);
|
|
$verifiedAt = $this->safeTimestamp($details['verified_at'] ?? null);
|
|
|
|
if (! $observedAt instanceof Carbon || ! $verifiedAt instanceof Carbon) {
|
|
return null;
|
|
}
|
|
|
|
if ($observedAt->isFuture() || $verifiedAt->isFuture() || $verifiedAt->lt($observedAt)) {
|
|
return null;
|
|
}
|
|
|
|
$evaluator = $this->safeString($details['evaluator'] ?? null);
|
|
$evaluatorVersion = $this->safeString($details['evaluator_version'] ?? null);
|
|
|
|
if ($evaluator !== self::EVIDENCE_EVALUATOR || $evaluatorVersion !== self::EVIDENCE_EVALUATOR_VERSION) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'source' => $source,
|
|
'observed_at' => $observedAt->toJSON(),
|
|
'verified_at' => $verifiedAt->toJSON(),
|
|
'evaluator' => $evaluator,
|
|
'evaluator_version' => $evaluatorVersion,
|
|
];
|
|
}
|
|
|
|
private function safeString(mixed $value): ?string
|
|
{
|
|
if (! is_string($value)) {
|
|
return null;
|
|
}
|
|
|
|
$value = trim($value);
|
|
|
|
if ($value === '') {
|
|
return null;
|
|
}
|
|
|
|
return preg_match('/\A[A-Za-z0-9_.:-]{1,80}\z/', $value) === 1 ? $value : null;
|
|
}
|
|
|
|
private function safeTimestamp(mixed $value): ?Carbon
|
|
{
|
|
if (! is_string($value)) {
|
|
return null;
|
|
}
|
|
|
|
$value = trim($value);
|
|
|
|
if ($value === '') {
|
|
return null;
|
|
}
|
|
|
|
$matches = [];
|
|
|
|
if (preg_match('/\A(?<year>\d{4})-(?<month>0[1-9]|1[0-2])-(?<day>0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,6})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)\z/', $value, $matches) !== 1) {
|
|
return null;
|
|
}
|
|
|
|
if (! checkdate((int) $matches['month'], (int) $matches['day'], (int) $matches['year'])) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return Carbon::parse($value);
|
|
} catch (Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $details
|
|
* @return array{workspace_id: int, managed_environment_id: int, provider_connection_id: int}|null
|
|
*/
|
|
private function validatedScopeIds(array $details): ?array
|
|
{
|
|
$workspaceId = $this->safePositiveInteger($details['workspace_id'] ?? null);
|
|
$environmentId = $this->safePositiveInteger($details['managed_environment_id'] ?? null);
|
|
$providerConnectionId = $this->safePositiveInteger($details['provider_connection_id'] ?? null);
|
|
|
|
if ($workspaceId === null || $environmentId === null || $providerConnectionId === null) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'workspace_id' => $workspaceId,
|
|
'managed_environment_id' => $environmentId,
|
|
'provider_connection_id' => $providerConnectionId,
|
|
];
|
|
}
|
|
|
|
private function safePositiveInteger(mixed $value): ?int
|
|
{
|
|
if (is_int($value)) {
|
|
return $value > 0 ? $value : null;
|
|
}
|
|
|
|
if (! is_string($value)) {
|
|
return null;
|
|
}
|
|
|
|
if ($value === '' || $value !== trim($value) || preg_match('/\A[1-9]\d{0,18}\z/', $value) !== 1) {
|
|
return null;
|
|
}
|
|
|
|
$integer = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
|
|
|
return is_int($integer) ? $integer : null;
|
|
}
|
|
|
|
private function blocked(
|
|
string $failureCode,
|
|
string $stateKey,
|
|
string $state,
|
|
?ManagedEnvironmentPermission $permission = null,
|
|
array $context = [],
|
|
): 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,
|
|
'permission_currentness_days' => $this->currentnessDays(),
|
|
...$context,
|
|
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
|
);
|
|
}
|
|
}
|