266 lines
11 KiB
PHP
266 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Operations;
|
|
|
|
use App\Contracts\Hardening\WriteGateInterface;
|
|
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Services\Tenants\TenantOperabilityService;
|
|
use App\Support\Operations\ExecutionAuthorityMode;
|
|
use App\Support\Operations\ExecutionDenialReasonCode;
|
|
use App\Support\Operations\OperationRunCapabilityResolver;
|
|
use App\Support\Operations\QueuedExecutionContext;
|
|
use App\Support\Operations\QueuedExecutionLegitimacyDecision;
|
|
use App\Support\Tenants\TenantInteractionLane;
|
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
|
|
|
class QueuedExecutionLegitimacyGate
|
|
{
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
private const SYSTEM_AUTHORITY_ALLOWLIST = [
|
|
'backup_schedule_run',
|
|
'backup_schedule_retention',
|
|
'backup_schedule_purge',
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver,
|
|
private readonly CapabilityResolver $capabilityResolver,
|
|
private readonly TenantOperabilityService $tenantOperabilityService,
|
|
private readonly WriteGateInterface $writeGate,
|
|
) {}
|
|
|
|
public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision
|
|
{
|
|
$run = OperationRun::query()->with(['tenant', 'user'])->findOrFail($run->getKey());
|
|
$context = $this->buildContext($run);
|
|
$checks = QueuedExecutionLegitimacyDecision::defaultChecks();
|
|
|
|
if ($context->tenant instanceof Tenant) {
|
|
$checks['workspace_scope'] = ((int) $context->tenant->workspace_id === $context->workspaceId) ? 'passed' : 'failed';
|
|
|
|
if ($checks['workspace_scope'] === 'failed') {
|
|
return QueuedExecutionLegitimacyDecision::deny($context, $checks, ExecutionDenialReasonCode::WorkspaceMismatch);
|
|
}
|
|
|
|
$checks['tenant_scope'] = 'passed';
|
|
} elseif ($run->tenant_id !== null) {
|
|
$checks['tenant_scope'] = 'failed';
|
|
|
|
return QueuedExecutionLegitimacyDecision::deny($context, $checks, ExecutionDenialReasonCode::TenantMissing);
|
|
} else {
|
|
$checks['workspace_scope'] = $context->workspaceId > 0 ? 'passed' : 'not_applicable';
|
|
}
|
|
|
|
if ($context->authorityMode === ExecutionAuthorityMode::ActorBound) {
|
|
if (! $context->initiator instanceof User) {
|
|
return QueuedExecutionLegitimacyDecision::deny($context, $checks, ExecutionDenialReasonCode::InitiatorMissing);
|
|
}
|
|
|
|
if ($context->tenant instanceof Tenant && ! $this->capabilityResolver->isMember($context->initiator, $context->tenant)) {
|
|
$checks['tenant_scope'] = 'failed';
|
|
|
|
return QueuedExecutionLegitimacyDecision::deny($context, $checks, ExecutionDenialReasonCode::InitiatorNotEntitled);
|
|
}
|
|
|
|
if ($context->requiredCapability !== null && $context->tenant instanceof Tenant) {
|
|
$checks['capability'] = $this->capabilityResolver->can(
|
|
$context->initiator,
|
|
$context->tenant,
|
|
$context->requiredCapability,
|
|
) ? 'passed' : 'failed';
|
|
|
|
if ($checks['capability'] === 'failed') {
|
|
return QueuedExecutionLegitimacyDecision::deny(
|
|
$context,
|
|
$checks,
|
|
ExecutionDenialReasonCode::MissingCapability,
|
|
['required_capability' => $context->requiredCapability],
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
if (! $this->isSystemAuthorityAllowed($context->operationType)) {
|
|
$checks['execution_prerequisites'] = 'failed';
|
|
|
|
return QueuedExecutionLegitimacyDecision::deny(
|
|
$context,
|
|
$checks,
|
|
ExecutionDenialReasonCode::ExecutionPrerequisiteInvalid,
|
|
['system_allowlist' => self::SYSTEM_AUTHORITY_ALLOWLIST],
|
|
);
|
|
}
|
|
}
|
|
|
|
if ($context->tenant instanceof Tenant) {
|
|
$operabilityQuestion = $this->questionForContext($context);
|
|
$operability = $this->tenantOperabilityService->outcomeFor(
|
|
tenant: $context->tenant,
|
|
question: $operabilityQuestion,
|
|
workspaceId: $context->workspaceId,
|
|
lane: TenantInteractionLane::AdministrativeManagement,
|
|
);
|
|
|
|
$checks['tenant_operability'] = $operability->allowed ? 'passed' : 'failed';
|
|
|
|
if (! $operability->allowed) {
|
|
return QueuedExecutionLegitimacyDecision::deny(
|
|
$context,
|
|
$checks,
|
|
ExecutionDenialReasonCode::TenantNotOperable,
|
|
[
|
|
'tenant_operability_question' => $operabilityQuestion->value,
|
|
'tenant_operability_reason_code' => $operability->reasonCode?->value,
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
$prerequisiteDecision = $this->evaluateExecutionPrerequisites($context, $checks);
|
|
|
|
if ($prerequisiteDecision instanceof QueuedExecutionLegitimacyDecision) {
|
|
return $prerequisiteDecision;
|
|
}
|
|
|
|
return QueuedExecutionLegitimacyDecision::allow($context, $prerequisiteDecision);
|
|
}
|
|
|
|
public function buildContext(OperationRun $run): QueuedExecutionContext
|
|
{
|
|
$context = is_array($run->context) ? $run->context : [];
|
|
$authorityMode = ExecutionAuthorityMode::fromNullable($context['execution_authority_mode'] ?? null)
|
|
?? ($run->user_id === null ? ExecutionAuthorityMode::SystemAuthority : ExecutionAuthorityMode::ActorBound);
|
|
$providerConnectionId = $this->resolveProviderConnectionId($context);
|
|
$workspaceId = (int) ($run->workspace_id ?? $run->tenant?->workspace_id ?? 0);
|
|
|
|
return new QueuedExecutionContext(
|
|
run: $run,
|
|
operationType: (string) $run->type,
|
|
workspaceId: $workspaceId,
|
|
tenant: $run->tenant,
|
|
initiator: $run->user,
|
|
authorityMode: $authorityMode,
|
|
requiredCapability: is_string($context['required_capability'] ?? null)
|
|
? $context['required_capability']
|
|
: $this->operationRunCapabilityResolver->requiredExecutionCapabilityForType((string) $run->type),
|
|
providerConnectionId: $providerConnectionId,
|
|
targetScope: [
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => $run->tenant_id !== null ? (int) $run->tenant_id : null,
|
|
'provider_connection_id' => $providerConnectionId,
|
|
],
|
|
prerequisiteClasses: $this->prerequisiteClassesFor((string) $run->type, $providerConnectionId),
|
|
);
|
|
}
|
|
|
|
public function isSystemAuthorityAllowed(string $operationType): bool
|
|
{
|
|
return in_array($operationType, self::SYSTEM_AUTHORITY_ALLOWLIST, true);
|
|
}
|
|
|
|
/**
|
|
* @param array{workspace_scope:string,tenant_scope:string,capability:string,tenant_operability:string,execution_prerequisites:string} $checks
|
|
* @return array{workspace_scope:string,tenant_scope:string,capability:string,tenant_operability:string,execution_prerequisites:string}|QueuedExecutionLegitimacyDecision
|
|
*/
|
|
private function evaluateExecutionPrerequisites(QueuedExecutionContext $context, array $checks): array|QueuedExecutionLegitimacyDecision
|
|
{
|
|
if ($context->providerConnectionId !== null) {
|
|
$validProviderConnection = ProviderConnection::query()
|
|
->whereKey($context->providerConnectionId)
|
|
->when(
|
|
$context->tenant instanceof Tenant,
|
|
fn ($query) => $query->where('tenant_id', (int) $context->tenant->getKey()),
|
|
)
|
|
->exists();
|
|
|
|
if (! $validProviderConnection) {
|
|
$checks['execution_prerequisites'] = 'failed';
|
|
|
|
return QueuedExecutionLegitimacyDecision::deny(
|
|
$context,
|
|
$checks,
|
|
ExecutionDenialReasonCode::ProviderConnectionInvalid,
|
|
['provider_connection_id' => $context->providerConnectionId],
|
|
);
|
|
}
|
|
}
|
|
|
|
if ($this->requiresWriteGate($context) && $context->tenant instanceof Tenant) {
|
|
try {
|
|
$this->writeGate->evaluate($context->tenant, $context->operationType);
|
|
} catch (ProviderAccessHardeningRequired $exception) {
|
|
$checks['execution_prerequisites'] = 'failed';
|
|
|
|
return QueuedExecutionLegitimacyDecision::deny(
|
|
$context,
|
|
$checks,
|
|
ExecutionDenialReasonCode::WriteGateBlocked,
|
|
[
|
|
'write_gate_reason_code' => $exception->reasonCode,
|
|
'write_gate_message' => $exception->reasonMessage,
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
if ($context->prerequisiteClasses !== []) {
|
|
$checks['execution_prerequisites'] = 'passed';
|
|
}
|
|
|
|
return $checks;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
private function resolveProviderConnectionId(array $context): ?int
|
|
{
|
|
$providerConnectionId = $context['provider_connection_id'] ?? ($context['target_scope']['provider_connection_id'] ?? null);
|
|
|
|
return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function prerequisiteClassesFor(string $operationType, ?int $providerConnectionId): array
|
|
{
|
|
$prerequisites = [];
|
|
|
|
if ($providerConnectionId !== null) {
|
|
$prerequisites[] = 'provider_connection';
|
|
}
|
|
|
|
if (str_starts_with($operationType, 'restore.')) {
|
|
$prerequisites[] = 'write_gate';
|
|
}
|
|
|
|
return $prerequisites;
|
|
}
|
|
|
|
private function questionForContext(QueuedExecutionContext $context): TenantOperabilityQuestion
|
|
{
|
|
if ($context->providerConnectionId !== null || in_array($context->operationType, ['provider.connection.check', 'compliance.snapshot', 'provider.compliance.snapshot'], true)) {
|
|
return TenantOperabilityQuestion::VerificationReadinessEligibility;
|
|
}
|
|
|
|
return match ($context->operationType) {
|
|
'restore.execute' => TenantOperabilityQuestion::RestoreEligibility,
|
|
default => TenantOperabilityQuestion::AdministrativeDiscoverability,
|
|
};
|
|
}
|
|
|
|
private function requiresWriteGate(QueuedExecutionContext $context): bool
|
|
{
|
|
return in_array('write_gate', $context->prerequisiteClasses, true);
|
|
}
|
|
}
|