TenantAtlas/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellPermissionEvidenceEvaluator.php
ahmido a6f4529941 feat: add Exchange credential permission evidence readiness (#500)
Validation: sail artisan test --filter=Spec433|Spec432|Spec431 --compact; pint --dirty --test; git diff --check
Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #500
2026-07-07 23:55:32 +00:00

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 !== ''),
);
}
}