TenantAtlas/app/Services/Operations/QueuedExecutionLegitimacyGate.php
ahmido 5bcb4f6ab8 feat: harden queued execution legitimacy (#179)
## Summary
- add a canonical queued execution legitimacy contract for actor-bound and system-authority operation runs
- enforce legitimacy before queued jobs transition runs to running across provider, inventory, restore, bulk, sync, and scheduled backup flows
- surface blocked execution outcomes consistently in Monitoring, notifications, audit data, and the tenantless operation viewer
- add Spec 149 artifacts and focused Pest coverage for legitimacy decisions, middleware ordering, blocked presentation, retry behavior, and cross-family adoption

## Testing
- vendor/bin/sail artisan test --compact tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/QueuedExecutionMiddlewareOrderingTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Verification/ProviderExecutionReauthorizationTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/RunInventorySyncExecutionReauthorizationTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/ExecuteRestoreRunExecutionReauthorizationTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/SystemRunBlockedExecutionNotificationTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/BulkOperationExecutionReauthorizationTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/QueuedExecutionRetryReauthorizationTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/QueuedExecutionContractMatrixTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/QueuedExecutionAuditTrailTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/TenantlessOperationRunViewerTest.php
- vendor/bin/sail bin pint --dirty --format agent

## Manual validation
- validated queued provider execution blocking for tenant operability drift in the integrated browser on /admin/operations and /admin/operations/{run}
- validated 404 vs 403 route behavior for non-membership vs in-scope capability denial
- validated initiator-null blocked system-run behavior without creating a user terminal notification

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #179
2026-03-17 21:52:40 +00:00

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