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
This commit is contained in:
ahmido 2026-03-17 21:52:40 +00:00
parent ede4cc363d
commit 5bcb4f6ab8
65 changed files with 3814 additions and 50 deletions

View File

@ -85,6 +85,8 @@ ## Active Technologies
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned (147-tenant-selector-remembered-context-enforcement) - PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned (147-tenant-selector-remembered-context-enforcement)
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing support-layer helpers such as `UiEnforcement`, `CapabilityResolver`, `WorkspaceContext`, `OperateHubShell`, `TenantOperabilityService`, and `TenantActionPolicySurface` (148-central-tenant-operability-policy) - PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing support-layer helpers such as `UiEnforcement`, `CapabilityResolver`, `WorkspaceContext`, `OperateHubShell`, `TenantOperabilityService`, and `TenantActionPolicySurface` (148-central-tenant-operability-policy)
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned for the first implementation slice (148-central-tenant-operability-policy) - PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned for the first implementation slice (148-central-tenant-operability-policy)
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing `OperationRunService`, `TrackOperationRun`, `ProviderOperationStartGate`, `TenantOperabilityService`, `CapabilityResolver`, and `WriteGateInterface` seams (149-queued-execution-reauthorization)
- PostgreSQL-backed application data plus queue-serialized `OperationRun` context; no schema migration planned for the first implementation slice (149-queued-execution-reauthorization)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -104,8 +106,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 149-queued-execution-reauthorization: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing `OperationRunService`, `TrackOperationRun`, `ProviderOperationStartGate`, `TenantOperabilityService`, `CapabilityResolver`, and `WriteGateInterface` seams
- 148-central-tenant-operability-policy: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing support-layer helpers such as `UiEnforcement`, `CapabilityResolver`, `WorkspaceContext`, `OperateHubShell`, `TenantOperabilityService`, and `TenantActionPolicySurface` - 148-central-tenant-operability-policy: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing support-layer helpers such as `UiEnforcement`, `CapabilityResolver`, `WorkspaceContext`, `OperateHubShell`, `TenantOperabilityService`, and `TenantActionPolicySurface`
- 147-tenant-selector-remembered-context-enforcement: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4 - 147-tenant-selector-remembered-context-enforcement: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4
- 146-central-tenant-status-presentation: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -70,7 +70,22 @@ public function selectTenant(int $tenantId): void
abort(403); abort(403);
} }
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); $workspaceContext = app(WorkspaceContext::class);
$workspaceId = $workspaceContext->currentWorkspaceId(request());
$tenant = null;
if ($workspaceId === null) {
$tenant = Tenant::query()->whereKey($tenantId)->first();
if ($tenant instanceof Tenant) {
$workspace = $tenant->workspace;
if ($workspace !== null && $user->canAccessTenant($tenant)) {
$workspaceContext->setCurrentWorkspace($workspace, $user, request());
$workspaceId = (int) $workspace->getKey();
}
}
}
if ($workspaceId === null) { if ($workspaceId === null) {
$this->redirect(route('filament.admin.pages.choose-workspace')); $this->redirect(route('filament.admin.pages.choose-workspace'));
@ -78,10 +93,12 @@ public function selectTenant(int $tenantId): void
return; return;
} }
$tenant = Tenant::query() if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== $workspaceId) {
->where('workspace_id', $workspaceId) $tenant = Tenant::query()
->whereKey($tenantId) ->where('workspace_id', $workspaceId)
->first(); ->whereKey($tenantId)
->first();
}
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
abort(404); abort(404);
@ -105,7 +122,7 @@ public function selectTenant(int $tenantId): void
$this->persistLastTenant($user, $tenant); $this->persistLastTenant($user, $tenant);
if (! app(WorkspaceContext::class)->rememberTenantContext($tenant, request())) { if (! $workspaceContext->rememberTenantContext($tenant, request())) {
abort(404); abort(404);
} }

View File

@ -160,6 +160,33 @@ public function redactionIntegrityNote(): ?string
return isset($this->run) ? RedactionIntegrity::noteForRun($this->run) : null; return isset($this->run) ? RedactionIntegrity::noteForRun($this->run) : null;
} }
/**
* @return array{tone: string, title: string, body: string}|null
*/
public function blockedExecutionBanner(): ?array
{
if (! isset($this->run) || (string) $this->run->outcome !== 'blocked') {
return null;
}
$context = is_array($this->run->context) ? $this->run->context : [];
$reasonCode = data_get($context, 'reason_code');
if (! is_string($reasonCode) || trim($reasonCode) === '') {
$reasonCode = data_get($context, 'execution_legitimacy.reason_code');
}
$reasonCode = is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : 'unknown_error';
$message = $this->run->failure_summary[0]['message'] ?? null;
$message = is_string($message) && trim($message) !== '' ? trim($message) : 'The queued run was refused before side effects could begin.';
return [
'tone' => 'amber',
'title' => 'Execution blocked',
'body' => sprintf('Reason code: %s. %s', $reasonCode, $message),
];
}
/** /**
* @return array{tone: string, title: string, body: string}|null * @return array{tone: string, title: string, body: string}|null
*/ */

View File

@ -174,6 +174,8 @@ protected function getHeaderActions(): array
], ],
context: array_merge($computed['selection'], [ context: array_merge($computed['selection'], [
'selection_hash' => $computed['selection_hash'], 'selection_hash' => $computed['selection_hash'],
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
'target_scope' => [ 'target_scope' => [
'entra_tenant_id' => $tenant->graphTenantId(), 'entra_tenant_id' => $tenant->graphTenantId(),
], ],

View File

@ -323,6 +323,15 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote) ? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
: null, : null,
$summaryLine !== null ? $factory->keyFact('Counts', $summaryLine) : null, $summaryLine !== null ? $factory->keyFact('Counts', $summaryLine) : null,
static::blockedExecutionReasonCode($record) !== null
? $factory->keyFact('Blocked reason', static::blockedExecutionReasonCode($record))
: null,
static::blockedExecutionDetail($record) !== null
? $factory->keyFact('Blocked detail', static::blockedExecutionDetail($record))
: null,
static::blockedExecutionSource($record) !== null
? $factory->keyFact('Blocked by', static::blockedExecutionSource($record))
: null,
RunDurationInsights::stuckGuidance($record) !== null ? $factory->keyFact('Guidance', RunDurationInsights::stuckGuidance($record)) : null, RunDurationInsights::stuckGuidance($record) !== null ? $factory->keyFact('Guidance', RunDurationInsights::stuckGuidance($record)) : null,
])), ])),
), ),
@ -369,7 +378,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$factory->viewSection( $factory->viewSection(
id: 'failures', id: 'failures',
kind: 'operational_context', kind: 'operational_context',
title: 'Failures', title: (string) $record->outcome === OperationRunOutcome::Blocked->value ? 'Blocked execution details' : 'Failures',
view: 'filament.infolists.entries.snapshot-json', view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => $record->failure_summary ?? []], viewData: ['payload' => $record->failure_summary ?? []],
), ),
@ -451,6 +460,51 @@ private static function summaryCountFacts(
); );
} }
private static function blockedExecutionReasonCode(OperationRun $record): ?string
{
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
return null;
}
$context = is_array($record->context) ? $record->context : [];
$reasonCode = data_get($context, 'execution_legitimacy.reason_code')
?? data_get($context, 'reason_code')
?? data_get($record->failure_summary, '0.reason_code');
return is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : null;
}
private static function blockedExecutionDetail(OperationRun $record): ?string
{
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
return null;
}
$message = data_get($record->failure_summary, '0.message');
return is_string($message) && trim($message) !== '' ? trim($message) : 'Execution was refused before work began.';
}
private static function blockedExecutionSource(OperationRun $record): ?string
{
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
return null;
}
$context = is_array($record->context) ? $record->context : [];
$blockedBy = $context['blocked_by'] ?? null;
if (! is_string($blockedBy) || trim($blockedBy) === '') {
return null;
}
return match (trim($blockedBy)) {
'queued_execution_legitimacy' => 'Execution legitimacy revalidation',
default => ucfirst(str_replace('_', ' ', trim($blockedBy))),
};
}
/** /**
* @return list<array<string, mixed>> * @return list<array<string, mixed>>
*/ */

View File

@ -1694,6 +1694,8 @@ public static function createRestoreRun(array $data): RestoreRun
'restore_run_id' => (int) $restoreRun->getKey(), 'restore_run_id' => (int) $restoreRun->getKey(),
'backup_set_id' => (int) $backupSet->getKey(), 'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false), 'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
], ],
initiator: $initiator, initiator: $initiator,
); );
@ -2092,6 +2094,8 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
'restore_run_id' => (int) $newRun->getKey(), 'restore_run_id' => (int) $newRun->getKey(),
'backup_set_id' => (int) $backupSet->getKey(), 'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => (bool) ($newRun->is_dry_run ?? false), 'is_dry_run' => (bool) ($newRun->is_dry_run ?? false),
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
], ],
initiator: $initiator, initiator: $initiator,
); );

View File

@ -2,6 +2,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Operations\PolicyBulkDeleteWorkerJob; use App\Jobs\Operations\PolicyBulkDeleteWorkerJob;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Services\OperationRunService; use App\Services\OperationRunService;
@ -32,6 +33,11 @@ public function __construct(
$this->operationRun = $operationRun; $this->operationRun = $operationRun;
} }
public function middleware(): array
{
return [new EnsureQueuedExecutionLegitimate];
}
public function handle(OperationRunService $runs): void public function handle(OperationRunService $runs): void
{ {
if (! $this->operationRun instanceof OperationRun) { if (! $this->operationRun instanceof OperationRun) {

View File

@ -2,6 +2,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Operations\TenantSyncWorkerJob; use App\Jobs\Operations\TenantSyncWorkerJob;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Services\OperationRunService; use App\Services\OperationRunService;
@ -32,6 +33,11 @@ public function __construct(
$this->operationRun = $operationRun; $this->operationRun = $operationRun;
} }
public function middleware(): array
{
return [new EnsureQueuedExecutionLegitimate];
}
public function handle(OperationRunService $runs): void public function handle(OperationRunService $runs): void
{ {
if (! $this->operationRun instanceof OperationRun) { if (! $this->operationRun instanceof OperationRun) {

View File

@ -4,6 +4,8 @@
use App\Contracts\Hardening\WriteGateInterface; use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired; use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun;
use App\Listeners\SyncRestoreRunToOperationRun; use App\Listeners\SyncRestoreRunToOperationRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\RestoreRun; use App\Models\RestoreRun;
@ -34,6 +36,14 @@ public function __construct(
$this->operationRun = $operationRun; $this->operationRun = $operationRun;
} }
/**
* @return array<int, object>
*/
public function middleware(): array
{
return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
}
public function handle(RestoreService $restoreService, AuditLogger $auditLogger): void public function handle(RestoreService $restoreService, AuditLogger $auditLogger): void
{ {
if (! $this->operationRun) { if (! $this->operationRun) {

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Middleware;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use App\Services\Operations\QueuedExecutionLegitimacyGate;
use Closure;
class EnsureQueuedExecutionLegitimate
{
/**
* @param mixed $job
* @param callable $next
* @return mixed
*/
public function handle($job, Closure $next)
{
$run = $this->resolveRun($job);
if (! $run instanceof OperationRun) {
return $next($job);
}
$decision = app(QueuedExecutionLegitimacyGate::class)->evaluate($run);
if (! $decision->allowed) {
app(OperationRunService::class)->finalizeExecutionLegitimacyBlockedRun($run, $decision);
return null;
}
return $next($job);
}
/**
* @param mixed $job
*/
private function resolveRun($job): ?OperationRun
{
if (method_exists($job, 'getOperationRun')) {
$run = $job->getOperationRun();
return $run instanceof OperationRun ? $run : null;
}
if (property_exists($job, 'operationRun')) {
$run = $job->operationRun;
return $run instanceof OperationRun ? $run : null;
}
return null;
}
}

View File

@ -17,14 +17,7 @@ class TrackOperationRun
*/ */
public function handle($job, Closure $next) public function handle($job, Closure $next)
{ {
// Check if the job has an 'operationRun' property or method $run = $this->resolveRun($job);
$run = null;
if (method_exists($job, 'getOperationRun')) {
$run = $job->getOperationRun();
} elseif (property_exists($job, 'operationRun')) {
$run = $job->operationRun;
}
if (! $run instanceof OperationRun) { if (! $run instanceof OperationRun) {
return $next($job); return $next($job);
@ -33,19 +26,23 @@ public function handle($job, Closure $next)
/** @var OperationRunService $service */ /** @var OperationRunService $service */
$service = app(OperationRunService::class); $service = app(OperationRunService::class);
// Mark as running $run->refresh();
$service->updateRun($run, 'running');
if ($run->status === 'completed') {
return null;
}
if ($run->status !== 'running') {
$service->updateRun($run, 'running');
}
try { try {
$response = $next($job); $response = $next($job);
// If the job was released back onto the queue (retry / delay), do not mark the run as completed.
if (property_exists($job, 'job') && $job->job && method_exists($job->job, 'isReleased') && $job->job->isReleased()) { if (property_exists($job, 'job') && $job->job && method_exists($job->job, 'isReleased') && $job->job->isReleased()) {
return $response; return $response;
} }
// If the job didn't already mark it as completed/failed, we do it here.
// Re-fetch to check current status
$run->refresh(); $run->refresh();
if ($run->status === 'running') { if ($run->status === 'running') {
@ -58,4 +55,24 @@ public function handle($job, Closure $next)
throw $e; throw $e;
} }
} }
/**
* @param mixed $job
*/
private function resolveRun($job): ?OperationRun
{
if (method_exists($job, 'getOperationRun')) {
$run = $job->getOperationRun();
return $run instanceof OperationRun ? $run : null;
}
if (property_exists($job, 'operationRun')) {
$run = $job->operationRun;
return $run instanceof OperationRun ? $run : null;
}
return null;
}
} }

View File

@ -2,6 +2,7 @@
namespace App\Jobs\Operations; namespace App\Jobs\Operations;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
@ -37,7 +38,7 @@ public function __construct(
*/ */
public function middleware(): array public function middleware(): array
{ {
return []; return [new EnsureQueuedExecutionLegitimate];
} }
public function handle(OperationRunService $runs): void public function handle(OperationRunService $runs): void

View File

@ -2,6 +2,7 @@
namespace App\Jobs\Operations; namespace App\Jobs\Operations;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
@ -38,7 +39,7 @@ public function __construct(
*/ */
public function middleware(): array public function middleware(): array
{ {
return []; return [new EnsureQueuedExecutionLegitimate];
} }
public function handle(OperationRunService $runs): void public function handle(OperationRunService $runs): void

View File

@ -2,6 +2,7 @@
namespace App\Jobs\Operations; namespace App\Jobs\Operations;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Policy; use App\Models\Policy;
use App\Services\OperationRunService; use App\Services\OperationRunService;
@ -33,6 +34,11 @@ public function __construct(
$this->operationRun = $operationRun; $this->operationRun = $operationRun;
} }
public function middleware(): array
{
return [new EnsureQueuedExecutionLegitimate];
}
public function handle(OperationRunService $runs, TargetScopeConcurrencyLimiter $limiter): void public function handle(OperationRunService $runs, TargetScopeConcurrencyLimiter $limiter): void
{ {
if (! $this->operationRun instanceof OperationRun) { if (! $this->operationRun instanceof OperationRun) {

View File

@ -2,6 +2,7 @@
namespace App\Jobs\Operations; namespace App\Jobs\Operations;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
@ -36,6 +37,11 @@ public function __construct(
$this->operationRun = $operationRun; $this->operationRun = $operationRun;
} }
public function middleware(): array
{
return [new EnsureQueuedExecutionLegitimate];
}
public function handle( public function handle(
OperationRunService $runs, OperationRunService $runs,
TargetScopeConcurrencyLimiter $limiter, TargetScopeConcurrencyLimiter $limiter,

View File

@ -2,6 +2,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun; use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
@ -41,7 +42,7 @@ public function __construct(
*/ */
public function middleware(): array public function middleware(): array
{ {
return [new TrackOperationRun]; return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
} }
public function handle( public function handle(

View File

@ -2,6 +2,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun; use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
@ -52,7 +53,7 @@ public function __construct(
*/ */
public function middleware(): array public function middleware(): array
{ {
return [new TrackOperationRun]; return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
} }
public function handle( public function handle(

View File

@ -2,6 +2,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun; use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
@ -41,7 +42,7 @@ public function __construct(
*/ */
public function middleware(): array public function middleware(): array
{ {
return [new TrackOperationRun]; return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
} }
public function handle( public function handle(

View File

@ -4,6 +4,8 @@
use App\Contracts\Hardening\WriteGateInterface; use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired; use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
@ -12,6 +14,7 @@
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Operations\ExecutionAuthorityMode;
use App\Support\OpsUx\AssignmentJobFingerprint; use App\Support\OpsUx\AssignmentJobFingerprint;
use App\Support\OpsUx\RunFailureSanitizer; use App\Support\OpsUx\RunFailureSanitizer;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
@ -39,6 +42,14 @@ class RestoreAssignmentsJob implements ShouldQueue
public int $backoff = 0; public int $backoff = 0;
/**
* @return array<int, object>
*/
public function middleware(): array
{
return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
}
/** /**
* Create a new job instance. * Create a new job instance.
*/ */
@ -403,6 +414,8 @@ private static function operationRunContext(
'policy_type' => trim($policyType), 'policy_type' => trim($policyType),
'policy_id' => trim($policyId), 'policy_id' => trim($policyId),
'assignment_item_count' => count($assignments), 'assignment_item_count' => count($assignments),
'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value,
'required_capability' => 'tenant.manage',
]; ];
} }

View File

@ -2,6 +2,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun; use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BackupSchedule; use App\Models\BackupSchedule;
use App\Models\OperationRun; use App\Models\OperationRun;
@ -58,7 +59,7 @@ public function __construct(
public function middleware(): array public function middleware(): array
{ {
return [new TrackOperationRun]; return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
} }
public function handle( public function handle(

View File

@ -2,6 +2,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun; use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
@ -45,7 +46,7 @@ public function __construct(
*/ */
public function middleware(): array public function middleware(): array
{ {
return [new TrackOperationRun]; return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
} }
/** /**
@ -89,11 +90,6 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $
$successCount = 0; $successCount = 0;
$failedCount = 0; $failedCount = 0;
// Note: The TrackOperationRun middleware will automatically set status to 'running' at start.
// It will also handle success completion if no exceptions thrown.
// However, InventorySyncService execution logic might be complex with partial failures.
// We might want to explicitly update the OperationRun if partial failures occur.
$result = $inventorySyncService->executeSelection( $result = $inventorySyncService->executeSelection(
$this->operationRun, $this->operationRun,
$tenant, $tenant,

View File

@ -2,6 +2,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun; use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Policy; use App\Models\Policy;
@ -39,7 +40,7 @@ public function __construct(
public function middleware(): array public function middleware(): array
{ {
return [new TrackOperationRun]; return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
} }
public function handle(PolicySyncService $service, OperationRunService $operationRunService): void public function handle(PolicySyncService $service, OperationRunService $operationRunService): void

View File

@ -5,6 +5,7 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\PlatformUser; use App\Models\PlatformUser;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\System\SystemOperationRunLinks; use App\Support\System\SystemOperationRunLinks;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
@ -26,19 +27,22 @@ public function via(object $notifiable): array
public function toDatabase(object $notifiable): array public function toDatabase(object $notifiable): array
{ {
$tenant = $this->run->tenant; $tenant = $this->run->tenant;
$runUrl = match (true) {
$notifiable instanceof PlatformUser => SystemOperationRunLinks::view($this->run),
$tenant instanceof Tenant => OperationRunLinks::view($this->run, $tenant),
default => OperationRunLinks::tenantlessView($this->run),
};
$notification = OperationUxPresenter::terminalDatabaseNotification( $notification = OperationUxPresenter::terminalDatabaseNotification(
run: $this->run, run: $this->run,
tenant: $tenant instanceof Tenant ? $tenant : null, tenant: $tenant instanceof Tenant ? $tenant : null,
); );
if ($notifiable instanceof PlatformUser) { $notification->actions([
$notification->actions([ \Filament\Actions\Action::make('view_run')
\Filament\Actions\Action::make('view_run') ->label('View run')
->label('View run') ->url($runUrl),
->url(SystemOperationRunLinks::view($this->run)), ]);
]);
}
return $notification->getDatabaseMessage(); return $notification->getDatabaseMessage();
} }

View File

@ -39,6 +39,7 @@
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer; use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer; use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
use App\Services\Intune\WindowsUpdateRingNormalizer; use App\Services\Intune\WindowsUpdateRingNormalizer;
use App\Services\Operations\QueuedExecutionLegitimacyGate;
use App\Services\PermissionPosture\FindingGeneratorContract; use App\Services\PermissionPosture\FindingGeneratorContract;
use App\Services\PermissionPosture\PermissionPostureFindingGenerator; use App\Services\PermissionPosture\PermissionPostureFindingGenerator;
use App\Services\Providers\MicrosoftGraphOptionsResolver; use App\Services\Providers\MicrosoftGraphOptionsResolver;
@ -122,6 +123,7 @@ public function register(): void
$this->app->singleton(EntraGroupReferenceResolver::class); $this->app->singleton(EntraGroupReferenceResolver::class);
$this->app->singleton(EntraRoleDefinitionReferenceResolver::class); $this->app->singleton(EntraRoleDefinitionReferenceResolver::class);
$this->app->singleton(PrincipalReferenceResolver::class); $this->app->singleton(PrincipalReferenceResolver::class);
$this->app->singleton(QueuedExecutionLegitimacyGate::class);
$this->app->singleton(ReferenceResolverRegistry::class, function ($app): ReferenceResolverRegistry { $this->app->singleton(ReferenceResolverRegistry::class, function ($app): ReferenceResolverRegistry {
/** @var array<int, ReferenceResolver> $resolvers */ /** @var array<int, ReferenceResolver> $resolvers */
$resolvers = [ $resolvers = [

View File

@ -15,6 +15,10 @@
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Operations\ExecutionAuthorityMode;
use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\OperationRunCapabilityResolver;
use App\Support\Operations\QueuedExecutionLegitimacyDecision;
use App\Support\OpsUx\BulkRunContext; use App\Support\OpsUx\BulkRunContext;
use App\Support\OpsUx\RunFailureSanitizer; use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\OpsUx\SummaryCountsNormalizer; use App\Support\OpsUx\SummaryCountsNormalizer;
@ -29,6 +33,7 @@ class OperationRunService
{ {
public function __construct( public function __construct(
private readonly AuditRecorder $auditRecorder, private readonly AuditRecorder $auditRecorder,
private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver,
) {} ) {}
public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5): bool public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5): bool
@ -69,6 +74,7 @@ public function ensureRun(
array $inputs, array $inputs,
?User $initiator = null ?User $initiator = null
): OperationRun { ): OperationRun {
$inputs = $this->normalizeExecutionContext($type, $inputs, $initiator);
$workspaceId = (int) ($tenant->workspace_id ?? 0); $workspaceId = (int) ($tenant->workspace_id ?? 0);
if ($workspaceId <= 0) { if ($workspaceId <= 0) {
@ -134,6 +140,7 @@ public function ensureRunWithIdentity(
array $context, array $context,
?User $initiator = null ?User $initiator = null
): OperationRun { ): OperationRun {
$context = $this->normalizeExecutionContext($type, $context, $initiator);
$workspaceId = (int) ($tenant->workspace_id ?? 0); $workspaceId = (int) ($tenant->workspace_id ?? 0);
if ($workspaceId <= 0) { if ($workspaceId <= 0) {
@ -268,6 +275,7 @@ public function ensureRunWithIdentityStrict(
array $context, array $context,
?User $initiator = null, ?User $initiator = null,
): OperationRun { ): OperationRun {
$context = $this->normalizeExecutionContext($type, $context, $initiator);
$workspaceId = (int) ($tenant->workspace_id ?? 0); $workspaceId = (int) ($tenant->workspace_id ?? 0);
if ($workspaceId <= 0) { if ($workspaceId <= 0) {
@ -388,6 +396,7 @@ public function ensureWorkspaceRunWithIdentity(
array $context, array $context,
?User $initiator = null, ?User $initiator = null,
): OperationRun { ): OperationRun {
$context = $this->normalizeExecutionContext($type, $context, $initiator);
$hash = $this->calculateWorkspaceHash((int) $workspace->getKey(), $type, $identityInputs); $hash = $this->calculateWorkspaceHash((int) $workspace->getKey(), $type, $identityInputs);
$existing = OperationRun::query() $existing = OperationRun::query()
@ -712,9 +721,11 @@ public function finalizeBlockedRun(
$context = is_array($run->context) ? $run->context : []; $context = is_array($run->context) ? $run->context : [];
$context['reason_code'] = $reasonCode; $context['reason_code'] = $reasonCode;
$context['next_steps'] = $nextSteps; $context['next_steps'] = $nextSteps;
$summaryCounts = $this->sanitizeSummaryCounts(is_array($run->summary_counts ?? null) ? $run->summary_counts : []);
$run->update([ $run->update([
'context' => $context, 'context' => $context,
'summary_counts' => $summaryCounts,
]); ]);
$run->refresh(); $run->refresh();
@ -733,6 +744,26 @@ public function finalizeBlockedRun(
); );
} }
public function finalizeExecutionLegitimacyBlockedRun(
OperationRun $run,
QueuedExecutionLegitimacyDecision $decision,
): OperationRun {
$context = is_array($run->context) ? $run->context : [];
$context['execution_legitimacy'] = $decision->toArray();
$context['blocked_by'] = 'queued_execution_legitimacy';
$context['retryable'] = $decision->retryable;
$run->update([
'context' => $context,
]);
return $this->finalizeBlockedRun(
run: $run->fresh(),
reasonCode: $decision->reasonCode?->value ?? ExecutionDenialReasonCode::ExecutionPrerequisiteInvalid->value,
message: $decision->reasonCode?->message() ?? 'Operation blocked before queued execution could begin.',
);
}
private function invokeDispatcher(callable $dispatcher, OperationRun $run): void private function invokeDispatcher(callable $dispatcher, OperationRun $run): void
{ {
$ref = null; $ref = null;
@ -775,6 +806,33 @@ protected function calculateWorkspaceHash(int $workspaceId, string $type, array
return hash('sha256', 'workspace|'.$workspaceId.'|'.$type.'|'.$json); return hash('sha256', 'workspace|'.$workspaceId.'|'.$type.'|'.$json);
} }
/**
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
private function normalizeExecutionContext(string $type, array $context, ?User $initiator): array
{
$context['execution_authority_mode'] = is_string($context['execution_authority_mode'] ?? null)
? trim((string) $context['execution_authority_mode'])
: ($initiator instanceof User ? ExecutionAuthorityMode::ActorBound->value : ExecutionAuthorityMode::SystemAuthority->value);
$requiredCapability = $context['required_capability'] ?? null;
if (! is_string($requiredCapability) || trim($requiredCapability) === '') {
$requiredCapability = $this->operationRunCapabilityResolver->requiredExecutionCapabilityForType($type);
} else {
$requiredCapability = trim($requiredCapability);
}
if (is_string($requiredCapability) && $requiredCapability !== '') {
$context['required_capability'] = $requiredCapability;
} else {
unset($context['required_capability']);
}
return $context;
}
/** /**
* Normalize inputs for stable identity hashing. * Normalize inputs for stable identity hashing.
* *
@ -889,6 +947,8 @@ private function writeTerminalAudit(OperationRun $run): void
{ {
$tenant = $run->tenant; $tenant = $run->tenant;
$workspace = $run->workspace; $workspace = $run->workspace;
$context = is_array($run->context) ? $run->context : [];
$executionLegitimacy = is_array($context['execution_legitimacy'] ?? null) ? $context['execution_legitimacy'] : [];
$operationLabel = OperationCatalog::label((string) $run->type); $operationLabel = OperationCatalog::label((string) $run->type);
$action = match ($run->outcome) { $action = match ($run->outcome) {
@ -910,9 +970,14 @@ private function writeTerminalAudit(OperationRun $run): void
context: [ context: [
'metadata' => [ 'metadata' => [
'operation_type' => $run->type, 'operation_type' => $run->type,
'summary_counts' => $run->summary_counts, 'summary_counts' => $this->sanitizeSummaryCounts(is_array($run->summary_counts ?? null) ? $run->summary_counts : []),
'failure_summary' => $run->failure_summary, 'failure_summary' => $run->failure_summary,
'target_scope' => is_array($run->context) ? ($run->context['target_scope'] ?? null) : null, 'target_scope' => $executionLegitimacy['target_scope'] ?? ($context['target_scope'] ?? null),
'reason_code' => $executionLegitimacy['reason_code'] ?? ($context['reason_code'] ?? null),
'denial_class' => $executionLegitimacy['denial_class'] ?? null,
'authority_mode' => $executionLegitimacy['authority_mode'] ?? ($context['execution_authority_mode'] ?? null),
'acting_identity_type' => $executionLegitimacy['initiator']['identity_type'] ?? ($run->user instanceof User ? 'user' : 'system'),
'blocked_by' => $context['blocked_by'] ?? null,
], ],
], ],
workspace: $workspace, workspace: $workspace,

View File

@ -0,0 +1,265 @@
<?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);
}
}

View File

@ -7,6 +7,9 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\Operations\ExecutionAuthorityMode;
use App\Support\Operations\OperationRunCapabilityResolver;
use App\Support\Providers\ProviderNextStepsRegistry; use App\Support\Providers\ProviderNextStepsRegistry;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\Verification\BlockedVerificationReportFactory; use App\Support\Verification\BlockedVerificationReportFactory;
@ -24,6 +27,7 @@ public function __construct(
private readonly ProviderOperationRegistry $registry, private readonly ProviderOperationRegistry $registry,
private readonly ProviderConnectionResolver $resolver, private readonly ProviderConnectionResolver $resolver,
private readonly ProviderNextStepsRegistry $nextStepsRegistry, private readonly ProviderNextStepsRegistry $nextStepsRegistry,
private readonly OperationRunCapabilityResolver $capabilityResolver,
) {} ) {}
/** /**
@ -104,6 +108,10 @@ public function start(
} }
$context = array_merge($extraContext, [ $context = array_merge($extraContext, [
'execution_authority_mode' => is_string($extraContext['execution_authority_mode'] ?? null)
? $extraContext['execution_authority_mode']
: ($initiator instanceof User ? ExecutionAuthorityMode::ActorBound->value : ExecutionAuthorityMode::SystemAuthority->value),
'required_capability' => $this->resolveRequiredCapability($operationType, $extraContext),
'provider' => $lockedConnection->provider, 'provider' => $lockedConnection->provider,
'module' => $definition['module'], 'module' => $definition['module'],
'provider_connection_id' => (int) $lockedConnection->getKey(), 'provider_connection_id' => (int) $lockedConnection->getKey(),
@ -149,6 +157,10 @@ private function startBlocked(
array $extraContext = [], array $extraContext = [],
): ProviderOperationStartResult { ): ProviderOperationStartResult {
$context = array_merge($extraContext, [ $context = array_merge($extraContext, [
'execution_authority_mode' => is_string($extraContext['execution_authority_mode'] ?? null)
? $extraContext['execution_authority_mode']
: ($initiator instanceof User ? ExecutionAuthorityMode::ActorBound->value : ExecutionAuthorityMode::SystemAuthority->value),
'required_capability' => $this->resolveRequiredCapability($operationType, $extraContext),
'provider' => $provider, 'provider' => $provider,
'module' => $module, 'module' => $module,
'target_scope' => [ 'target_scope' => [
@ -222,4 +234,20 @@ private function invokeDispatcher(callable $dispatcher, OperationRun $run): void
$dispatcher(); $dispatcher();
} }
/**
* @param array<string, mixed> $extraContext
*/
private function resolveRequiredCapability(string $operationType, array $extraContext): ?string
{
if (is_string($extraContext['required_capability'] ?? null) && trim((string) $extraContext['required_capability']) !== '') {
return trim((string) $extraContext['required_capability']);
}
if ($this->registry->isAllowed($operationType)) {
return Capabilities::PROVIDER_RUN;
}
return $this->capabilityResolver->requiredExecutionCapabilityForType($operationType);
}
} }

View File

@ -15,6 +15,7 @@
use App\Services\Providers\ProviderOperationStartGate; use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Providers\ProviderOperationStartResult; use App\Services\Providers\ProviderOperationStartResult;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Operations\ExecutionAuthorityMode;
use App\Support\Providers\ProviderVerificationStatus; use App\Support\Providers\ProviderVerificationStatus;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use InvalidArgumentException; use InvalidArgumentException;
@ -81,7 +82,10 @@ public function providerConnectionCheckForTenant(
operationType: 'provider.connection.check', operationType: 'provider.connection.check',
dispatcher: fn (OperationRun $run): mixed => $this->dispatchConnectionHealthCheck($run, $tenant, $initiator), dispatcher: fn (OperationRun $run): mixed => $this->dispatchConnectionHealthCheck($run, $tenant, $initiator),
initiator: $initiator, initiator: $initiator,
extraContext: $extraContext, extraContext: array_merge($extraContext, [
'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value,
'required_capability' => Capabilities::PROVIDER_RUN,
]),
); );
} }
@ -111,6 +115,8 @@ public function providerConnectionCheckUsingConnection(
dispatcher: fn (OperationRun $run): mixed => $this->dispatchConnectionHealthCheck($run, $tenant, $initiator), dispatcher: fn (OperationRun $run): mixed => $this->dispatchConnectionHealthCheck($run, $tenant, $initiator),
initiator: $initiator, initiator: $initiator,
extraContext: array_merge($extraContext, [ extraContext: array_merge($extraContext, [
'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value,
'required_capability' => Capabilities::PROVIDER_RUN,
'identity' => [ 'identity' => [
'connection_type' => $identity->connectionType->value, 'connection_type' => $identity->connectionType->value,
'credential_source' => $identity->credentialSource, 'credential_source' => $identity->credentialSource,

View File

@ -17,7 +17,7 @@ public function spec(mixed $value): BadgeSpec
OperationRunOutcome::Pending->value => new BadgeSpec('Pending', 'gray', 'heroicon-m-clock'), OperationRunOutcome::Pending->value => new BadgeSpec('Pending', 'gray', 'heroicon-m-clock'),
OperationRunOutcome::Succeeded->value => new BadgeSpec('Succeeded', 'success', 'heroicon-m-check-circle'), OperationRunOutcome::Succeeded->value => new BadgeSpec('Succeeded', 'success', 'heroicon-m-check-circle'),
OperationRunOutcome::PartiallySucceeded->value => new BadgeSpec('Partially succeeded', 'warning', 'heroicon-m-exclamation-triangle'), OperationRunOutcome::PartiallySucceeded->value => new BadgeSpec('Partially succeeded', 'warning', 'heroicon-m-exclamation-triangle'),
OperationRunOutcome::Blocked->value => new BadgeSpec('Blocked', 'warning', 'heroicon-m-no-symbol'), OperationRunOutcome::Blocked->value, 'operation.blocked' => new BadgeSpec('Blocked', 'warning', 'heroicon-m-no-symbol'),
OperationRunOutcome::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'), OperationRunOutcome::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
OperationRunOutcome::Cancelled->value => new BadgeSpec('Cancelled', 'gray', 'heroicon-m-minus-circle'), OperationRunOutcome::Cancelled->value => new BadgeSpec('Cancelled', 'gray', 'heroicon-m-minus-circle'),
default => BadgeSpec::unknown(), default => BadgeSpec::unknown(),

View File

@ -23,6 +23,8 @@ public static function labels(): array
'provider.connection.check' => 'Provider connection check', 'provider.connection.check' => 'Provider connection check',
'inventory_sync' => 'Inventory sync', 'inventory_sync' => 'Inventory sync',
'compliance.snapshot' => 'Compliance snapshot', 'compliance.snapshot' => 'Compliance snapshot',
'provider.inventory.sync' => 'Inventory sync',
'provider.compliance.snapshot' => 'Compliance snapshot',
'entra_group_sync' => 'Directory groups sync', 'entra_group_sync' => 'Directory groups sync',
'backup_set.add_policies' => 'Backup set update', 'backup_set.add_policies' => 'Backup set update',
'backup_set.remove_policies' => 'Backup set update', 'backup_set.remove_policies' => 'Backup set update',
@ -75,6 +77,8 @@ public static function expectedDurationSeconds(string $operationType): ?int
'policy.export' => 120, 'policy.export' => 120,
'inventory_sync' => 180, 'inventory_sync' => 180,
'compliance.snapshot' => 180, 'compliance.snapshot' => 180,
'provider.inventory.sync' => 180,
'provider.compliance.snapshot' => 180,
'entra_group_sync' => 120, 'entra_group_sync' => 120,
'assignments.fetch', 'assignments.restore' => 60, 'assignments.fetch', 'assignments.restore' => 60,
'ops.reconcile_adapter_runs' => 120, 'ops.reconcile_adapter_runs' => 120,

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations;
enum ExecutionAuthorityMode: string
{
case ActorBound = 'actor_bound';
case SystemAuthority = 'system_authority';
public static function fromNullable(mixed $value): ?self
{
if (! is_string($value)) {
return null;
}
return self::tryFrom(trim($value));
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations;
enum ExecutionDenialClass: string
{
case ScopeDenied = 'scope_denied';
case CapabilityDenied = 'capability_denied';
case TenantNotOperable = 'tenant_not_operable';
case PrerequisiteInvalid = 'prerequisite_invalid';
case InitiatorInvalid = 'initiator_invalid';
public function isRetryable(): bool
{
return match ($this) {
self::TenantNotOperable, self::PrerequisiteInvalid => true,
self::ScopeDenied, self::CapabilityDenied, self::InitiatorInvalid => false,
};
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations;
enum ExecutionDenialReasonCode: string
{
case WorkspaceMismatch = 'workspace_mismatch';
case TenantNotEntitled = 'tenant_not_entitled';
case MissingCapability = 'missing_capability';
case TenantNotOperable = 'tenant_not_operable';
case TenantMissing = 'tenant_missing';
case InitiatorMissing = 'initiator_missing';
case InitiatorNotEntitled = 'initiator_not_entitled';
case ProviderConnectionInvalid = 'provider_connection_invalid';
case WriteGateBlocked = 'write_gate_blocked';
case ExecutionPrerequisiteInvalid = 'execution_prerequisite_invalid';
public function denialClass(): ExecutionDenialClass
{
return match ($this) {
self::WorkspaceMismatch, self::TenantNotEntitled, self::TenantMissing => ExecutionDenialClass::ScopeDenied,
self::MissingCapability => ExecutionDenialClass::CapabilityDenied,
self::TenantNotOperable => ExecutionDenialClass::TenantNotOperable,
self::ProviderConnectionInvalid, self::WriteGateBlocked, self::ExecutionPrerequisiteInvalid => ExecutionDenialClass::PrerequisiteInvalid,
self::InitiatorMissing, self::InitiatorNotEntitled => ExecutionDenialClass::InitiatorInvalid,
};
}
public function message(): string
{
return match ($this) {
self::WorkspaceMismatch => 'Operation blocked because the queued run no longer matches the current workspace scope.',
self::TenantNotEntitled => 'Operation blocked because the target tenant is no longer entitled for this run.',
self::MissingCapability => 'Operation blocked because the initiating actor no longer has the required capability.',
self::TenantNotOperable => 'Operation blocked because the target tenant is not currently operable for this action.',
self::TenantMissing => 'Operation blocked because the target tenant could not be resolved at execution time.',
self::InitiatorMissing => 'Operation blocked because the initiating actor could not be resolved at execution time.',
self::InitiatorNotEntitled => 'Operation blocked because the initiating actor is no longer entitled to the tenant.',
self::ProviderConnectionInvalid => 'Operation blocked because the provider connection is no longer valid for the queued scope.',
self::WriteGateBlocked => 'Operation blocked because write hardening currently refuses execution for this tenant.',
self::ExecutionPrerequisiteInvalid => 'Operation blocked because the queued execution prerequisites are no longer satisfied.',
};
}
}

View File

@ -30,4 +30,21 @@ public function requiredCapabilityForType(string $operationType): ?string
default => null, default => null,
}; };
} }
public function requiredExecutionCapabilityForType(string $operationType): ?string
{
$operationType = trim($operationType);
if ($operationType === '') {
return null;
}
return match ($operationType) {
'provider.connection.check', 'provider.inventory.sync', 'provider.compliance.snapshot' => Capabilities::PROVIDER_RUN,
'policy.sync', 'policy.sync_one', 'tenant.sync' => Capabilities::TENANT_SYNC,
'policy.delete' => Capabilities::TENANT_MANAGE,
'assignments.restore', 'restore.execute' => Capabilities::TENANT_MANAGE,
default => $this->requiredCapabilityForType($operationType),
};
}
} }

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
final readonly class QueuedExecutionContext
{
/**
* @param array{workspace_id:int,tenant_id:int|null,provider_connection_id:int|null} $targetScope
* @param list<string> $prerequisiteClasses
* @param array<string, mixed> $metadata
*/
public function __construct(
public OperationRun $run,
public string $operationType,
public int $workspaceId,
public ?Tenant $tenant,
public ?User $initiator,
public ExecutionAuthorityMode $authorityMode,
public ?string $requiredCapability,
public ?int $providerConnectionId,
public array $targetScope,
public array $prerequisiteClasses = [],
public array $metadata = [],
) {}
/**
* @return array{identity_type:string,user_id:int|null}|null
*/
public function initiatorSnapshot(): ?array
{
if ($this->authorityMode === ExecutionAuthorityMode::SystemAuthority) {
return [
'identity_type' => 'system',
'user_id' => null,
];
}
if (! $this->initiator instanceof User) {
return null;
}
return [
'identity_type' => 'user',
'user_id' => (int) $this->initiator->getKey(),
];
}
}

View File

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations;
final readonly class QueuedExecutionLegitimacyDecision
{
/**
* @param array{identity_type:string,user_id:int|null}|null $initiator
* @param array{workspace_id:int,tenant_id:int|null,provider_connection_id:int|null} $targetScope
* @param array{workspace_scope:string,tenant_scope:string,capability:string,tenant_operability:string,execution_prerequisites:string} $checks
* @param array<string, mixed> $metadata
*/
public function __construct(
public string $operationType,
public bool $allowed,
public ExecutionAuthorityMode $authorityMode,
public ?array $initiator,
public array $targetScope,
public array $checks,
public ?ExecutionDenialClass $denialClass = null,
public ?ExecutionDenialReasonCode $reasonCode = null,
public bool $retryable = false,
public array $metadata = [],
) {}
/**
* @param array{workspace_scope:string,tenant_scope:string,capability:string,tenant_operability:string,execution_prerequisites:string} $checks
* @param array<string, mixed> $metadata
*/
public static function allow(QueuedExecutionContext $context, array $checks, array $metadata = []): self
{
return new self(
operationType: $context->operationType,
allowed: true,
authorityMode: $context->authorityMode,
initiator: $context->initiatorSnapshot(),
targetScope: $context->targetScope,
checks: $checks,
metadata: $metadata,
);
}
/**
* @param array{workspace_scope:string,tenant_scope:string,capability:string,tenant_operability:string,execution_prerequisites:string} $checks
* @param array<string, mixed> $metadata
*/
public static function deny(
QueuedExecutionContext $context,
array $checks,
ExecutionDenialReasonCode $reasonCode,
array $metadata = [],
): self {
$denialClass = $reasonCode->denialClass();
return new self(
operationType: $context->operationType,
allowed: false,
authorityMode: $context->authorityMode,
initiator: $context->initiatorSnapshot(),
targetScope: $context->targetScope,
checks: $checks,
denialClass: $denialClass,
reasonCode: $reasonCode,
retryable: $denialClass->isRetryable(),
metadata: $metadata,
);
}
/**
* @return array{workspace_scope:string,tenant_scope:string,capability:string,tenant_operability:string,execution_prerequisites:string}
*/
public static function defaultChecks(): array
{
return [
'workspace_scope' => 'not_applicable',
'tenant_scope' => 'not_applicable',
'capability' => 'not_applicable',
'tenant_operability' => 'not_applicable',
'execution_prerequisites' => 'not_applicable',
];
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'operation_type' => $this->operationType,
'authority_mode' => $this->authorityMode->value,
'allowed' => $this->allowed,
'retryable' => $this->retryable,
'reason_code' => $this->reasonCode?->value,
'denial_class' => $this->denialClass?->value,
'initiator' => $this->initiator,
'target_scope' => $this->targetScope,
'checks' => $this->checks,
'metadata' => $this->metadata,
];
}
}

View File

@ -10,7 +10,7 @@
final class OperationStatusNormalizer final class OperationStatusNormalizer
{ {
/** /**
* Returns one of: queued|running|succeeded|partial|failed. * Returns one of: queued|running|succeeded|partial|blocked|failed.
*/ */
public static function toUxStatus(?string $status, ?string $outcome): string public static function toUxStatus(?string $status, ?string $outcome): string
{ {
@ -25,6 +25,10 @@ public static function toUxStatus(?string $status, ?string $outcome): string
return 'running'; return 'running';
} }
if ($status === 'completed' && $outcome === 'blocked') {
return 'blocked';
}
// Terminal normalization (compatibility) // Terminal normalization (compatibility)
if ($status === 'failed' || $outcome === 'failed') { if ($status === 'failed' || $outcome === 'failed') {
return 'failed'; return 'failed';
@ -42,6 +46,7 @@ public static function toUxStatus(?string $status, ?string $outcome): string
return match ($outcome) { return match ($outcome) {
'partially_succeeded' => 'partial', 'partially_succeeded' => 'partial',
'succeeded' => 'succeeded', 'succeeded' => 'succeeded',
'blocked' => 'blocked',
'failed' => 'failed', 'failed' => 'failed',
default => 'failed', default => 'failed',
}; };

View File

@ -59,16 +59,18 @@ public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $
$titleSuffix = match ($uxStatus) { $titleSuffix = match ($uxStatus) {
'succeeded' => 'completed', 'succeeded' => 'completed',
'partial' => 'completed with warnings', 'partial' => 'completed with warnings',
'blocked' => 'blocked',
default => 'failed', default => 'failed',
}; };
$body = match ($uxStatus) { $body = match ($uxStatus) {
'succeeded' => 'Completed successfully.', 'succeeded' => 'Completed successfully.',
'partial' => 'Completed with warnings.', 'partial' => 'Completed with warnings.',
'blocked' => 'Execution was blocked.',
default => 'Failed.', default => 'Failed.',
}; };
if ($uxStatus === 'failed') { if (in_array($uxStatus, ['failed', 'blocked'], true)) {
$failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? ''); $failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? '');
$failureMessage = self::sanitizeFailureMessage($failureMessage); $failureMessage = self::sanitizeFailureMessage($failureMessage);
@ -91,6 +93,7 @@ public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $
$status = match ($uxStatus) { $status = match ($uxStatus) {
'succeeded' => 'success', 'succeeded' => 'success',
'partial' => 'warning', 'partial' => 'warning',
'blocked' => 'warning',
default => 'danger', default => 'danger',
}; };

View File

@ -3,6 +3,7 @@
namespace App\Support\OpsUx; namespace App\Support\OpsUx;
use App\Services\Intune\SecretClassificationService; use App\Services\Intune\SecretClassificationService;
use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
final class RunFailureSanitizer final class RunFailureSanitizer
@ -37,12 +38,16 @@ public static function sanitizeCode(string $code): string
public static function normalizeReasonCode(string $candidate): string public static function normalizeReasonCode(string $candidate): string
{ {
$candidate = strtolower(trim($candidate)); $candidate = strtolower(trim($candidate));
$executionDenialReasonCodes = array_map(
static fn (ExecutionDenialReasonCode $reasonCode): string => $reasonCode->value,
ExecutionDenialReasonCode::cases(),
);
if ($candidate === '') { if ($candidate === '') {
return ProviderReasonCodes::UnknownError; return ProviderReasonCodes::UnknownError;
} }
if (ProviderReasonCodes::isKnown($candidate) || in_array($candidate, ['ok', 'not_applicable'], true)) { if (ProviderReasonCodes::isKnown($candidate) || in_array($candidate, ['ok', 'not_applicable'], true) || in_array($candidate, $executionDenialReasonCodes, true)) {
return $candidate; return $candidate;
} }
@ -80,7 +85,7 @@ public static function normalizeReasonCode(string $candidate): string
default => $candidate, default => $candidate,
}; };
if (ProviderReasonCodes::isKnown($candidate) || in_array($candidate, ['ok', 'not_applicable'], true)) { if (ProviderReasonCodes::isKnown($candidate) || in_array($candidate, ['ok', 'not_applicable'], true) || in_array($candidate, $executionDenialReasonCodes, true)) {
return $candidate; return $candidate;
} }

View File

@ -586,6 +586,50 @@ ### Run Log Inspect Affordance Alignment
- **Why this boundary is right**: One resource, one anti-pattern, one fix. Expanding scope to "all run-log surfaces" or "all operation views" would turn a quick correction into a rollout spec and delay the most visible improvement. - **Why this boundary is right**: One resource, one anti-pattern, one fix. Expanding scope to "all run-log surfaces" or "all operation views" would turn a quick correction into a rollout spec and delay the most visible improvement.
- **Priority**: medium - **Priority**: medium
### Admin Visual Language Canon — First-Party UI Convention Codification and Drift Prevention
- **Type**: foundation
- **Source**: admin UI consistency analysis 2026-03-17
- **Problem**: TenantPilot has accumulated a strong set of first-party visual conventions across Filament resources, widgets, detail pages, badges, status indicators, action hierarchies, and operational surfaces. These conventions are emerging organically and are already broadly consistent — but they remain implicit. No canonical reference defines when to use native Filament patterns vs custom enterprise-detail compositions, which badge/status semantics apply to which domain states, how timestamps should render (`since()` vs absolute datetime vs contextual format), what the card/section/surface hierarchy rules are, which widget composition strategies are canonical, or where cross-panel visual divergence is intentional vs accidental. As the product's surface area grows — new policy families, new governance domains, new operational pages, new evidence/reporting surfaces — the risk is not current visual chaos but future drift caused by missing written selection criteria and decision rules.
- **Why it matters**: Without a codified visual language reference, each new surface is a local design decision made without canonical guidance. This produces slow, cumulative inconsistency that becomes expensive to correct retroactively and degrades enterprise UX credibility. The problem is amplified by multi-agent development: multiple contributors (human and AI) cannot converge on implicit conventions they haven't seen documented. The value is not aesthetic — it is architectural: a canonical reference prevents divergent local choices, reduces review friction, accelerates new surface development, and establishes a stable foundation for the product's long-term visual identity without introducing third-party theme dependencies.
- **Proposed direction**:
- Codify the existing first-party admin visual conventions as a canonical reference document (e.g. `docs/ui/admin-visual-language.md` or similar), covering:
- Badge/status semantics: color mapping rules, icon usage criteria, domain-specific badge extraction patterns, when to use Filament native badge vs custom status composition
- Timestamp rendering: decision rules for `since()` (relative) vs absolute datetime vs contextual format, with domain-specific overrides where justified
- Action hierarchy: primary action vs header actions vs row actions vs bulk actions presentation conventions (complementing the Action Surface Contract's interaction-level rules with visual-level guidance)
- Widget composition: selection criteria for stat cards, chart widgets, list widgets, and custom compositions; density and grouping rules
- Surface/card/section hierarchy: when to use native Filament sections vs custom detail cards vs grouped infoblocks; nesting and visual weight rules
- Enterprise-detail page composition: canonical structure for entity detail/view pages (header, metadata, status, content sections, related data)
- Cross-panel visual divergence: explicit rules for where admin-panel and system-panel styling may diverge and where they must converge
- Typography and spacing: canonical use of Filament's built-in text scales and spacing tokens; rules against ad hoc inline styles
- Establish guardrails against ad hoc local visual overrides (documented anti-patterns, PR review checklist items, or lightweight CI checks where practical)
- Explicitly state that native Filament v5 configuration and CSS hook classes remain the primary styling foundation; a thin first-party theme layer is only justified if native configuration proves insufficient for a documented, bounded set of requirements
- Explicitly reject third-party theme packages (e.g. Filament theme marketplace packages) as an architectural baseline unless separately justified by a dedicated evaluation spec with clear acceptance criteria
- Where existing conventions have already diverged, define the canonical choice and flag surfaces that need alignment (as future cleanup tasks, not as part of this spec's implementation scope)
- **In scope**:
- Inventory of existing visual conventions across tier-1 admin surfaces (resources, detail pages, dashboards, operational views)
- Canonical reference document with decision rules and examples
- Anti-pattern catalog (known visual drift patterns to avoid)
- Lightweight enforcement strategy (review checklist, optional CI, or validator approach)
- Explicit architectural position on theme dependencies
- **Out of scope**:
- Visual redesign of any existing surface (this is codification, not redesign)
- Aesthetic refresh or "make it look nicer" polish work
- Third-party theme evaluation, selection, or integration
- Broad Filament view publishing or deep customization layer
- Marketing/branding/identity work (this is internal admin UX, not external brand)
- Color palette redesign or new design-system creation
- Retrofitting all existing surfaces to strict compliance (alignment cleanup is tracked separately per surface)
- **Key architectural positions**:
- Native Filament v5 remains the primary visual foundation. The product's visual identity is expressed through intentional use of native Filament configuration, not through override layers.
- CSS hook classes are the canonical customization mechanism where native configuration is insufficient. No publishing of Filament internal views for styling purposes.
- The main gap is missing canonical reference and decision rules, not missing components or missing technology.
- The value proposition is preventing future UI drift as more surfaces are added, not correcting a current visual crisis.
- **Dependencies**: Action Surface Contract (Spec 082 / v1.1 candidate) for interaction-level conventions that this visual-level reference complements but does not duplicate. Operations Naming Harmonization candidate for operator-facing terminology alignment that is a distinct concern from visual conventions.
- **Related candidates**: Action Surface Contract v1.1, Operations Naming Harmonization, Help Center / Documentation Surface (the visual language reference could eventually link from contextual help)
- **Trigger / best time to do this**: Before the next wave of new governance domain surfaces (Entra Role Governance, Enterprise App Governance, SharePoint Sharing Governance, Evidence Domain) and before the Policy Setting Explorer UX, so those surfaces are built against documented canonical conventions rather than best-effort pattern matching.
- **Risks if ignored**: Slow visual drift across surfaces, increasing review friction for new surfaces, divergent local conventions that become expensive to reconcile, weakened enterprise UX credibility as surface count grows, and higher cost of eventual systematic alignment.
- **Priority**: medium
--- ---
## Covered / Absorbed ## Covered / Absorbed

View File

@ -1,5 +1,6 @@
@php @php
$contextBanner = $this->canonicalContextBanner(); $contextBanner = $this->canonicalContextBanner();
$blockedBanner = $this->blockedExecutionBanner();
$pollInterval = $this->pollInterval(); $pollInterval = $this->pollInterval();
@endphp @endphp
@ -25,6 +26,17 @@
</div> </div>
@endif @endif
@if ($blockedBanner !== null)
@php
$blockedBannerClasses = 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100';
@endphp
<div class="mb-6 rounded-lg border px-4 py-3 text-sm {{ $blockedBannerClasses }}">
<p class="font-semibold">{{ $blockedBanner['title'] }}</p>
<p class="mt-1">{{ $blockedBanner['body'] }}</p>
</div>
@endif
@if ($this->redactionIntegrityNote()) @if ($this->redactionIntegrityNote())
<div class="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100"> <div class="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{{ $this->redactionIntegrityNote() }} {{ $this->redactionIntegrityNote() }}

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Queued Execution Reauthorization and Scope Continuity
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-17
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validated against the current roadmap hardening lane and adjacent specs 144 and 148.
- Scope intentionally excludes query canon, Livewire hardening, and broader domain expansion so planning can stay focused on execution-time trust continuity.

View File

@ -0,0 +1,180 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://tenantpilot.local/specs/149/contracts/execution-legitimacy.schema.json",
"title": "Queued Execution Legitimacy Decision",
"type": "object",
"additionalProperties": false,
"required": [
"operation_type",
"authority_mode",
"allowed",
"retryable",
"target_scope",
"checks"
],
"properties": {
"operation_type": {
"type": "string",
"minLength": 1
},
"authority_mode": {
"type": "string",
"enum": [
"actor_bound",
"system_authority"
]
},
"allowed": {
"type": "boolean"
},
"retryable": {
"type": "boolean",
"description": "Canonical initial mapping: false for scope_denied, capability_denied, and initiator_invalid; true for tenant_not_operable and prerequisite_invalid."
},
"reason_code": {
"type": [
"string",
"null"
],
"enum": [
"workspace_mismatch",
"tenant_not_entitled",
"missing_capability",
"tenant_not_operable",
"tenant_missing",
"initiator_missing",
"initiator_not_entitled",
"provider_connection_invalid",
"write_gate_blocked",
"execution_prerequisite_invalid",
null
]
},
"denial_class": {
"type": [
"string",
"null"
],
"description": "High-level refusal class used to derive retryable behavior and operator-facing blocked-execution explanation.",
"enum": [
"scope_denied",
"capability_denied",
"tenant_not_operable",
"prerequisite_invalid",
"initiator_invalid",
null
]
},
"initiator": {
"type": [
"object",
"null"
],
"additionalProperties": false,
"required": [
"identity_type",
"user_id"
],
"properties": {
"identity_type": {
"type": "string",
"enum": [
"user",
"system"
]
},
"user_id": {
"type": [
"integer",
"null"
],
"minimum": 1
}
}
},
"target_scope": {
"type": "object",
"additionalProperties": false,
"required": [
"workspace_id",
"tenant_id"
],
"properties": {
"workspace_id": {
"type": "integer",
"minimum": 1
},
"tenant_id": {
"type": [
"integer",
"null"
],
"minimum": 1
},
"provider_connection_id": {
"type": [
"integer",
"null"
],
"minimum": 1
}
}
},
"checks": {
"type": "object",
"additionalProperties": false,
"required": [
"workspace_scope",
"tenant_scope",
"capability",
"tenant_operability",
"execution_prerequisites"
],
"properties": {
"workspace_scope": {
"type": "string",
"enum": [
"passed",
"failed",
"not_applicable"
]
},
"tenant_scope": {
"type": "string",
"enum": [
"passed",
"failed",
"not_applicable"
]
},
"capability": {
"type": "string",
"enum": [
"passed",
"failed",
"not_applicable"
]
},
"tenant_operability": {
"type": "string",
"enum": [
"passed",
"failed",
"not_applicable"
]
},
"execution_prerequisites": {
"type": "string",
"enum": [
"passed",
"failed",
"not_applicable"
]
}
}
},
"metadata": {
"type": "object"
}
}
}

View File

@ -0,0 +1,16 @@
# No External API Changes
Spec 149 hardens internal queued-execution behavior only.
## Contract Statement
- No new public HTTP API is introduced.
- No existing Filament route, admin page path, or canonical Monitoring route changes in this slice.
- Existing operator entry points continue to enqueue runs through current action surfaces.
- Existing Monitoring routes continue to display `OperationRun` truth; the changed contract is the meaning of blocked execution, not the route shape.
## Internal Surface Impact
- Start surfaces keep current action labels and links.
- `/admin/operations` and `/admin/operations/{run}` keep current paths.
- Changes are limited to queue execution semantics, run outcome meaning, and audit or notification consistency.

View File

@ -0,0 +1,200 @@
# Phase 1 Data Model: Queued Execution Reauthorization and Scope Continuity
## Overview
This feature does not require a new database table in its first implementation slice. The data-model work is the formalization of existing persisted records plus new derived support-layer objects that express queued execution legitimacy consistently across job families.
## Persistent Domain Entities
### OperationRun
**Purpose**: Canonical workspace-owned observability record for queued tenant-affecting work.
**Key fields**:
- `id`
- `workspace_id`
- `tenant_id` nullable for workspace-scoped runs
- `user_id` nullable for scheduled or system runs
- `initiator_name`
- `type`
- `status`
- `outcome`
- `run_identity_hash`
- `context`
- `summary_counts`
- `failure_summary`
**Relationships**:
- Belongs to one workspace
- May belong to one tenant
- May belong to one human initiator
**Validation rules relevant to this feature**:
- `workspace_id` and `tenant_id` must remain authoritative for scope continuity checks.
- `status` and `outcome` remain service-owned through `OperationRunService`.
- `context` may gain additional execution-authority metadata, but it must remain sanitized and serializable.
**State transitions relevant to this feature**:
- `queued``running` only after legitimacy passes
- `queued` → terminal status with blocked outcome when execution is refused before work begins, using the canonical `OperationRunService` transition path
- retry attempts re-evaluate legitimacy from the current state, not from the original queued truth
### Tenant
**Purpose**: Tenant-owned execution target whose current scope, entitlement, and operability determine whether queued work may begin.
**Key fields**:
- `id`
- `workspace_id`
- lifecycle or operability-related state already consumed by `TenantOperabilityService`
- provider and RBAC-health fields already used by hardening gates
**Relationships**:
- Belongs to one workspace
- Has many provider connections, restore runs, inventory records, and operation runs
**Validation rules relevant to this feature**:
- Must still belong to the expected workspace when the job starts
- Must remain entitled and operable for the requested execution class
### User
**Purpose**: Human initiator for actor-bound queued runs.
**Key fields**:
- `id`
- membership and role relationships already used by capability resolution
**Relationships**:
- May initiate many `OperationRun` records
- May or may not still be entitled to the tenant when execution starts
**Validation rules relevant to this feature**:
- Actor-bound execution must re-check current workspace membership, tenant membership, and required capability
### ProviderConnection
**Purpose**: Provider-backed execution prerequisite for queued provider operations.
**Key fields**:
- `id`
- `tenant_id`
- `provider`
- `status`
- `consent_status`
- `verification_status`
- `entra_tenant_id`
**Relationships**:
- Belongs to one tenant
- May be referenced in `OperationRun.context`
**Validation rules relevant to this feature**:
- Provider-backed jobs must re-check that the connection still matches tenant scope and is still valid before execution side effects occur
## New Derived Domain Objects
### ExecutionAuthorityMode
**Purpose**: Declares whose authority the queued job is executing under.
**Canonical values**:
- `actor_bound`
- `system_authority`
**Behavior**:
- `actor_bound` requires current actor membership, entitlement, and capability checks at execution time
- `system_authority` requires current tenant operability plus explicit system-allowed execution semantics, but not a human capability check
- `system_authority` is valid only when the operation type appears in the canonical system-execution allowlist owned by the execution legitimacy gate and sourced from trusted scheduler or system entry paths
### QueuedExecutionContext
**Purpose**: Normalized evaluation input for execution-time legitimacy.
**Fields**:
- `run`
- `operationType`
- `workspaceId`
- `tenant` nullable
- `initiator` nullable
- `authorityMode`
- `requiredCapability` nullable
- `providerConnectionId` nullable
- `targetScope` structured payload with nullable tenant- or provider-level members when not applicable
- `prerequisiteClass` nullable or list-based
**Validation rules**:
- `workspaceId` must match the resolved workspace of the run and target tenant
- actor-bound context requires an initiator reference or a safe failure path
- target-scope metadata may inform evaluation, but authoritative truth is always re-resolved from current records
### QueuedExecutionLegitimacyDecision
**Purpose**: Structured answer to whether queued work may begin.
**Contract note**:
- The internal PHP DTO may use camelCase property names, but when serialized into `OperationRun.context`, failure payloads, or contract fixtures it must map directly to the schema-defined snake_case contract in `contracts/execution-legitimacy.schema.json`.
**Fields**:
- `operationType`
- `allowed`
- `authorityMode`
- `initiator` nullable
- `targetScope`
- `checks`
- `denialClass` nullable
- `reasonCode` nullable
- `retryable`
- `metadata`
**Behavior**:
- `allowed=false` means the job must not produce side effects
- `targetScope` is always present, with nullable tenant- or provider-level members when a narrower target is not applicable
- `checks` always records the canonical evaluation results for workspace scope, tenant scope, capability, tenant operability, and execution prerequisites
- `retryable` is decided centrally by denial class: `scope_denied`, `capability_denied`, and `initiator_invalid` are terminal; `tenant_not_operable` and `prerequisite_invalid` are retryable and must be re-evaluated fresh on each attempt
- `metadata` may carry safe hints for audit or Monitoring detail views
### ExecutionDenialClass
**Purpose**: High-level category of why execution was refused.
**Canonical values**:
- `scope_denied`
- `capability_denied`
- `tenant_not_operable`
- `prerequisite_invalid`
- `initiator_invalid`
### ExecutionDenialReasonCode
**Purpose**: Stable reason-code vocabulary for execution-time refusal.
**Initial values**:
- `workspace_mismatch`
- `tenant_not_entitled`
- `missing_capability`
- `tenant_not_operable`
- `tenant_missing`
- `initiator_missing`
- `initiator_not_entitled`
- `provider_connection_invalid`
- `write_gate_blocked`
- `execution_prerequisite_invalid`
## Consumer Mapping
| Consumer | Primary execution concern |
|---|---|
| Queue middleware before run start | Evaluate legitimacy before `running` transition |
| `ProviderOperationStartGate` adopters | Preserve dispatch-time gate and add execution recheck |
| Restore or write jobs | Reuse write-hardening semantics inside canonical execution contract |
| Inventory or sync jobs | Re-check actor-bound scope and tenant operability before local mutation work |
| Bulk orchestrator and worker jobs | Re-check legitimacy on orchestrator start and retry paths |
| Monitoring run detail | Render blocked execution reasons distinctly from generic failure |
## Migration Notes
- No persistence migration is required for the first slice.
- New authority metadata can live in `OperationRun.context` and sanitized failure payloads.
- Existing provider-blocked reason handling can be reused rather than replaced.
- Existing `TrackOperationRun` behavior will likely become an adapter over the new legitimacy-first flow rather than remain the earliest middleware in the chain.

View File

@ -0,0 +1,203 @@
# Implementation Plan: Queued Execution Reauthorization and Scope Continuity
**Branch**: `149-queued-execution-reauthorization` | **Date**: 2026-03-17 | **Spec**: [specs/149-queued-execution-reauthorization/spec.md](./spec.md)
**Input**: Feature specification from `/specs/149-queued-execution-reauthorization/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Introduce one canonical execution-legitimacy contract for queued tenant-affecting operations so work is re-authorized when the worker is actually about to act, not only when the operator clicked Start. The implementation will reuse the existing `OperationRun` observability model, `OperationRunService` blocked outcome flow, `TenantOperabilityService`, and write-hardening seams, but add a shared run-time execution gate and middleware ordering that can fail closed before any side effects.
This is a support-layer and job-orchestration hardening feature, not a new UI surface or persistence redesign. The plan therefore focuses on extending the queue execution path already present in `app/Jobs`, `app/Jobs/Middleware`, `app/Services/OperationRunService.php`, `app/Services/Providers`, `app/Services/Tenants`, and `app/Services/Hardening`, then migrating representative high-risk job families first: provider-backed queued runs, restore or write jobs, inventory or sync jobs, and bulk orchestrator families.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament 5, Livewire 4, Pest 4, existing `OperationRunService`, `TrackOperationRun`, `ProviderOperationStartGate`, `TenantOperabilityService`, `CapabilityResolver`, and `WriteGateInterface` seams
**Storage**: PostgreSQL-backed application data plus queue-serialized `OperationRun` context; no schema migration planned for the first implementation slice
**Testing**: Pest 4 unit and feature coverage run through Laravel Sail
**Target Platform**: Laravel Sail web application with queue workers processing Filament-started and scheduled tenant-affecting operations
**Project Type**: Laravel monolith web application
**Performance Goals**: Execution legitimacy checks must complete synchronously before side effects, add no render-time remote calls, and keep per-job startup overhead limited to current authoritative DB lookups plus existing support-layer evaluation
**Constraints**: Preserve existing Ops-UX run lifecycle ownership, terminal notification rules, centralized badge semantics, Filament v5 plus Livewire v4 compliance, provider registration in `bootstrap/providers.php`, and current route contracts; no new Graph bypasses, no asset or panel changes, and no weakening of 404 versus 403 semantics
**Scale/Scope**: One shared execution-legitimacy contract, one queue-middleware or execution-gate integration path, representative adoption across provider, restore, inventory or sync, and bulk job families, plus focused regression coverage under `tests/Feature` and `tests/Unit`
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
**Pre-Phase 0 Gate: PASS**
- Inventory-first: PASS. This feature does not change inventory or snapshot ownership and only hardens when queued jobs may act.
- Read/write separation: PASS WITH HARDENING EMPHASIS. In-scope write paths remain queued, auditable, and confirmation-backed where already required. This feature strengthens fail-closed execution rather than broadening write authority.
- Graph contract path: PASS. No new Microsoft Graph domain is introduced, and existing provider or restore flows continue to rely on existing service layers instead of direct endpoint shortcuts.
- Deterministic capabilities: PASS. Capability enforcement remains tied to the canonical capability registry and resolver services; the feature extends when those checks happen.
- RBAC-UX planes: PASS. This feature remains in the admin `/admin` plane and tenant-context admin starts. Platform `/system` is not broadened. Non-members remain 404, members lacking capability remain 403 in authorization semantics.
- Workspace isolation: PASS. Execution legitimacy will re-check workspace and tenant scope using authoritative records, not UI memory.
- Tenant isolation: PASS. Tenant-bound queued work must still prove current tenant entitlement before acting.
- Destructive confirmation: PASS. No new destructive Filament action is introduced; existing start actions keep current confirmation rules.
- Global search safety: PASS. No global search behavior changes are planned.
- Run observability and Ops-UX: PASS. `OperationRun` remains the canonical observability record, start surfaces remain enqueue-only, Monitoring remains DB-only, and denied execution paths remain terminal run outcomes rather than ad-hoc notifications.
- Ops-UX lifecycle ownership: PASS. `OperationRun.status` and `OperationRun.outcome` remain service-owned through `OperationRunService`; the implementation must not let middleware or jobs bypass that rule.
- Ops-UX summary counts: PASS. Denied runs will continue using existing normalized summary counts and failure payload rules.
- Ops-UX guards: PASS WITH EXTENSION. Existing guard philosophy remains correct; this feature will add focused regression tests around execution-time denial rather than weaken current service-ownership rules.
- Ops-UX system runs: PASS. Scheduled or initiator-null runs remain visible in Monitoring without initiator-only terminal DB notifications.
- Automation and idempotency: PASS. Existing queue locks, idempotency, stale-queued handling, and dedupe contracts remain in force and become more reliable when legitimacy is rechecked before work starts.
- Data minimization: PASS. Denial reasons and audit entries will remain sanitized and secret-free.
- Badge semantics (BADGE-001): PASS. Existing blocked versus failed outcome semantics remain centralized through operation outcome helpers.
- UI naming (UI-NAMING-001): PASS. Operator-facing text continues to use domain wording such as `blocked`, `failed`, `queued`, and `View run`.
- Filament Action Surface Contract: PASS. Visible action inventories are unchanged; only their backend trust contract is hardened.
- Filament UX-001: PASS. No layout change is planned.
- Asset strategy: PASS. No new Filament or front-end assets are needed, so deployment guidance for `php artisan filament:assets` remains unchanged.
**Post-Phase 1 Re-check: PASS**
- The design extends existing support seams instead of introducing a second operation-run lifecycle model.
- No database migration, Graph-contract registry change, panel registration change, or asset build change is required for the first implementation slice.
- Livewire v4 and Filament v5 compliance remain intact, and provider registration stays in `bootstrap/providers.php`.
- Existing global-search requirements remain satisfied because no resource search contract is changed.
## Project Structure
### Documentation (this feature)
```text
specs/149-queued-execution-reauthorization/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ ├── execution-legitimacy.schema.json
│ └── no-external-api-changes.md
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Contracts/
│ └── Hardening/
├── Jobs/
│ ├── Middleware/
│ └── Operations/
├── Models/
│ ├── OperationRun.php
│ ├── ProviderConnection.php
│ ├── Tenant.php
│ └── User.php
├── Notifications/
│ └── OperationRunCompleted.php
├── Policies/
├── Services/
│ ├── Hardening/
│ ├── Inventory/
│ ├── OperationRunService.php
│ ├── Providers/
│ ├── Tenants/
│ └── Verification/
└── Support/
├── Auth/
├── Badges/
├── Operation*/
├── OpsUx/
├── Providers/
└── Tenants/
tests/
├── Feature/
│ ├── Operations/
│ ├── Rbac/
│ ├── Restore/
│ └── Verification/
└── Unit/
├── Jobs/
├── Operations/
└── Tenants/
```
**Structure Decision**: Use the existing Laravel monolith and harden the queue execution boundary in-place. Shared execution legitimacy should live beside the current job, tenant-operability, provider-start, and `OperationRun` seams rather than in a new standalone subsystem.
## Phase 0 Research Summary
- `OperationRunService` already provides the core observability primitives needed for this feature: canonical queued runs, stale-queued failure handling, blocked terminal outcomes, sanitized failure payloads, and terminal audit plus notification emission.
- `ProviderOperationStartGate` is a strong dispatch-time gate for provider-backed operations, but it only validates legitimacy before enqueue and not again inside the worker. The new feature should extend the same contract to execution time rather than replacing it.
- `TrackOperationRun` currently marks runs as `running` before any legitimacy recheck. That ordering is too early for this spec because a denied-at-execution run must fail closed before side effects and ideally before being treated as a real running operation.
- The codebase already distinguishes a provider-blocked outcome through `OperationRunOutcome::Blocked`, `finalizeBlockedRun()`, and provider reason codes. That is the correct existing vocabulary to reuse for denied execution instead of inventing a parallel terminal state.
- Job families are inconsistent today. Provider Gen2 flows use an explicit start gate and connection resolution, restore or write jobs use localized `WriteGateInterface` checks inside the job, and other queued jobs often resolve tenant and user IDs directly without a canonical actor or scope continuity contract.
- `TenantOperabilityService` already centralizes tenant lifecycle and entitlement-aware decisions, but it has no queued-execution lane or execution-specific question. The first implementation slice should reuse its authority while introducing an execution-oriented decision seam rather than reintroducing local lifecycle checks in jobs.
- The audit-derived candidate notes already narrowed the must-answer questions for this feature: execution identity, non-operable tenant handling, retryable versus terminal denials, and `OperationRun` plus `AuditLog` representation. Those questions are fully resolved in `research.md` and carried into the design below.
## Phase 1 Design
### Implementation Approach
1. Add one shared execution-legitimacy contract ahead of side effects.
- Introduce a support-layer decision boundary that evaluates whether a queued operation may still begin.
- Keep `OperationRunService` as the lifecycle owner and treat the new decision layer as the gate that decides whether the run may transition from queued to meaningful execution.
2. Separate dispatch-time acceptance from execution-time legitimacy.
- Keep existing dispatch gates such as `ProviderOperationStartGate` for enqueue-time checks, dedupe, and blocked preflight outcomes.
- Add a second revalidation stage in the worker path so queue delay cannot bypass current authorization, scope, operability, or prerequisite truth.
3. Reuse existing blocked outcome semantics.
- Represent execution-time refusal through `OperationRunOutcome::Blocked` plus structured reason codes and sanitized failure payloads.
- Do not introduce a second terminal state just for execution reauthorization.
4. Distinguish human-bound authority from system authority explicitly.
- Human-initiated runs remain actor-bound and must re-check the current actor's membership, entitlement, and capability at execution time.
- Scheduled or initiator-null runs remain system-authority runs and must re-check allowed system execution plus tenant operability and prerequisites without pretending they are user-authorized actions.
- The allowed system execution policy comes from one canonical operation-type allowlist owned by the execution legitimacy gate and fed only by trusted scheduler or system entry paths.
5. Move the first legitimacy check before `TrackOperationRun` marks a run as running.
- Either introduce a new queue middleware that executes before `TrackOperationRun` or refactor the existing middleware flow so legitimacy is evaluated first.
- The queue worker must not display a denied run as `running` before the denial is known.
6. Scope the first implementation slice to representative high-risk job families.
- Provider-backed runs already using `ProviderOperationStartGate`.
- Restore or write jobs currently guarded by `WriteGateInterface` inside the worker.
- Inventory or sync jobs that resolve tenant and user at execution time and already use `TrackOperationRun`.
- Bulk orchestrator or worker families that fan out destructive tenant-affecting work.
7. Keep the first slice schema-free and asset-free.
- Store new authority or denial metadata inside existing `OperationRun.context` and failure payloads.
- Reuse current Monitoring pages, notifications, and badges.
- Prove the metadata contract with focused regression coverage so blocked execution remains observable without adding persistence fields.
### Planned Workstreams
- **Workstream A: Execution legitimacy core model**
Introduce or extend support-layer types for authority mode, execution context, legitimacy decision, denial classification, reason codes, and the initial retryability mapping. Keep them close to the existing operation and tenant support layers.
- **Workstream B: Queue middleware and lifecycle ordering**
Refactor queue execution entry so legitimacy is evaluated before `TrackOperationRun` marks a run as `running`, while preserving service-owned run transitions and retry-safe behavior.
- **Workstream C: Representative job-family adoption**
Apply the shared contract to provider-backed jobs, restore or write jobs, inventory or sync jobs, and at least one bulk orchestrator family so the new contract is proven across different execution shapes.
- **Workstream D: Denial observability and audit semantics**
Normalize execution-time denial into blocked run outcomes, structured reason codes, Monitoring detail messaging, summary-count-safe payloads, and audit events that clearly separate policy refusal from runtime failure.
- **Workstream E: Regression hardening**
Add focused Pest coverage for allowed paths, lost capability, lost entitlement, tenant non-operability, system-run behavior, retry behavior, representative job-family adoption, and direct-access 404 versus 403 semantics on canonical operations surfaces.
### Testing Strategy
- Add unit tests for the execution-legitimacy decision layer, covering actor-bound and system-authority contexts plus structured denial reasons.
- Add unit tests for the canonical system-authority allowlist and the initial retryability mapping so gate decisions stay deterministic across job families.
- Add unit or integration tests for queue-middleware ordering to prove a run is not marked `running` before legitimacy passes.
- Add focused feature tests for representative provider-backed jobs showing dispatch-time acceptance plus execution-time denial when connection or scope truth changes.
- Add focused feature tests for representative provider-backed, restore, and system-authority flows showing still-legitimate execution continues successfully without false denial.
- Add focused feature tests for restore or write jobs showing existing write-gate checks are folded into the canonical execution contract rather than left as isolated local patterns.
- Add focused feature tests for inventory or sync jobs showing lost capability, lost tenant membership, and non-operable tenant outcomes are blocked before execution.
- Add focused feature tests for at least one bulk orchestrator family showing retries perform a fresh legitimacy evaluation and blocked execution remains observable.
- Add focused tests proving initiator-null runs do not emit initiator-only terminal database notifications while still recording blocked terminal outcomes in Monitoring.
- Add or update run-detail, notification, and canonical operations access tests to prove blocked execution remains distinct from generic failure while preserving 404 versus 403 semantics.
- Add focused tests proving blocked-run summary counts remain normalized through the canonical summary key contract.
- Add focused tests proving authority and denial metadata stays inside existing `OperationRun` context and failure payload structures with no schema change.
- Run the minimum focused Pest suite through Sail; no full-suite run is required for planning artifacts.
## Complexity Tracking
No constitution violations or exceptional complexity are planned at this stage.

View File

@ -0,0 +1,92 @@
# Quickstart: Queued Execution Reauthorization and Scope Continuity
## Goal
Validate that queued tenant-affecting work is re-authorized when execution begins, blocked runs fail closed before side effects, and Monitoring clearly distinguishes blocked execution from generic failure.
## Prerequisites
1. Start Sail.
2. Ensure at least one workspace exists with a tenant that can run provider, inventory, or restore operations.
3. Ensure at least one actor-bound operation and one initiator-null or scheduled-style operation can be queued in the local environment.
4. Ensure queue workers are running through Sail.
## Implementation Validation Order
### 1. Run focused unit coverage for the execution-legitimacy core
```bash
vendor/bin/sail artisan test --compact tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php
vendor/bin/sail artisan test --compact tests/Feature/Operations/QueuedExecutionMiddlewareOrderingTest.php
```
Expected outcome:
- Legitimacy decisions distinguish actor-bound and system-authority execution.
- Denial reasons distinguish capability, scope, operability, and prerequisite failures.
- A run is not marked `running` before legitimacy passes.
### 2. Run focused provider-operation tests
```bash
vendor/bin/sail artisan test --compact tests/Feature/Verification/ProviderExecutionReauthorizationTest.php
vendor/bin/sail artisan test --compact tests/Feature/Operations/RunInventorySyncExecutionReauthorizationTest.php
```
Expected outcome:
- Dispatch-time provider blocking still works.
- Execution-time scope or prerequisite changes block the job before side effects.
- Blocked runs remain visible in Monitoring with stable reason codes.
### 3. Run focused restore and write-hardening tests
```bash
vendor/bin/sail artisan test --compact tests/Feature/Operations/ExecuteRestoreRunExecutionReauthorizationTest.php
```
Expected outcome:
- Restore or write jobs no longer depend on job-local checks alone.
- Execution-time blocking remains observable as a blocked run, not a silent skip or generic failure.
### 4. Run focused bulk and retry-path tests
```bash
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
```
Expected outcome:
- Representative bulk orchestrators re-check legitimacy when execution begins.
- Retry attempts perform a fresh legitimacy decision instead of inheriting stale authority.
### 5. Run focused Monitoring and notification tests
```bash
vendor/bin/sail artisan test --compact tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php
vendor/bin/sail artisan test --compact tests/Feature/Operations/SystemRunBlockedExecutionNotificationTest.php
vendor/bin/sail artisan test --compact tests/Feature/Operations/QueuedExecutionAuditTrailTest.php
vendor/bin/sail artisan test --compact tests/Feature/Operations/TenantlessOperationRunViewerTest.php
```
Expected outcome:
- User-initiated blocked runs still use the canonical terminal feedback path.
- Initiator-null blocked runs remain visible in Monitoring without initiator-only terminal DB notifications.
- Run detail clearly distinguishes `blocked` from `failed`.
- Canonical operations access preserves `404` for non-entitled actors and `403` for in-scope capability denial on both `/admin/operations` and `/admin/operations/{run}`.
### 6. Manual smoke-check in the browser
1. Start a tenant-affecting queued operation from an existing admin surface.
2. Before the worker processes it, revoke the initiating user's relevant capability or tenant membership.
3. Let the worker pick up the job and confirm `/admin/operations` shows the blocked run without misclassifying it as generic failure.
4. Open `/admin/operations/{run}` and confirm the run is terminal with a blocked-style reason and no side effects occurred.
5. Validate that a non-entitled actor receives `404` and an in-scope actor lacking capability receives `403` on both canonical operations surfaces.
6. Repeat with a tenant that becomes non-operable before execution.
7. Repeat with a scheduled or initiator-null run and confirm Monitoring shows the blocked outcome without a user-targeted completion notification.
## Non-Goals For This Slice
- No new external or public API routes.
- No new Graph contract registry entries.
- No new assets, panel registration changes, or UI redesign.
- No repo-wide migration of every queued job in one pass beyond the explicitly in-scope job families listed in this spec.

View File

@ -0,0 +1,65 @@
# Phase 0 Research: Queued Execution Reauthorization and Scope Continuity
## Decision: Extend the existing `OperationRun` and queue middleware seam instead of creating a second execution framework
**Rationale**: The repo already has the right core primitives for observability and queue orchestration: `OperationRunService`, `TrackOperationRun`, `ProviderOperationStartGate`, blocked outcome semantics, and sanitized terminal audit handling. The missing piece is not an entirely new framework but a canonical execution-legitimacy check that runs before jobs start doing real work.
**Alternatives considered**:
- Create a separate execution-orchestration subsystem just for reauthorization: rejected because it would duplicate `OperationRun` lifecycle ownership and make the queue path harder to reason about.
- Keep adding local checks inside individual jobs: rejected because that is the exact drift pattern this feature is supposed to eliminate.
## Decision: Reuse `OperationRunOutcome::Blocked` as the canonical execution-denial outcome
**Rationale**: `OperationRunOutcome` already includes `blocked`, and `OperationRunService::finalizeBlockedRun()` already writes sanitized blocked outcomes with reason codes, next steps, terminal audit, and normal terminal notification behavior. Reusing that vocabulary keeps Monitoring and operator language consistent.
**Alternatives considered**:
- Add a new `denied` terminal outcome: rejected because it would fork existing outcome semantics and badge behavior without a strong product need.
- Represent execution-time denial as `failed`: rejected because the spec explicitly requires a clear distinction between intentional trust-policy refusal and ordinary runtime failure.
## Decision: Human-initiated queued runs remain actor-bound; scheduled runs remain system-authority runs
**Rationale**: The architecture audit raised the core identity question directly. For human-initiated work, the safest and most comprehensible rule is that authority must still belong to the initiating actor when the job begins. For scheduled or initiator-null work, the system must act under explicit system authority and current tenant operability rather than pretending a user still owns the action.
**Alternatives considered**:
- Convert all queued jobs into system-owned authority after dispatch: rejected because that would silently broaden authority and weaken audit meaning.
- Freeze the dispatch-time actor snapshot as permanent authority: rejected because that preserves the stale-legitimacy gap this spec is trying to close.
## Decision: Put the legitimacy check before `TrackOperationRun` marks runs as `running`
**Rationale**: `TrackOperationRun` currently transitions the run to `running` before the job body executes. For this feature, that is too late and too optimistic. A blocked-at-execution job should fail closed before side effects and before Monitoring treats it as an active operation.
**Alternatives considered**:
- Leave `TrackOperationRun` as-is and block inside each job body: rejected because jobs would already look like active execution and the ordering would vary by job.
- Mark the run running first and immediately block it afterward: rejected because it creates misleading transient truth in Monitoring and leaves room for side effects to start too early.
## Decision: Reuse `TenantOperabilityService` for tenant-state truth, but add an execution-oriented decision seam
**Rationale**: Tenant operability is already centralized for selector, route, and lifecycle-safe action semantics. The queue execution path should not reintroduce raw lifecycle checks. At the same time, the existing lanes and questions do not directly represent queued execution, so the plan should extend the central seam with an execution-oriented question or adjacent support primitive.
**Alternatives considered**:
- Hardcode tenant lifecycle checks inside jobs: rejected because it recreates the same drift pattern that Specs 143, 144, and 148 are reducing elsewhere.
- Ignore tenant operability and only re-check capability: rejected because archived, discarded, or otherwise non-operable tenants are a distinct class of invalid execution.
## Decision: Treat execution-prerequisite failures separately from capability or membership loss, but still fail closed before work
**Rationale**: The feature needs structured denial reasons, not just a boolean. Existing code already distinguishes provider-configuration blocks and write-gate failures. The execution contract should preserve that distinction so operators can tell the difference between authorization loss, tenant non-operability, and prerequisite invalidity.
**Alternatives considered**:
- Collapse every denied start into one generic `blocked` reason: rejected because the spec requires operator and audit clarity.
- Treat prerequisite failures as retryable by default: rejected because some prerequisite failures are deterministic policy blocks and should be terminal until state changes.
## Decision: Scope the first implementation slice to representative queued job families, not every queued job in the repo
**Rationale**: The repo has dozens of `ShouldQueue` jobs. Planning all of them as day-one adopters would produce a vague plan and stall execution. The feature needs one shared contract plus enough representative adoption to prove it works across provider-backed operations, restore or write jobs, inventory or sync jobs, and bulk orchestrators.
**Alternatives considered**:
- Attempt repo-wide queue adoption in one slice: rejected because it is too large for a focused hardening feature.
- Apply the contract to one provider job only: rejected because that would leave the architecture mostly unchanged while claiming closure.
## Decision: Preserve existing external routes and keep the first slice schema-free
**Rationale**: This feature is an internal execution-hardening change. Existing Filament and Monitoring routes remain the same, and the required new metadata can live in `OperationRun.context` and failure payloads. That keeps the first slice focused on behavior, not API or persistence churn.
**Alternatives considered**:
- Introduce new routes or a separate operations API just for execution legitimacy: rejected because the feature does not require a new operator flow.
- Add dedicated persistence tables for denial state: rejected because existing `OperationRun` and `AuditLog` structures already provide the right observability foundation.

View File

@ -0,0 +1,187 @@
# Feature Specification: Queued Execution Reauthorization and Scope Continuity
**Feature Branch**: `149-queued-execution-reauthorization`
**Created**: 2026-03-17
**Status**: Draft
**Input**: User description: "Queued Execution Reauthorization and Scope Continuity"
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**:
- `/admin/t/{tenant}/...`
- `/admin/operations`
- `/admin/operations/{run}`
- Tenant-context and admin-plane surfaces that start queued tenant-affecting operations
- Scheduled and system-triggered operation entry paths that enqueue tenant-affecting work
- **Data Ownership**:
- `OperationRun` remains the canonical workspace-owned observability record.
- Queued jobs may act on tenant-owned records, but execution legitimacy must be re-evaluated against current workspace and tenant scope at run time.
- This feature does not change ownership boundaries and does not introduce a second queue identity model.
- **Implementation Slice**:
- For this first implementation slice, in-scope queued operation families are provider-backed queued runs, restore or write jobs, inventory or sync jobs, bulk orchestrator or worker families, and scheduled backup runs explicitly adopted by this spec.
- Other queued families are out of scope for this slice until they are explicitly adopted into the same canonical execution legitimacy contract.
- **RBAC**:
- Authorization planes involved: admin `/admin` routes, tenant-context admin surfaces, and queued execution that resumes later from those starts.
- Workspace or tenant non-members remain deny-as-not-found.
- In-scope members who lack the required capability at execution time remain forbidden in authorization semantics and must be represented to operators as a blocked execution outcome with a clear denial reason rather than a successful run.
- Canonical Operations surfaces keep their current filter semantics when tenant context is active; this feature changes blocked-outcome meaning and access continuity, not tenant-context prefilter behavior on `/admin/operations` or `/admin/operations/{run}`.
- Platform `/system` access is not broadened by this feature.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Stop Invalid Queued Mutations Before They Start (Priority: P1)
As a workspace operator, I need a queued operation to prove it is still legitimate when it actually begins, so that a previously allowed click cannot mutate tenant state after access, tenant operability, or scope conditions have changed.
**Why this priority**: This is the core trust boundary. If queued work can outlive the legitimacy of the actor or tenant scope that started it, every downstream governance feature inherits a backend integrity problem.
**Independent Test**: Can be fully tested by enqueueing a tenant-affecting operation, changing authorization or tenant operability before execution starts, and confirming the job is refused before any remote or local mutation work begins.
**Acceptance Scenarios**:
1. **Given** a user queues a tenant-affecting operation and then loses the required capability before the worker starts, **When** execution begins, **Then** the operation is refused before any mutation work occurs.
2. **Given** a user queues a tenant-affecting operation and the tenant becomes non-operable before execution begins, **When** execution begins, **Then** the operation is refused before any mutation work occurs.
3. **Given** a queued operation starts and the actor remains entitled and the tenant remains operable, **When** execution begins, **Then** the operation proceeds normally.
---
### User Story 2 - Understand Why A Queued Operation Was Refused (Priority: P1)
As a workspace operator, I need refused queued work to appear as an explicit blocked outcome with clear audit and operations visibility, so that I can distinguish trust-policy enforcement from ordinary runtime failure.
**Why this priority**: Operators and auditors need to know whether the system protected the tenant intentionally or simply crashed. Without that distinction, monitoring data becomes misleading.
**Independent Test**: Can be fully tested by forcing an execution-time block and verifying that the resulting operation history, audit trail, and operator-facing outcome clearly identify a blocked execution rather than a generic failure.
**Acceptance Scenarios**:
1. **Given** a queued operation is refused at execution time, **When** the operator opens operation history, **Then** the run shows a terminal blocked outcome with a structured denial reason that distinguishes it from ordinary processing failure.
2. **Given** a user-triggered queued operation is refused at execution time, **When** the run becomes terminal, **Then** the initiator receives the normal terminal operation feedback path for the blocked outcome.
3. **Given** a scheduled or system-triggered queued operation is refused at execution time, **When** the run becomes terminal, **Then** the outcome is visible in Monitoring and audit surfaces without creating an initiator-only terminal database notification.
---
### User Story 3 - Enforce One Trust Contract Across Queued Job Families (Priority: P2)
As a product owner, I need queued execution legitimacy to follow one canonical contract across high-risk job families, so that future features do not keep re-implementing local authorization checks with inconsistent results.
**Why this priority**: A local patch in one job family does not solve the architectural gap. The strategic value comes from a reusable execution contract.
**Independent Test**: Can be fully tested by applying the same blocked-path and allowed-path checks to multiple in-scope queued operation families and confirming they produce the same legitimacy and observability behavior.
**Acceptance Scenarios**:
1. **Given** two different queued tenant-affecting operation types are started under the same actor and tenant conditions, **When** execution-time legitimacy changes before both run, **Then** both jobs follow the same blocked-outcome and denial-reason semantics.
2. **Given** an in-scope queued operation is retried after a previous blocked execution, **When** the retry starts, **Then** execution-time legitimacy is checked again instead of inheriting the previous attempt's result.
### Edge Cases
- The actor remains a workspace member but loses tenant membership before execution begins.
- The actor remains a tenant member but loses the specific capability required for the queued operation before execution begins.
- The tenant is archived, discarded, or otherwise becomes non-operable after dispatch but before execution.
- The queued run is retried after an earlier blocked attempt and current legitimacy has changed again.
- The queued run was created by a scheduler or system actor with no interactive initiator.
- The target record still exists, but the provider connection or other execution prerequisite is no longer valid at run time.
- A long queue delay means the selected tenant context in the browser is irrelevant or stale by the time execution starts.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature hardens existing queued and long-running tenant-affecting work. It introduces no new Microsoft Graph domain, but every in-scope execution path that can mutate state or call remote mutation endpoints must revalidate legitimacy before work begins. Existing contract-registry discipline remains unchanged: no direct Graph bypasses are allowed. Safety gates become two-phase: dispatch-time acceptance plus execution-time legitimacy recheck. Tenant isolation remains mandatory at execution time, not only at dispatch. In-scope operations continue to use `OperationRun` as the canonical run record, and blocked execution paths must remain auditable and test-covered.
**Constitution alignment (OPS-UX):** This feature reuses existing `OperationRun` types and must comply with the Ops-UX 3-surface feedback contract. Start surfaces remain intent-only and may only show queued feedback. Progress remains visible only in the active-ops widget and run-detail surfaces. Execution-time blocked outcomes are terminal outcomes and must use the canonical terminal notification path for user-initiated runs. `OperationRun.status` and `OperationRun.outcome` transitions remain service-owned through `OperationRunService`. Any summary counts written for blocked runs must continue to use `OperationSummaryKeys::all()` with flat numeric-only values. Scheduled and system-triggered runs continue to have no initiator terminal database notification.
**Constitution alignment (RBAC-UX):** This feature changes authorization behavior in the admin `/admin` plane and tenant-context admin surfaces by extending authorization continuity from dispatch time to execution time. Cross-plane access remains deny-as-not-found. 404 semantics still mean the actor is not entitled to the workspace or tenant scope. 403 semantics still mean the actor is in scope but lacks the required capability. Execution-time enforcement must be server-side and must rely on the canonical capability registry, current workspace and tenant entitlement, and current tenant operability rather than remembered UI context or dispatch-time assumptions. Global search behavior is unchanged. Existing destructive start actions remain confirmation-protected where already required.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. This feature concerns queued tenant operations and Monitoring, not `/auth/*` handshake exceptions.
**Constitution alignment (BADGE-001):** This feature refines blocked execution presentation on operations surfaces so blocked remains the canonical terminal outcome while structured denial reasons explain why execution was refused. Any resulting status or outcome presentation must remain centralized through the existing operation outcome or badge semantics rather than page-local mappings.
**Constitution alignment (UI-NAMING-001):** The target object is the queued operation run. Operator-facing verbs remain `Start`, `View run`, and existing operation-specific verbs such as `Sync`, `Verify`, or `Restore`. New operator-facing outcome and audit language must use consistent domain wording such as `execution blocked`, `authorization changed`, `tenant no longer operable`, or `execution prerequisites no longer valid`. Internal implementation phrases must not become the primary operator vocabulary.
**Constitution alignment (Filament Action Surfaces):** This feature modifies the trust semantics behind existing Filament start actions and Monitoring detail surfaces. The Action Surface Contract remains satisfied because the visible surface change is limited to start behavior and run-outcome semantics; no new destructive action family is introduced.
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature does not introduce new Create, Edit, or View layouts. Existing start surfaces and operation-detail surfaces keep their current structure. UX impact is limited to clearer blocked-execution outcomes and trust-safe operator messaging.
### Functional Requirements
- **FR-149-001**: The system MUST require execution-time legitimacy revalidation for every in-scope queued operation before the first local mutation, remote mutation, or irreversible external side effect occurs.
- **FR-149-002**: Dispatch-time authorization alone MUST NOT be sufficient to permit later queued execution.
- **FR-149-003**: Execution-time legitimacy revalidation MUST evaluate current workspace entitlement, current tenant entitlement when the run is tenant-bound, current capability requirements for the specific operation type, current tenant operability, and current execution prerequisites required for that operation family.
- **FR-149-004**: If execution-time legitimacy fails, the system MUST refuse the queued operation before any in-scope mutation work begins.
- **FR-149-005**: If execution-time legitimacy fails, the system MUST produce an explicit terminal blocked operation outcome that distinguishes policy enforcement from ordinary runtime failure.
- **FR-149-006**: If execution-time legitimacy fails for a user-initiated run, the system MUST deliver the canonical terminal operation feedback to the initiating user and MUST NOT emit ad-hoc blocked-execution notifications outside the existing operation feedback contract.
- **FR-149-007**: If execution-time legitimacy fails for a scheduled or system-triggered run, the system MUST record the outcome in Monitoring and audit surfaces without creating an initiator-only terminal database notification.
- **FR-149-008**: The system MUST record a structured denial reason that distinguishes at least capability loss, membership or scope loss, tenant non-operability, and execution-prerequisite failure.
- **FR-149-009**: Execution-time legitimacy evaluation MUST use canonical authorization and tenant-context authorities and MUST NOT rely on selected browser tenant context, remembered UI state, or copied dispatch-time role strings.
- **FR-149-010**: In-scope queued job retries MUST perform a fresh execution-time legitimacy recheck for each attempt.
- **FR-149-011**: In-scope queued operations that remain legitimate at execution time MUST continue through the existing operation flow without added operator friction beyond the execution-time recheck.
- **FR-149-012**: This feature MUST define one reusable execution legitimacy contract for in-scope queued operation families instead of requiring job-specific ad-hoc authorization patches.
- **FR-149-013**: The reusable execution legitimacy contract MUST support both actor-initiated runs and scheduled or system-initiated runs without collapsing them into the same identity semantics.
- **FR-149-014**: For actor-initiated runs, execution-time legitimacy MUST remain bound to the current actor's live authorization and scope relationship, not merely to the fact that the actor originally clicked the action.
- **FR-149-015**: For scheduled or system-initiated runs, execution-time legitimacy MUST remain bound to the allowed system execution policy and current tenant operability, even when no interactive actor exists.
- **FR-149-016**: Operation history for blocked execution MUST remain viewable through the canonical operations surfaces and must provide enough explanation for operators to understand what changed.
- **FR-149-017**: Audit logging for blocked execution MUST capture the operation type, affected workspace and tenant context when present, denial class, and acting identity category without revealing secrets.
- **FR-149-018**: This feature MUST preserve existing deny-as-not-found versus forbidden semantics for direct resource access while representing execution-time refusal as a run outcome rather than a silent disappearance of the run.
- **FR-149-019**: In-scope implementations MUST NOT allow execution-time authorization continuity gaps to be resolved only in UI code, Filament visibility logic, or dispatch-time controller or Livewire action logic.
- **FR-149-020**: Regression coverage for this feature MUST include at least one allowed execution path, one lost-capability path, one lost-membership or wrong-scope path, one tenant-non-operable path, one scheduled-or-system path, and one retry path.
- **FR-149-021**: Execution-authority and denial metadata for this feature MUST be stored within existing `OperationRun` context and failure payload structures, and the first implementation slice MUST NOT require a schema migration to represent blocked execution decisions.
- **FR-149-022**: The allowed system execution policy for scheduled or initiator-null runs MUST be resolved from one canonical operation-type allowlist owned by the execution legitimacy gate and populated only by trusted scheduler or system entry paths, not by job-local ad-hoc checks.
- **FR-149-023**: Retryability MUST be determined centrally by the execution legitimacy contract using a stable initial rule set: `scope_denied`, `capability_denied`, and `initiator_invalid` are terminal (`retryable=false`), while `tenant_not_operable` and `prerequisite_invalid` are retryable (`retryable=true`) and must be re-evaluated fresh on each attempt.
## UI Action Matrix *(mandatory when Filament is changed)*
If this feature adds or modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Tenant and admin start surfaces | Existing admin and tenant-context pages that enqueue tenant-affecting work | Existing surface-specific actions unchanged | Existing links or row inspection unchanged | Existing start verbs such as `Sync`, `Verify`, `Restore`, or equivalent in-scope start actions | Existing grouped bulk actions unchanged in this spec | Existing empty-state CTAs unchanged | Not applicable | Not applicable | Yes for blocked execution and existing sensitive mutations | Start actions remain dispatch-time intent surfaces only. This spec adds execution-time legitimacy semantics behind them rather than a new visible action family. |
| Operations index | `/admin/operations` | Existing scope and filter controls unchanged | Clickable row or primary linked run identifier leading to `View run` detail | None | Existing grouped bulk actions unchanged | Existing empty state unchanged | Not applicable | Not applicable | Existing audit model unchanged | Existing inspect behavior is treated as the canonical affordance for this modified surface; no lone `View run` row action is introduced by this spec. Blocked execution outcomes must be legible and distinct from generic failure. |
| Operation run detail | `/admin/operations/{run}` | Existing navigation actions unchanged | Canonical run detail page | None | None | Not applicable | Existing `View` and related follow-up actions unchanged | Not applicable | Existing audit model unchanged | This spec changes outcome semantics and explanation, not page category or layout. |
### Key Entities *(include if feature involves data)*
- **Queued Operation Request**: A user-initiated or system-initiated instruction that has been accepted for later execution but is not yet allowed to mutate anything purely because it was queued.
- **Execution Legitimacy Decision**: The authoritative run-time answer to whether a queued operation may still begin, based on current scope, capability, operability, and prerequisites.
- **Operation Run**: The canonical workspace-owned observability record that tracks queued intent, execution progress, blocked execution, success, or failure.
- **Execution Denial Audit Event**: The audit-trail representation of a queued operation that was intentionally refused before work began.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-149-001**: In focused regression coverage, 100% of in-scope queued operations that lose legitimacy before execution are refused before any mutation work begins.
- **SC-149-002**: In focused regression coverage, 0 blocked execution cases are reported as generic runtime failures when the real outcome is policy enforcement.
- **SC-149-003**: In focused regression coverage, 100% of still-legitimate queued operations proceed successfully through the existing execution path without false blocking.
- **SC-149-004**: In focused regression coverage, 100% of covered retry attempts perform a fresh execution-time legitimacy check.
- **SC-149-005**: In focused regression coverage, 100% of covered scheduled or system-initiated blocked cases remain visible in Monitoring without producing an initiator-only terminal database notification.
- **SC-149-006**: In focused review of in-scope queued job families, every covered family uses the same canonical execution legitimacy contract rather than a new local authorization pattern.
- **SC-149-007**: In focused RBAC regression coverage, 100% of direct access attempts to canonical operations surfaces preserve deny-as-not-found for non-entitled actors and forbidden for in-scope capability denial.
## Assumptions
- Spec 144 and Spec 148 have already established the broader workspace-trust and tenant-operability direction that this feature extends into queued execution.
- Existing queued operation families already create or reuse canonical `OperationRun` records and do not need a second observability model.
- Existing capability registry and tenant-operability authorities are available and should be reused rather than replaced.
- This feature prioritizes tenant-affecting queued work and does not require every read-only background activity in the product to adopt the same contract immediately.
## Dependencies
- Existing operations semantics and `OperationRun` lifecycle rules
- Audit log foundation
- Canonical tenant context and operability hardening work
- In-scope queued job families that can mutate tenant-affecting state or call remote mutation endpoints
## Risks
- Applying the contract only to one or two jobs would leave the architecture vulnerable while creating a false sense of closure.
- Overloading denial reasons with low-value technical detail could make operator messaging noisier instead of clearer.
- Treating tenant operability as optional at execution time would preserve a key class of stale-legitimacy bugs.
- Treating scheduled runs as if they were actor-initiated runs would blur audit meaning and notification behavior.
## Final Direction
Queued work must be legitimate twice: once when the system accepts the intent, and again when the worker is actually about to act. This feature makes execution-time legitimacy a first-class contract for tenant-affecting queued operations so the platform can safely delay work without delaying trust checks.

View File

@ -0,0 +1,209 @@
# Tasks: Queued Execution Reauthorization and Scope Continuity
**Input**: Design documents from `/specs/149-queued-execution-reauthorization/`
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
**Tests**: Runtime behavior changes in this repo require Pest coverage. This feature changes queued execution semantics, authorization continuity, Monitoring outcomes, and audit behavior, so tests are required for every user story.
**Operations**: This feature reuses existing `OperationRun` records and queued work. Tasks below enforce the Ops-UX 3-surface contract, keep `OperationRun.status` and `OperationRun.outcome` service-owned via `OperationRunService`, preserve initiator-only terminal notifications, and keep blocked execution observable through canonical Monitoring routes.
**RBAC**: This feature changes authorization continuity in the admin `/admin` plane and tenant-context admin surfaces. Tasks below preserve `404` for non-members or non-entitled actors, `403` for in-scope capability denial, and canonical capability-registry usage with no raw role-string checks.
**UI Naming**: Blocked execution copy, run-detail text, and audit prose must keep using consistent operator-facing vocabulary such as `blocked`, `failed`, `queued`, and `View run`.
**Filament UI Action Surfaces**: This feature changes backend trust semantics behind existing Filament start actions and Monitoring pages. No new action family is introduced; existing action surfaces stay intact while run outcomes and detail explanations become more precise.
**Filament UI UX-001**: This feature is not a layout redesign. Existing Monitoring and start surfaces keep their current layouts.
**Badges**: Blocked-versus-failed outcome rendering must continue to use centralized operation badge semantics.
**Contract Artifacts**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/149-queued-execution-reauthorization/contracts/execution-legitimacy.schema.json` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/149-queued-execution-reauthorization/contracts/no-external-api-changes.md` are internal design contracts for the execution-legitimacy boundary and route stability, not requirements to add new public HTTP endpoints.
**Organization**: Tasks are grouped by user story so each story can be implemented and tested independently.
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Prepare regression targets and representative execution paths for the queued-execution hardening work.
- [X] T001 [P] Create or extend the execution-core regression targets in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/QueuedExecutionMiddlewareOrderingTest.php
- [X] T002 [P] Create or extend provider and inventory execution-regression targets in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Verification/ProviderExecutionReauthorizationTest.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/RunInventorySyncExecutionReauthorizationTest.php
- [X] T003 [P] Create or extend restore and system-run regression targets in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/ExecuteRestoreRunExecutionReauthorizationTest.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/SystemRunBlockedExecutionNotificationTest.php
- [X] T004 [P] Create or extend bulk and retry-path regression targets in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/BulkOperationExecutionReauthorizationTest.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/QueuedExecutionRetryReauthorizationTest.php
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Build the shared execution-legitimacy boundary that all user stories depend on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T005 Create the execution-legitimacy support types in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Operations/ExecutionAuthorityMode.php, /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Operations/ExecutionDenialClass.php, /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Operations/ExecutionDenialReasonCode.php, /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Operations/QueuedExecutionContext.php, and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Operations/QueuedExecutionLegitimacyDecision.php
- [X] T006 Implement the canonical execution gate, including the system-authority allowlist and initial retryability mapping, in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Operations/QueuedExecutionLegitimacyGate.php and bind any required dependencies in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Providers/AppServiceProvider.php
- [X] T007 Refactor queue entry ordering in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/Middleware/TrackOperationRun.php and add /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/Middleware/EnsureQueuedExecutionLegitimate.php so legitimacy is evaluated before a run is marked `running`
- [X] T008 [P] Extend blocked execution lifecycle handling in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/OperationRunService.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/OperationRunOutcomeBadge.php to preserve centralized blocked-versus-failed semantics
- [X] T009 [P] Add foundational unit and middleware coverage for legitimacy ordering, system-authority allowlisting, and retryability mapping in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/QueuedExecutionMiddlewareOrderingTest.php
**Checkpoint**: Foundation ready. The repo has one shared execution-legitimacy boundary, and user stories can now adopt it independently.
---
## Phase 3: User Story 1 - Stop Invalid Queued Mutations Before They Start (Priority: P1) 🎯 MVP
**Goal**: Ensure queued tenant-affecting work is refused before side effects when capability, scope, or tenant operability drift after dispatch.
**Independent Test**: Queue representative tenant-affecting operations, change capability or tenant operability before the worker starts, and verify the jobs are blocked before any mutation work begins.
### Tests for User Story 1
- [X] T010 [P] [US1] Add actor-bound capability-loss, tenant-scope-loss, and still-legitimate allowed-path coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Verification/ProviderExecutionReauthorizationTest.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/RunInventorySyncExecutionReauthorizationTest.php
- [X] T011 [P] [US1] Add tenant-non-operable, write-gate denial, and still-legitimate restore allowed-path coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/ExecuteRestoreRunExecutionReauthorizationTest.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/QueuedExecutionMiddlewareOrderingTest.php
### Implementation for User Story 1
- [X] T012 [US1] Attach execution-authority and required-capability metadata at enqueue time in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Providers/ProviderOperationStartGate.php, /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Verification/StartVerification.php, and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Operations/OperationRunCapabilityResolver.php
- [X] T013 [US1] Adopt the shared execution gate in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/ProviderConnectionHealthCheckJob.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/RunInventorySyncJob.php so actor-bound queued work blocks before side effects
- [X] T014 [US1] Adopt the shared execution gate in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/ExecuteRestoreRunJob.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/RestoreAssignmentsJob.php so write jobs fail closed on scope, capability, and operability drift
- [X] T015 [US1] Normalize job-side legitimacy hooks for queued starts and retries in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/Middleware/EnsureQueuedExecutionLegitimate.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Operations/QueuedExecutionLegitimacyGate.php
**Checkpoint**: User Story 1 is complete when representative queued jobs refuse invalid execution before any tenant-affecting side effects occur.
---
## Phase 4: User Story 2 - Understand Why A Queued Operation Was Refused (Priority: P1)
**Goal**: Make blocked execution visible as an intentional policy refusal in Monitoring, audit, and terminal feedback instead of an indistinct runtime failure.
**Independent Test**: Force execution-time blocking for user-initiated and initiator-null runs and verify Monitoring, audit, and notification behavior clearly identify blocked execution.
### Tests for User Story 2
- [X] T016 [P] [US2] Add blocked outcome presentation, reason-code, and normalized summary-count coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/TenantlessOperationRunViewerTest.php
- [X] T017 [P] [US2] Add initiator-null notification, audit regression, and direct-access 404-versus-403 coverage for both /admin/operations and /admin/operations/{run} in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/SystemRunBlockedExecutionNotificationTest.php, /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/QueuedExecutionAuditTrailTest.php, and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/TenantlessOperationRunViewerTest.php
### Implementation for User Story 2
- [X] T018 [US2] Extend terminal blocked-execution handling in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/OperationRunService.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Notifications/OperationRunCompleted.php so user-initiated runs keep canonical terminal feedback without ad-hoc denial notifications
- [X] T019 [US2] Surface blocked execution reasons in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php
- [X] T020 [US2] Normalize blocked execution audit, Monitoring copy, and summary-count-safe payload handling in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/OperationRunService.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/OperationRunOutcomeBadge.php
**Checkpoint**: User Story 2 is complete when blocked execution is clearly visible as policy refusal rather than generic failure across Monitoring, audit, and terminal feedback.
---
## Phase 5: User Story 3 - Enforce One Trust Contract Across Queued Job Families (Priority: P2)
**Goal**: Apply one reusable execution-legitimacy contract across representative job families and retry paths instead of local one-off checks.
**Independent Test**: Apply the same allowed-path and blocked-path scenarios to provider, restore, inventory, bulk, and system-authority jobs and confirm they all follow the same legitimacy and observability semantics.
### Tests for User Story 3
- [X] T021 [P] [US3] Add bulk orchestrator and retry-path contract coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/BulkOperationExecutionReauthorizationTest.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/QueuedExecutionRetryReauthorizationTest.php
- [X] T022 [P] [US3] Add cross-family contract-matrix, allowed-path, and metadata-storage coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/QueuedExecutionContractMatrixTest.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php
### Implementation for User Story 3
- [X] T023 [US3] Refactor the bulk execution abstractions to consume the shared legitimacy gate in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/Operations/BulkOperationOrchestratorJob.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/Operations/BulkOperationWorkerJob.php
- [X] T024 [US3] Apply the shared contract to additional provider and sync families in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/ProviderInventorySyncJob.php, /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/ProviderComplianceSnapshotJob.php, and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/SyncPoliciesJob.php
- [X] T025 [US3] Apply the system-authority execution path, canonical allowlist policy source, and schema-free metadata persistence contract to scheduled runs in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/RunBackupScheduleJob.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Operations/QueuedExecutionLegitimacyGate.php
**Checkpoint**: User Story 3 is complete when representative queued job families and retries all use the same legitimacy contract and blocked outcome semantics.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Finalize contract artifacts, formatting, focused validation, and manual verification across all stories.
- [X] T026 [P] Align the internal execution contract artifacts in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/149-queued-execution-reauthorization/contracts/execution-legitimacy.schema.json and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/149-queued-execution-reauthorization/contracts/no-external-api-changes.md with the final implementation decisions
- [X] T027 Run the focused Pest suites from /Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/149-queued-execution-reauthorization/quickstart.md covering /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php, /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/QueuedExecutionMiddlewareOrderingTest.php, /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Verification/ProviderExecutionReauthorizationTest.php, /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/RunInventorySyncExecutionReauthorizationTest.php, /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/ExecuteRestoreRunExecutionReauthorizationTest.php, /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/SystemRunBlockedExecutionNotificationTest.php, /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/BulkOperationExecutionReauthorizationTest.php, /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/QueuedExecutionRetryReauthorizationTest.php, /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/QueuedExecutionContractMatrixTest.php, /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php, /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/QueuedExecutionAuditTrailTest.php, and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/TenantlessOperationRunViewerTest.php
- [X] T028 Run formatting for touched files with `vendor/bin/sail bin pint --dirty --format agent`
- [X] T029 [P] Validate the manual smoke checklist in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/149-queued-execution-reauthorization/quickstart.md against /admin/operations and representative queued start surfaces in the admin panel
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1: Setup** has no dependencies and can start immediately.
- **Phase 2: Foundational** depends on Phase 1 and blocks all user story work.
- **Phase 3: User Story 1** depends on Phase 2 and delivers the MVP.
- **Phase 4: User Story 2** depends on Phase 2 and is best delivered after User Story 1 because it reuses the same blocked-execution contract.
- **Phase 5: User Story 3** depends on Phase 2 and benefits from the core legitimacy gate and blocked outcome semantics from User Stories 1 and 2.
- **Phase 6: Polish** depends on all desired user stories being complete.
### User Story Dependencies
- **User Story 1 (P1)** can start immediately after the foundational phase and is the MVP slice.
- **User Story 2 (P1)** can start after the foundational phase but should follow User Story 1 so blocked execution already exists as a real runtime path.
- **User Story 3 (P2)** depends on the foundational phase and builds on the contract established in User Stories 1 and 2.
### Within Each User Story
- Write or extend tests first and confirm they fail before implementation.
- Shared support-layer changes land before job-family adoption.
- Job-family adoption should precede Monitoring copy and manual validation updates.
- Story-level regression coverage should pass before moving to the next priority story.
### Parallel Opportunities
- `T001`, `T002`, `T003`, and `T004` can run in parallel because they prepare separate regression targets.
- `T008` and `T009` can run in parallel after `T005`, `T006`, and `T007` define the shared contract and middleware ordering.
- `T010` and `T011` can run in parallel within User Story 1.
- `T016` and `T017` can run in parallel within User Story 2.
- `T021` and `T022` can run in parallel within User Story 3.
- `T026` and `T029` can run in parallel after implementation is complete.
---
## Parallel Example: User Story 1
```bash
# Run the P1 regression additions together:
Task: "Add actor-bound capability-loss and tenant-scope-loss coverage in tests/Feature/Verification/ProviderExecutionReauthorizationTest.php and tests/Feature/Operations/RunInventorySyncExecutionReauthorizationTest.php"
Task: "Add tenant-non-operable, write-gate denial-reason, and still-legitimate restore allowed-path coverage in tests/Feature/Operations/ExecuteRestoreRunExecutionReauthorizationTest.php and tests/Feature/Operations/QueuedExecutionMiddlewareOrderingTest.php"
```
## Parallel Example: User Story 2
```bash
# Split Monitoring-detail and initiator-null coverage:
Task: "Add blocked outcome presentation, reason-code, and normalized summary-count coverage in tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php and tests/Feature/Operations/TenantlessOperationRunViewerTest.php"
Task: "Add initiator-null notification, audit regression, and direct-access 404-versus-403 coverage for both /admin/operations and /admin/operations/{run} in tests/Feature/Operations/SystemRunBlockedExecutionNotificationTest.php, tests/Feature/Operations/QueuedExecutionAuditTrailTest.php, and tests/Feature/Operations/TenantlessOperationRunViewerTest.php"
```
## Parallel Example: User Story 3
```bash
# Split bulk/retry and cross-family contract validation:
Task: "Add bulk orchestrator and retry-path contract coverage in tests/Feature/Operations/BulkOperationExecutionReauthorizationTest.php and tests/Feature/Operations/QueuedExecutionRetryReauthorizationTest.php"
Task: "Add cross-family contract-matrix coverage in tests/Feature/Operations/QueuedExecutionContractMatrixTest.php and tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php"
```
---
## Implementation Strategy
### MVP First
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. **Stop and validate** that representative queued jobs now fail closed before side effects when legitimacy drifts.
### Incremental Delivery
1. Deliver User Story 1 to establish the runtime safety boundary.
2. Deliver User Story 2 to make blocked execution intelligible in Monitoring, audit, and terminal feedback.
3. Deliver User Story 3 to propagate the same contract across representative job families and retries.
4. Finish with Phase 6 regression, formatting, and manual validation.
### Team Strategy
1. One engineer owns the shared support-layer and middleware work in `app/Support/Operations`, `app/Services/Operations`, and `app/Jobs/Middleware`.
2. A second engineer can prepare the provider, inventory, and restore regression coverage in parallel once the shared contract shape is clear.
3. Bulk and scheduled-run adoption can proceed as a separate stream after the foundational contract lands.
---
## Notes
- `[P]` tasks touch separate files and can be executed in parallel.
- Each user story remains independently testable after the foundational phase.
- This feature does not add schema changes, public HTTP routes, Graph contract-registry entries, new assets, or new Filament panels.
- Keep blocked execution represented as a canonical run outcome, not as a silent skip or a generic failure placeholder.

View File

@ -14,6 +14,7 @@
test('bulk delete sync execution updates policies immediately', function () { test('bulk delete sync execution updates policies immediately', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$user = User::factory()->create(); $user = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
$policies = Policy::factory()->count(10)->create(['tenant_id' => $tenant->id]); $policies = Policy::factory()->count(10)->create(['tenant_id' => $tenant->id]);
$policyIds = $policies->pluck('id')->toArray(); $policyIds = $policies->pluck('id')->toArray();

View File

@ -3,6 +3,7 @@
use App\Jobs\SyncPoliciesJob; use App\Jobs\SyncPoliciesJob;
use App\Models\Policy; use App\Models\Policy;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse; use App\Services\Graph\GraphResponse;
use App\Services\OperationRunService; use App\Services\OperationRunService;
@ -16,6 +17,9 @@
]); ]);
$tenant->makeCurrent(); $tenant->makeCurrent();
$user = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $user, role: 'owner');
ensureDefaultProviderConnection($tenant); ensureDefaultProviderConnection($tenant);
$policies = Policy::factory() $policies = Policy::factory()
@ -85,7 +89,7 @@ public function request(string $method, string $path, array $options = []): Grap
'scope' => 'subset', 'scope' => 'subset',
'policy_ids' => $selectedIds, 'policy_ids' => $selectedIds,
], ],
initiator: null, initiator: $user,
); );
SyncPoliciesJob::dispatchSync( SyncPoliciesJob::dispatchSync(

View File

@ -131,6 +131,37 @@
->toBe(OperationRunLinks::view($run, $tenant)); ->toBe(OperationRunLinks::view($run, $tenant));
}); });
it('uses a tenantless view link for completed tenantless runs', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => null,
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'provider.connection.check',
'status' => 'queued',
'outcome' => 'pending',
]);
app(OperationRunService::class)->updateRun(
$run,
status: 'completed',
outcome: 'blocked',
failures: [[
'code' => 'operation.blocked',
'reason_code' => 'execution_prerequisite_invalid',
'message' => 'Operation blocked because the queued execution prerequisites are no longer satisfied.',
]],
);
$notification = $user->notifications()->latest('id')->first();
expect($notification)->not->toBeNull()
->and($notification->data['actions'][0]['url'] ?? null)->toBe(OperationRunLinks::tenantlessView($run));
});
it('renders partial backup-set update notifications with RBAC foundation summary counts', function () { it('renders partial backup-set update notifications with RBAC foundation summary counts', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);

View File

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
use App\Jobs\BulkPolicyDeleteJob;
use App\Models\Policy;
use App\Services\Auth\CapabilityResolver;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use Illuminate\Support\Facades\Queue;
function runQueuedBulkJobThroughMiddleware(object $job, Closure $terminal): mixed
{
$pipeline = array_reduce(
array_reverse($job->middleware()),
fn (Closure $next, object $middleware): Closure => fn (object $job): mixed => $middleware->handle($job, $next),
$terminal,
);
return $pipeline($job);
}
it('stores actor-bound execution metadata when bulk policy delete is queued', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$policies = Policy::factory()->count(2)->create([
'tenant_id' => (int) $tenant->getKey(),
]);
$selection = app(BulkSelectionIdentity::class)->fromIds($policies->pluck('id')->all());
$run = app(OperationRunService::class)->enqueueBulkOperation(
tenant: $tenant,
type: 'policy.delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selection,
dispatcher: static fn (): null => null,
initiator: $user,
extraContext: [
'policy_count' => $policies->count(),
],
);
expect($run->context)->toMatchArray([
'execution_authority_mode' => 'actor_bound',
'required_capability' => 'tenant.manage',
'policy_count' => 2,
])
->and($run->context['selection']['kind'] ?? null)->toBe('ids')
->and($run->context['selection']['ids_count'] ?? null)->toBe(2)
->and($run->context['selection']['ids_hash'] ?? null)->toBeString();
});
it('blocks bulk policy delete before worker fan-out when the initiator loses capability', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$policies = Policy::factory()->count(2)->create([
'tenant_id' => (int) $tenant->getKey(),
]);
$selection = app(BulkSelectionIdentity::class)->fromIds($policies->pluck('id')->all());
$run = app(OperationRunService::class)->enqueueBulkOperation(
tenant: $tenant,
type: 'policy.delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selection,
dispatcher: static fn (): null => null,
initiator: $user,
extraContext: [
'policy_count' => $policies->count(),
],
);
$job = new BulkPolicyDeleteJob(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
policyIds: $policies->pluck('id')->all(),
operationRun: $run,
);
$user->tenantMemberships()->where('tenant_id', $tenant->getKey())->update(['role' => 'readonly']);
app(CapabilityResolver::class)->clearCache();
$terminalInvoked = false;
runQueuedBulkJobThroughMiddleware(
$job,
function (BulkPolicyDeleteJob $job) use (&$terminalInvoked): void {
$terminalInvoked = true;
$job->handle(app(OperationRunService::class));
},
);
$run->refresh();
expect($terminalInvoked)->toBeFalse()
->and($run->status)->toBe('completed')
->and($run->outcome)->toBe('blocked')
->and($run->context['reason_code'] ?? null)->toBe('missing_capability')
->and($run->context['execution_legitimacy']['target_scope']['tenant_id'] ?? null)->toBe((int) $tenant->getKey());
Queue::assertNothingPushed();
});

View File

@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
use App\Jobs\ExecuteRestoreRunJob;
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunOutcome;
use App\Support\RestoreRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function runQueuedRestoreJobThroughMiddleware(object $job, Closure $terminal): mixed
{
$pipeline = array_reduce(
array_reverse($job->middleware()),
fn (Closure $next, object $middleware): Closure => fn (object $job): mixed => $middleware->handle($job, $next),
$terminal,
);
return $pipeline($job);
}
it('blocks restore execution when the tenant becomes non-operable before start', function (): void {
$tenant = \App\Models\Tenant::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$user = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $user, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$backupSet = BackupSet::factory()->for($tenant)->create([
'name' => 'Backup',
'status' => 'completed',
'item_count' => 0,
]);
$restoreRun = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'requested_by' => 'actor@example.com',
'is_dry_run' => false,
'status' => RestoreRunStatus::Queued->value,
'requested_items' => null,
'preview' => [],
'results' => null,
'metadata' => [],
]);
$tenant->delete();
$tenant->refresh();
$operationRun = app(OperationRunService::class)->ensureRun(
tenant: $tenant,
type: 'restore.execute',
inputs: [
'restore_run_id' => $restoreRun->id,
'backup_set_id' => $backupSet->id,
'is_dry_run' => false,
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
],
initiator: $user,
);
$tenant->forceFill([
'status' => 'active',
'deleted_at' => null,
])->save();
$job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor', $operationRun);
$terminalInvoked = false;
runQueuedRestoreJobThroughMiddleware(
$job,
function (ExecuteRestoreRunJob $job) use (&$terminalInvoked): mixed {
$terminalInvoked = true;
return $job;
},
);
$operationRun->refresh();
$restoreRun->refresh();
expect($terminalInvoked)->toBeFalse()
->and($operationRun->outcome)->toBe(OperationRunOutcome::Blocked->value)
->and($operationRun->context['reason_code'] ?? null)->toBe('tenant_not_operable')
->and($restoreRun->status)->toBe(RestoreRunStatus::Queued->value);
});
it('allows restore execution when legitimacy still holds', function (): void {
$tenant = \App\Models\Tenant::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$user = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $user, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$backupSet = BackupSet::factory()->for($tenant)->create([
'name' => 'Backup',
'status' => 'completed',
'item_count' => 0,
]);
$restoreRun = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'requested_by' => 'actor@example.com',
'is_dry_run' => false,
'status' => RestoreRunStatus::Queued->value,
'requested_items' => null,
'preview' => [],
'results' => null,
'metadata' => [],
]);
$tenant->delete();
$tenant->refresh();
$operationRun = app(OperationRunService::class)->ensureRun(
tenant: $tenant,
type: 'restore.execute',
inputs: [
'restore_run_id' => $restoreRun->id,
'backup_set_id' => $backupSet->id,
'is_dry_run' => false,
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
],
initiator: $user,
);
$job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor', $operationRun);
$terminalInvoked = false;
runQueuedRestoreJobThroughMiddleware(
$job,
function (ExecuteRestoreRunJob $job) use (&$terminalInvoked): mixed {
$terminalInvoked = true;
return $job;
},
);
$operationRun->refresh();
expect($terminalInvoked)->toBeTrue()
->and($operationRun->outcome)->toBe(OperationRunOutcome::Succeeded->value);
});

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Notifications\OperationRunCompleted;
use App\Services\OperationRunService;
use Filament\Facades\Filament;
it('renders blocked terminal notifications distinctly from failed runs', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'inventory_sync',
'status' => 'queued',
'outcome' => 'pending',
'context' => [
'reason_code' => 'missing_capability',
'blocked_by' => 'queued_execution_legitimacy',
],
]);
app(OperationRunService::class)->updateRun(
$run,
status: 'completed',
outcome: 'blocked',
summaryCounts: [
'total' => 2,
'processed' => 0,
'failed' => 0,
],
failures: [[
'code' => 'operation.blocked',
'reason_code' => 'missing_capability',
'message' => 'Operation blocked because the initiating actor no longer has the required capability.',
]],
);
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->getKey(),
'notifiable_type' => $user->getMorphClass(),
'type' => OperationRunCompleted::class,
'data->title' => 'Inventory sync blocked',
]);
$notification = $user->notifications()->latest('id')->first();
expect($notification)->not->toBeNull()
->and($notification->data['body'] ?? null)->toContain('Execution was blocked.')
->and($notification->data['body'] ?? null)->toContain('required capability')
->and($notification->data['body'] ?? null)->toContain('Total: 2');
});

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use App\Support\Operations\ExecutionAuthorityMode;
use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\QueuedExecutionContext;
use App\Support\Operations\QueuedExecutionLegitimacyDecision;
it('writes a blocked terminal audit trail with execution denial context', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'provider.connection.check',
'status' => 'queued',
'outcome' => 'pending',
'summary_counts' => [
'total' => '4',
'bogus_key' => 'ignored',
],
]);
$context = new QueuedExecutionContext(
run: $run,
operationType: 'provider.connection.check',
workspaceId: (int) $tenant->workspace_id,
tenant: $tenant,
initiator: $user,
authorityMode: ExecutionAuthorityMode::ActorBound,
requiredCapability: 'providers.view',
providerConnectionId: 123,
targetScope: [
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'provider_connection_id' => 123,
],
);
$decision = QueuedExecutionLegitimacyDecision::deny(
context: $context,
checks: [
'workspace_scope' => 'passed',
'tenant_scope' => 'failed',
'capability' => 'not_applicable',
'tenant_operability' => 'not_applicable',
'execution_prerequisites' => 'not_applicable',
],
reasonCode: ExecutionDenialReasonCode::InitiatorNotEntitled,
);
app(OperationRunService::class)->finalizeExecutionLegitimacyBlockedRun($run, $decision);
$run->refresh();
$audit = AuditLog::query()
->where('operation_run_id', (int) $run->getKey())
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->action)->toBe('operation.blocked')
->and($audit?->status)->toBe('blocked')
->and($audit?->summary)->toContain('blocked')
->and(data_get($audit?->metadata, 'operation_type'))->toBe('provider.connection.check')
->and(data_get($audit?->metadata, 'failure_summary.0.reason_code'))->toBe('initiator_not_entitled')
->and(data_get($audit?->metadata, 'target_scope.provider_connection_id'))->toBe(123)
->and(data_get($audit?->metadata, 'denial_class'))->toBe('initiator_invalid')
->and(data_get($audit?->metadata, 'authority_mode'))->toBe('actor_bound')
->and(data_get($audit?->metadata, 'acting_identity_type'))->toBe('user')
->and($run->summary_counts)->toBe(['total' => 4]);
});

View File

@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderInventorySyncJob;
use App\Jobs\RunBackupScheduleJob;
use App\Jobs\SyncPoliciesJob;
use App\Models\BackupSchedule;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Services\OperationRunService;
use App\Services\Providers\ProviderOperationStartGate;
use App\Support\Auth\Capabilities;
function runQueuedContractMatrixJobThroughMiddleware(object $job, Closure $terminal): mixed
{
$pipeline = array_reduce(
array_reverse($job->middleware()),
fn (Closure $next, object $middleware): Closure => fn (object $job): mixed => $middleware->handle($job, $next),
$terminal,
);
return $pipeline($job);
}
it('blocks provider execution families consistently when provider capability is lost', function (string $operationType, string $jobClass): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
'status' => 'connected',
]);
$run = app(OperationRunService::class)->ensureRun(
tenant: $tenant,
type: $operationType,
inputs: [
'provider_connection_id' => (int) $connection->getKey(),
'required_capability' => Capabilities::PROVIDER_RUN,
],
initiator: $user,
);
$job = new $jobClass(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $connection->getKey(),
operationRun: $run,
);
$user->tenantMemberships()->where('tenant_id', $tenant->getKey())->update(['role' => 'readonly']);
app(CapabilityResolver::class)->clearCache();
$terminalInvoked = false;
runQueuedContractMatrixJobThroughMiddleware(
$job,
function () use (&$terminalInvoked): string {
$terminalInvoked = true;
return 'ran';
},
);
$run->refresh();
expect($terminalInvoked)->toBeFalse()
->and($run->outcome)->toBe('blocked')
->and($run->context['reason_code'] ?? null)->toBe('missing_capability')
->and($run->context['execution_legitimacy']['metadata']['required_capability'] ?? null)->toBe(Capabilities::PROVIDER_RUN);
})->with([
'provider inventory sync' => ['inventory_sync', ProviderInventorySyncJob::class],
'provider compliance snapshot' => ['compliance.snapshot', ProviderComplianceSnapshotJob::class],
]);
it('blocks policy sync before side effects when sync capability is lost', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = app(OperationRunService::class)->ensureRun(
tenant: $tenant,
type: 'policy.sync',
inputs: [
'scope' => 'all',
'types' => ['deviceConfiguration'],
],
initiator: $user,
);
$job = new SyncPoliciesJob(
tenantId: (int) $tenant->getKey(),
types: ['deviceConfiguration'],
policyIds: null,
operationRun: $run,
);
$user->tenantMemberships()->where('tenant_id', $tenant->getKey())->update(['role' => 'readonly']);
app(CapabilityResolver::class)->clearCache();
$terminalInvoked = false;
runQueuedContractMatrixJobThroughMiddleware(
$job,
function () use (&$terminalInvoked): string {
$terminalInvoked = true;
return 'ran';
},
);
$run->refresh();
expect($terminalInvoked)->toBeFalse()
->and($run->outcome)->toBe('blocked')
->and($run->context['reason_code'] ?? null)->toBe('missing_capability')
->and($run->context['execution_legitimacy']['metadata']['required_capability'] ?? null)->toBe(Capabilities::TENANT_SYNC);
});
it('blocks scheduled backup runs under system authority when tenant operability drifts', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$schedule = BackupSchedule::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'name' => 'Queued legitimacy contract',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '01:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
'next_run_at' => null,
]);
$run = app(OperationRunService::class)->ensureRunWithIdentityStrict(
tenant: $tenant,
type: 'backup_schedule_run',
identityInputs: [
'backup_schedule_id' => (int) $schedule->getKey(),
'scheduled_for' => now()->toDateTimeString(),
],
context: [
'backup_schedule_id' => (int) $schedule->getKey(),
'trigger' => 'scheduled',
],
);
$otherWorkspace = Workspace::factory()->create();
$tenant->forceFill([
'workspace_id' => (int) $otherWorkspace->getKey(),
])->save();
$job = new RunBackupScheduleJob(operationRun: $run, backupScheduleId: (int) $schedule->getKey());
$terminalInvoked = false;
runQueuedContractMatrixJobThroughMiddleware(
$job,
function () use (&$terminalInvoked): string {
$terminalInvoked = true;
return 'ran';
},
);
$run->refresh();
expect($terminalInvoked)->toBeFalse()
->and($run->outcome)->toBe('blocked')
->and($run->context['reason_code'] ?? null)->toBe('workspace_mismatch')
->and($run->context['execution_legitimacy']['authority_mode'] ?? null)->toBe('system_authority');
});
it('stores canonical execution metadata in existing operation run context structures', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$policyRun = app(OperationRunService::class)->ensureRun(
tenant: $tenant,
type: 'policy.sync',
inputs: [
'scope' => 'all',
'types' => ['deviceConfiguration'],
],
initiator: $user,
);
$scheduleRun = app(OperationRunService::class)->ensureRunWithIdentityStrict(
tenant: $tenant,
type: 'backup_schedule_run',
identityInputs: [
'backup_schedule_id' => 99,
'scheduled_for' => now()->toDateTimeString(),
],
context: [
'backup_schedule_id' => 99,
'trigger' => 'scheduled',
],
);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
'status' => 'connected',
]);
$result = app(ProviderOperationStartGate::class)->start(
tenant: $tenant,
connection: $connection,
operationType: 'inventory_sync',
dispatcher: static fn (OperationRun $run): null => null,
initiator: $user,
);
expect($policyRun->context)->toMatchArray([
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_SYNC,
])
->and($scheduleRun->context)->toMatchArray([
'execution_authority_mode' => 'system_authority',
'required_capability' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
])
->and($result->run->context)->toMatchArray([
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::PROVIDER_RUN,
'provider_connection_id' => (int) $connection->getKey(),
]);
});

View File

@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Services\Operations\QueuedExecutionLegitimacyGate;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\ExecutionAuthorityMode;
use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\QueuedExecutionContext;
use App\Support\Operations\QueuedExecutionLegitimacyDecision;
it('blocks before track middleware can mark the run running', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'inventory_sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
$context = new QueuedExecutionContext(
run: $run,
operationType: 'inventory_sync',
workspaceId: (int) $tenant->workspace_id,
tenant: $tenant,
initiator: $user,
authorityMode: ExecutionAuthorityMode::ActorBound,
requiredCapability: null,
providerConnectionId: null,
targetScope: [
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'provider_connection_id' => null,
],
);
$decision = QueuedExecutionLegitimacyDecision::deny(
context: $context,
checks: [
'workspace_scope' => 'passed',
'tenant_scope' => 'passed',
'capability' => 'failed',
'tenant_operability' => 'not_applicable',
'execution_prerequisites' => 'not_applicable',
],
reasonCode: ExecutionDenialReasonCode::MissingCapability,
);
app()->instance(QueuedExecutionLegitimacyGate::class, new class($decision)
{
public function __construct(private readonly QueuedExecutionLegitimacyDecision $decision) {}
public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision
{
return $this->decision;
}
});
$ensure = new EnsureQueuedExecutionLegitimate;
$track = new TrackOperationRun;
$executed = false;
$job = new class($run)
{
public function __construct(public OperationRun $operationRun) {}
};
$response = $ensure->handle($job, function ($job) use (&$executed, $track) {
return $track->handle($job, function () use (&$executed): string {
$executed = true;
return 'ran';
});
});
$run->refresh();
expect($response)->toBeNull()
->and($executed)->toBeFalse()
->and($run->status)->toBe(OperationRunStatus::Completed->value)
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
->and($run->started_at)->toBeNull()
->and($run->context['blocked_by'] ?? null)->toBe('queued_execution_legitimacy')
->and($run->context['execution_legitimacy']['reason_code'] ?? null)->toBe('missing_capability');
});
it('marks legitimate execution running before completing it', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'inventory_sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
$context = new QueuedExecutionContext(
run: $run,
operationType: 'inventory_sync',
workspaceId: (int) $tenant->workspace_id,
tenant: $tenant,
initiator: $user,
authorityMode: ExecutionAuthorityMode::ActorBound,
requiredCapability: null,
providerConnectionId: null,
targetScope: [
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'provider_connection_id' => null,
],
);
$decision = QueuedExecutionLegitimacyDecision::allow(
context: $context,
checks: [
'workspace_scope' => 'passed',
'tenant_scope' => 'passed',
'capability' => 'passed',
'tenant_operability' => 'passed',
'execution_prerequisites' => 'not_applicable',
],
);
app()->instance(QueuedExecutionLegitimacyGate::class, new class($decision)
{
public function __construct(private readonly QueuedExecutionLegitimacyDecision $decision) {}
public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision
{
return $this->decision;
}
});
$ensure = new EnsureQueuedExecutionLegitimate;
$track = new TrackOperationRun;
$executed = false;
$job = new class($run)
{
public function __construct(public OperationRun $operationRun) {}
};
$response = $ensure->handle($job, function ($job) use (&$executed, $track) {
return $track->handle($job, function () use (&$executed): string {
$executed = true;
return 'ran';
});
});
$run->refresh();
expect($response)->toBe('ran')
->and($executed)->toBeTrue()
->and($run->status)->toBe(OperationRunStatus::Completed->value)
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
->and($run->started_at)->not->toBeNull()
->and($run->completed_at)->not->toBeNull();
});
it('persists write-gate denials as blocked before track middleware runs', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'restore.execute',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
$context = new QueuedExecutionContext(
run: $run,
operationType: 'restore.execute',
workspaceId: (int) $tenant->workspace_id,
tenant: $tenant,
initiator: $user,
authorityMode: ExecutionAuthorityMode::ActorBound,
requiredCapability: null,
providerConnectionId: null,
targetScope: [
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'provider_connection_id' => null,
],
);
$decision = QueuedExecutionLegitimacyDecision::deny(
context: $context,
checks: [
'workspace_scope' => 'passed',
'tenant_scope' => 'passed',
'capability' => 'passed',
'tenant_operability' => 'passed',
'execution_prerequisites' => 'failed',
],
reasonCode: ExecutionDenialReasonCode::WriteGateBlocked,
);
app()->instance(QueuedExecutionLegitimacyGate::class, new class($decision)
{
public function __construct(private readonly QueuedExecutionLegitimacyDecision $decision) {}
public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision
{
return $this->decision;
}
});
$ensure = new EnsureQueuedExecutionLegitimate;
$track = new TrackOperationRun;
$executed = false;
$job = new class($run)
{
public function __construct(public OperationRun $operationRun) {}
};
$response = $ensure->handle($job, function ($job) use (&$executed, $track) {
return $track->handle($job, function () use (&$executed): string {
$executed = true;
return 'ran';
});
});
$run->refresh();
expect($response)->toBeNull()
->and($executed)->toBeFalse()
->and($run->status)->toBe(OperationRunStatus::Completed->value)
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
->and($run->started_at)->toBeNull()
->and($run->context['reason_code'] ?? null)->toBe('write_gate_blocked')
->and($run->context['execution_legitimacy']['reason_code'] ?? null)->toBe('write_gate_blocked');
});

View File

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
use App\Jobs\Operations\BulkOperationWorkerJob;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use App\Services\Operations\QueuedExecutionLegitimacyGate;
use App\Support\Operations\ExecutionAuthorityMode;
use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\QueuedExecutionContext;
use App\Support\Operations\QueuedExecutionLegitimacyDecision;
function runQueuedRetryJobThroughMiddleware(object $job, Closure $terminal): mixed
{
$pipeline = array_reduce(
array_reverse($job->middleware()),
fn (Closure $next, object $middleware): Closure => fn (object $job): mixed => $middleware->handle($job, $next),
$terminal,
);
return $pipeline($job);
}
it('re-evaluates legitimacy on each bulk worker retry attempt', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = app(OperationRunService::class)->ensureRun(
tenant: $tenant,
type: 'policy.delete',
inputs: [
'target_scope' => [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
],
initiator: $user,
);
$job = new class((int) $tenant->getKey(), (int) $user->getKey(), 'policy-123', $run) extends BulkOperationWorkerJob
{
protected function process(OperationRunService $runs): void {}
};
$context = new QueuedExecutionContext(
run: $run,
operationType: 'policy.delete',
workspaceId: (int) $tenant->workspace_id,
tenant: $tenant,
initiator: $user,
authorityMode: ExecutionAuthorityMode::ActorBound,
requiredCapability: 'tenant.manage',
providerConnectionId: null,
targetScope: [
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'provider_connection_id' => null,
],
);
$allowDecision = QueuedExecutionLegitimacyDecision::allow(
context: $context,
checks: [
'workspace_scope' => 'passed',
'tenant_scope' => 'passed',
'capability' => 'passed',
'tenant_operability' => 'passed',
'execution_prerequisites' => 'not_applicable',
],
);
$denyDecision = QueuedExecutionLegitimacyDecision::deny(
context: $context,
checks: [
'workspace_scope' => 'passed',
'tenant_scope' => 'passed',
'capability' => 'failed',
'tenant_operability' => 'passed',
'execution_prerequisites' => 'not_applicable',
],
reasonCode: ExecutionDenialReasonCode::MissingCapability,
metadata: [
'required_capability' => 'tenant.manage',
],
);
app()->instance(QueuedExecutionLegitimacyGate::class, new class($allowDecision, $denyDecision)
{
private int $callCount = 0;
public function __construct(
private readonly QueuedExecutionLegitimacyDecision $allowDecision,
private readonly QueuedExecutionLegitimacyDecision $denyDecision,
) {}
public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision
{
return $this->callCount++ === 0 ? $this->allowDecision : $this->denyDecision;
}
});
$executionCount = 0;
runQueuedRetryJobThroughMiddleware(
$job,
function () use (&$executionCount): string {
$executionCount++;
return 'attempt-1';
},
);
$blockedResponse = runQueuedRetryJobThroughMiddleware(
$job,
function () use (&$executionCount): string {
$executionCount++;
return 'attempt-2';
},
);
$run->refresh();
expect($executionCount)->toBe(1)
->and($blockedResponse)->toBeNull()
->and($run->status)->toBe('completed')
->and($run->outcome)->toBe('blocked')
->and($run->context['reason_code'] ?? null)->toBe('missing_capability')
->and($run->context['execution_legitimacy']['checks']['capability'] ?? null)->toBe('failed');
});

View File

@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
use App\Jobs\RunInventorySyncJob;
use App\Models\OperationRun;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Services\Inventory\InventorySyncService;
use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
function runQueuedInventoryJobThroughMiddleware(object $job, Closure $terminal): mixed
{
$pipeline = array_reduce(
array_reverse($job->middleware()),
fn (Closure $next, object $middleware): Closure => fn (object $job): mixed => $middleware->handle($job, $next),
$terminal,
);
return $pipeline($job);
}
it('stores actor-bound execution metadata when inventory sync is queued', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListInventoryItems::class)
->callAction('run_inventory_sync', data: [
'policy_types' => ['deviceConfiguration'],
'include_foundations' => false,
'include_dependencies' => false,
]);
$opRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'inventory_sync')
->latest('id')
->first();
expect($opRun)->not->toBeNull()
->and($opRun?->context)->toMatchArray([
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
]);
});
it('blocks inventory execution when the initiator loses inventory capability before start', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListInventoryItems::class)
->callAction('run_inventory_sync', data: [
'policy_types' => ['deviceConfiguration'],
'include_foundations' => false,
'include_dependencies' => false,
]);
$capturedJob = null;
Queue::assertPushed(RunInventorySyncJob::class, function (RunInventorySyncJob $job) use (&$capturedJob): bool {
$capturedJob = $job;
return true;
});
expect($capturedJob)->toBeInstanceOf(RunInventorySyncJob::class);
$user->tenantMemberships()->where('tenant_id', $tenant->getKey())->update(['role' => 'readonly']);
app(CapabilityResolver::class)->clearCache();
$terminalInvoked = false;
runQueuedInventoryJobThroughMiddleware(
$capturedJob,
function (RunInventorySyncJob $job) use (&$terminalInvoked): mixed {
$terminalInvoked = true;
return $job;
},
);
$capturedJob->operationRun?->refresh();
expect($terminalInvoked)->toBeFalse()
->and($capturedJob->operationRun?->outcome?->value ?? $capturedJob->operationRun?->outcome)->toBe('blocked')
->and($capturedJob->operationRun?->context['reason_code'] ?? null)->toBe('missing_capability')
->and($capturedJob->operationRun?->context['execution_legitimacy']['metadata']['required_capability'] ?? null)->toBe(Capabilities::TENANT_INVENTORY_SYNC_RUN);
});
it('allows inventory execution when legitimacy still holds', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListInventoryItems::class)
->callAction('run_inventory_sync', data: [
'policy_types' => ['deviceConfiguration'],
'include_foundations' => false,
'include_dependencies' => false,
]);
$capturedJob = null;
Queue::assertPushed(RunInventorySyncJob::class, function (RunInventorySyncJob $job) use (&$capturedJob): bool {
$capturedJob = $job;
return true;
});
$this->mock(InventorySyncService::class, function ($mock): void {
$mock->shouldReceive('executeSelection')
->once()
->andReturn([
'status' => 'success',
'had_errors' => false,
'error_codes' => [],
'error_context' => [],
'errors_count' => 0,
'items_observed_count' => 0,
'items_upserted_count' => 0,
'processed_policy_types' => ['deviceConfiguration'],
'failed_policy_types' => [],
'skipped_policy_types' => [],
]);
});
runQueuedInventoryJobThroughMiddleware(
$capturedJob,
fn (RunInventorySyncJob $job): mixed => $job->handle(
app(InventorySyncService::class),
app(AuditLogger::class),
app(OperationRunService::class),
),
);
$capturedJob->operationRun?->refresh();
expect($capturedJob->operationRun?->outcome?->value ?? $capturedJob->operationRun?->outcome)->toBe('succeeded');
});

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Services\OperationRunService;
it('does not emit terminal database notifications for blocked system runs', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => null,
'initiator_name' => 'System',
'type' => 'backup_schedule_run',
'status' => 'queued',
'outcome' => 'pending',
'context' => [
'reason_code' => 'tenant_not_operable',
'blocked_by' => 'queued_execution_legitimacy',
],
]);
app(OperationRunService::class)->updateRun(
$run,
status: 'completed',
outcome: 'blocked',
failures: [[
'code' => 'operation.blocked',
'reason_code' => 'tenant_not_operable',
'message' => 'Operation blocked because the target tenant is not currently operable for this action.',
]],
);
expect($run->fresh()?->outcome)->toBe('blocked')
->and(
\Illuminate\Notifications\DatabaseNotification::query()->count()
)->toBe(0);
});

View File

@ -104,6 +104,47 @@
->assertForbidden(); ->assertForbidden();
}); });
it('shows blocked execution guidance in the canonical tenantless viewer', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'inventory_sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Blocked->value,
'context' => [
'reason_code' => 'missing_capability',
'blocked_by' => 'queued_execution_legitimacy',
'execution_legitimacy' => [
'reason_code' => 'missing_capability',
],
],
'failure_summary' => [[
'code' => 'operation.blocked',
'reason_code' => 'missing_capability',
'message' => 'Operation blocked because the initiating actor no longer has the required capability.',
]],
]);
Filament::setTenant($tenant, true);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertSuccessful()
->assertSee('Execution blocked')
->assertSee('Blocked reason')
->assertSee('Blocked detail')
->assertSee('Execution legitimacy revalidation')
->assertSee('missing_capability')
->assertSee('required capability');
});
it('keeps a canonical run viewer accessible when the remembered tenant differs from the run tenant', function (): void { it('keeps a canonical run viewer accessible when the remembered tenant differs from the run tenant', function (): void {
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
$tenantA = Tenant::factory()->for($workspace)->create(); $tenantA = Tenant::factory()->for($workspace)->create();

View File

@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
function runQueuedJobThroughMiddleware(object $job, Closure $terminal): mixed
{
$pipeline = array_reduce(
array_reverse($job->middleware()),
fn (Closure $next, object $middleware): Closure => fn (object $job): mixed => $middleware->handle($job, $next),
$terminal,
);
return $pipeline($job);
}
it('stores actor-bound execution metadata when verification is queued', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
'status' => 'connected',
]);
Livewire::test(ListProviderConnections::class)
->callTableAction('check_connection', $connection);
$opRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'provider.connection.check')
->latest('id')
->first();
expect($opRun)->not->toBeNull()
->and($opRun?->context)->toMatchArray([
'execution_authority_mode' => 'actor_bound',
'required_capability' => 'provider.run',
'provider_connection_id' => (int) $connection->getKey(),
]);
});
it('blocks verification execution when the initiator loses provider capability before start', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
'status' => 'connected',
]);
Livewire::test(ListProviderConnections::class)
->callTableAction('check_connection', $connection);
$capturedJob = null;
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, function (ProviderConnectionHealthCheckJob $job) use (&$capturedJob): bool {
$capturedJob = $job;
return true;
});
expect($capturedJob)->toBeInstanceOf(ProviderConnectionHealthCheckJob::class);
$user->tenantMemberships()->where('tenant_id', $tenant->getKey())->update(['role' => 'readonly']);
app(CapabilityResolver::class)->clearCache();
$terminalInvoked = false;
runQueuedJobThroughMiddleware(
$capturedJob,
function (ProviderConnectionHealthCheckJob $job) use (&$terminalInvoked): mixed {
$terminalInvoked = true;
return $job;
},
);
$capturedJob->operationRun?->refresh();
expect($terminalInvoked)->toBeFalse()
->and($capturedJob->operationRun?->outcome?->value ?? $capturedJob->operationRun?->outcome)->toBe('blocked')
->and($capturedJob->operationRun?->context['reason_code'] ?? null)->toBe('missing_capability')
->and($capturedJob->operationRun?->context['execution_legitimacy']['metadata']['required_capability'] ?? null)->toBe(Capabilities::PROVIDER_RUN);
});
it('blocks verification execution when the initiator loses tenant membership before start', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
'status' => 'connected',
]);
Livewire::test(ListProviderConnections::class)
->callTableAction('check_connection', $connection);
$capturedJob = null;
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, function (ProviderConnectionHealthCheckJob $job) use (&$capturedJob): bool {
$capturedJob = $job;
return true;
});
expect($capturedJob)->toBeInstanceOf(ProviderConnectionHealthCheckJob::class);
$user->tenantMemberships()->where('tenant_id', $tenant->getKey())->delete();
app(CapabilityResolver::class)->clearCache();
$terminalInvoked = false;
runQueuedJobThroughMiddleware(
$capturedJob,
function (ProviderConnectionHealthCheckJob $job) use (&$terminalInvoked): mixed {
$terminalInvoked = true;
return $job;
},
);
$capturedJob->operationRun?->refresh();
expect($terminalInvoked)->toBeFalse()
->and($capturedJob->operationRun?->outcome?->value ?? $capturedJob->operationRun?->outcome)->toBe('blocked')
->and($capturedJob->operationRun?->context['reason_code'] ?? null)->toBe('initiator_not_entitled');
});

View File

@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Services\Operations\QueuedExecutionLegitimacyGate;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\ExecutionAuthorityMode;
use App\Support\Operations\ExecutionDenialClass;
use App\Support\Operations\ExecutionDenialReasonCode;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('allows actor-bound inventory execution when the initiator still has capability', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'inventory_sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value,
],
]);
$decision = app(QueuedExecutionLegitimacyGate::class)->evaluate($run);
expect($decision->allowed)->toBeTrue()
->and($decision->authorityMode)->toBe(ExecutionAuthorityMode::ActorBound)
->and($decision->reasonCode)->toBeNull()
->and($decision->checks)->toMatchArray([
'workspace_scope' => 'passed',
'tenant_scope' => 'passed',
'capability' => 'passed',
'tenant_operability' => 'passed',
'execution_prerequisites' => 'not_applicable',
])
->and($decision->toArray())->toMatchArray([
'operation_type' => 'inventory_sync',
'authority_mode' => 'actor_bound',
'allowed' => true,
'retryable' => false,
'target_scope' => [
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'provider_connection_id' => null,
],
]);
});
it('denies actor-bound execution when the initiator loses capability', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'inventory_sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value,
],
]);
$decision = app(QueuedExecutionLegitimacyGate::class)->evaluate($run);
expect($decision->allowed)->toBeFalse()
->and($decision->denialClass)->toBe(ExecutionDenialClass::CapabilityDenied)
->and($decision->reasonCode)->toBe(ExecutionDenialReasonCode::MissingCapability)
->and($decision->retryable)->toBeFalse()
->and($decision->checks)->toMatchArray([
'workspace_scope' => 'passed',
'tenant_scope' => 'passed',
'capability' => 'failed',
]);
});
it('allows system-authority execution only for allowlisted operation types', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => null,
'type' => 'backup_schedule_run',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'execution_authority_mode' => ExecutionAuthorityMode::SystemAuthority->value,
],
]);
$decision = app(QueuedExecutionLegitimacyGate::class)->evaluate($run);
expect($decision->allowed)->toBeTrue()
->and($decision->authorityMode)->toBe(ExecutionAuthorityMode::SystemAuthority)
->and($decision->initiator)->toMatchArray([
'identity_type' => 'system',
'user_id' => null,
]);
});
it('denies non-allowlisted system-authority execution with retryable prerequisite semantics', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => null,
'type' => 'inventory_sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'execution_authority_mode' => ExecutionAuthorityMode::SystemAuthority->value,
],
]);
$decision = app(QueuedExecutionLegitimacyGate::class)->evaluate($run);
expect($decision->allowed)->toBeFalse()
->and($decision->denialClass)->toBe(ExecutionDenialClass::PrerequisiteInvalid)
->and($decision->reasonCode)->toBe(ExecutionDenialReasonCode::ExecutionPrerequisiteInvalid)
->and($decision->retryable)->toBeTrue()
->and($decision->checks['execution_prerequisites'])->toBe('failed');
});
it('maps write-gate blocks to retryable prerequisite denials', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$tenant->forceFill([
'status' => 'archived',
'deleted_at' => now(),
'rbac_status' => 'not_configured',
'rbac_last_checked_at' => null,
])->save();
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'restore.execute',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value,
],
]);
$decision = app(QueuedExecutionLegitimacyGate::class)->evaluate($run);
expect($decision->allowed)->toBeFalse()
->and($decision->reasonCode)->toBe(ExecutionDenialReasonCode::WriteGateBlocked)
->and($decision->denialClass)->toBe(ExecutionDenialClass::PrerequisiteInvalid)
->and($decision->retryable)->toBeTrue()
->and($decision->checks['execution_prerequisites'])->toBe('failed');
});
it('infers tenant sync capability for policy sync runs from the central resolver', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'policy.sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value,
],
]);
$context = app(QueuedExecutionLegitimacyGate::class)->buildContext($run);
expect($context->requiredCapability)->toBe('tenant.sync')
->and($context->authorityMode)->toBe(ExecutionAuthorityMode::ActorBound);
});
it('infers system-authority schedule capability for backup schedule runs from the central resolver', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => null,
'type' => 'backup_schedule_run',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [],
]);
$context = app(QueuedExecutionLegitimacyGate::class)->buildContext($run);
expect($context->requiredCapability)->toBe('tenant_backup_schedules.run')
->and($context->authorityMode)->toBe(ExecutionAuthorityMode::SystemAuthority)
->and($context->initiatorSnapshot())->toMatchArray([
'identity_type' => 'system',
'user_id' => null,
]);
});