From e4a3a4d378b12c2fa80a923a3902ab975772d1c1 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 11 Jan 2026 16:55:03 +0100 Subject: [PATCH] feat: job-only restore runs with idempotency --- .../Resources/BulkOperationRunResource.php | 138 ++++++++++ .../Pages/ListBulkOperationRuns.php | 11 + .../Pages/ViewBulkOperationRun.php | 11 + .../PolicyResource/Pages/ViewPolicy.php | 116 ++++++--- app/Filament/Resources/RestoreRunResource.php | 242 +++++++++++++----- app/Jobs/CapturePolicySnapshotJob.php | 107 ++++++++ app/Jobs/ExecuteRestoreRunJob.php | 62 ++++- app/Models/BulkOperationRun.php | 1 + app/Models/RestoreRun.php | 10 + .../RunStatusChangedNotification.php | 94 +++++++ app/Policies/BulkOperationRunPolicy.php | 39 +++ app/Providers/AppServiceProvider.php | 1 + app/Rules/SkipOrUuidRule.php | 37 +++ app/Services/BulkOperationService.php | 32 +++ app/Services/Intune/RestoreService.php | 74 +++++- app/Support/RunIdempotency.php | 95 +++++++ ...tency_key_to_bulk_operation_runs_table.php | 32 +++ ..._idempotency_key_to_restore_runs_table.php | 32 +++ .../entries/restore-results.blade.php | 21 +- 19 files changed, 1039 insertions(+), 116 deletions(-) create mode 100644 app/Filament/Resources/BulkOperationRunResource.php create mode 100644 app/Filament/Resources/BulkOperationRunResource/Pages/ListBulkOperationRuns.php create mode 100644 app/Filament/Resources/BulkOperationRunResource/Pages/ViewBulkOperationRun.php create mode 100644 app/Jobs/CapturePolicySnapshotJob.php create mode 100644 app/Notifications/RunStatusChangedNotification.php create mode 100644 app/Policies/BulkOperationRunPolicy.php create mode 100644 app/Rules/SkipOrUuidRule.php create mode 100644 app/Support/RunIdempotency.php create mode 100644 database/migrations/2026_01_11_120001_add_idempotency_key_to_bulk_operation_runs_table.php create mode 100644 database/migrations/2026_01_11_120002_add_idempotency_key_to_restore_runs_table.php diff --git a/app/Filament/Resources/BulkOperationRunResource.php b/app/Filament/Resources/BulkOperationRunResource.php new file mode 100644 index 0000000..cfdde9b --- /dev/null +++ b/app/Filament/Resources/BulkOperationRunResource.php @@ -0,0 +1,138 @@ +schema([ + Section::make('Run') + ->schema([ + TextEntry::make('user.name') + ->label('Initiator') + ->placeholder('—'), + TextEntry::make('resource')->badge(), + TextEntry::make('action')->badge(), + TextEntry::make('status') + ->badge() + ->color(fn (BulkOperationRun $record): string => static::statusColor($record->status)), + TextEntry::make('total_items')->label('Total')->numeric(), + TextEntry::make('processed_items')->label('Processed')->numeric(), + TextEntry::make('succeeded')->numeric(), + TextEntry::make('failed')->numeric(), + TextEntry::make('skipped')->numeric(), + TextEntry::make('created_at')->dateTime(), + TextEntry::make('updated_at')->dateTime(), + TextEntry::make('idempotency_key')->label('Idempotency key')->copyable()->placeholder('—'), + ]) + ->columns(2) + ->columnSpanFull(), + + Section::make('Items') + ->schema([ + ViewEntry::make('item_ids') + ->label('') + ->view('filament.infolists.entries.snapshot-json') + ->state(fn (BulkOperationRun $record) => $record->item_ids ?? []) + ->columnSpanFull(), + ]) + ->columnSpanFull(), + + Section::make('Failures') + ->schema([ + ViewEntry::make('failures') + ->label('') + ->view('filament.infolists.entries.snapshot-json') + ->state(fn (BulkOperationRun $record) => $record->failures ?? []) + ->columnSpanFull(), + ]) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->defaultSort('id', 'desc') + ->modifyQueryUsing(function (Builder $query): Builder { + $tenantId = Tenant::current()->getKey(); + + return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId)); + }) + ->columns([ + Tables\Columns\TextColumn::make('user.name') + ->label('Initiator') + ->placeholder('—') + ->toggleable(), + Tables\Columns\TextColumn::make('resource')->badge(), + Tables\Columns\TextColumn::make('action')->badge(), + Tables\Columns\TextColumn::make('status') + ->badge() + ->color(fn (BulkOperationRun $record): string => static::statusColor($record->status)), + Tables\Columns\TextColumn::make('created_at')->since(), + Tables\Columns\TextColumn::make('total_items')->label('Total')->numeric(), + Tables\Columns\TextColumn::make('processed_items')->label('Processed')->numeric(), + Tables\Columns\TextColumn::make('failed')->numeric(), + ]) + ->actions([ + Actions\ViewAction::make(), + ]) + ->bulkActions([]); + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->with('user') + ->latest('id'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListBulkOperationRuns::route('/'), + 'view' => Pages\ViewBulkOperationRun::route('/{record}'), + ]; + } + + private static function statusColor(?string $status): string + { + return match ($status) { + 'completed' => 'success', + 'completed_with_errors' => 'warning', + 'failed' => 'danger', + 'running' => 'info', + default => 'gray', + }; + } +} diff --git a/app/Filament/Resources/BulkOperationRunResource/Pages/ListBulkOperationRuns.php b/app/Filament/Resources/BulkOperationRunResource/Pages/ListBulkOperationRuns.php new file mode 100644 index 0000000..ef4ea96 --- /dev/null +++ b/app/Filament/Resources/BulkOperationRunResource/Pages/ListBulkOperationRuns.php @@ -0,0 +1,11 @@ +label('Capture snapshot') ->requiresConfirmation() ->modalHeading('Capture snapshot now') - ->modalSubheading('This will fetch the latest configuration from Microsoft Graph and store a new policy version.') + ->modalSubheading('This queues a background job that fetches the latest configuration from Microsoft Graph and stores a new policy version.') ->form([ Forms\Components\Checkbox::make('include_assignments') ->label('Include assignments') @@ -37,51 +41,79 @@ protected function getActions(): array ->action(function (array $data) { $policy = $this->record; - try { - $tenant = $policy->tenant; + $tenant = $policy->tenant; + $user = auth()->user(); - if (! $tenant) { - Notification::make() - ->title('Policy has no tenant associated.') - ->danger() - ->send(); - - return; - } - - $version = app(VersionService::class)->captureFromGraph( - tenant: $tenant, - policy: $policy, - createdBy: auth()->user()?->email ?? null, - includeAssignments: $data['include_assignments'] ?? false, - includeScopeTags: $data['include_scope_tags'] ?? false, - ); - - if (($version->metadata['source'] ?? null) === 'metadata_only') { - $status = $version->metadata['original_status'] ?? null; - - Notification::make() - ->title('Snapshot captured (metadata only)') - ->body(sprintf( - 'Microsoft Graph returned %s for this policy type, so only local metadata was saved. Full restore is not possible until Graph works again.', - $status ?? 'an error' - )) - ->warning() - ->send(); - } else { - Notification::make() - ->title('Snapshot captured successfully.') - ->success() - ->send(); - } - - $this->redirect($this->getResource()::getUrl('view', ['record' => $policy->getKey()])); - } catch (\Throwable $e) { + if (! $tenant || ! $user) { Notification::make() - ->title('Failed to capture snapshot: '.$e->getMessage()) + ->title('Missing tenant or user context.') ->danger() ->send(); + + return; } + + $idempotencyKey = RunIdempotency::buildKey( + tenantId: $tenant->getKey(), + operationType: 'policy.capture_snapshot', + targetId: $policy->getKey() + ); + + $existingRun = RunIdempotency::findActiveBulkOperationRun( + tenantId: $tenant->getKey(), + idempotencyKey: $idempotencyKey + ); + + if ($existingRun) { + Notification::make() + ->title('Snapshot already in progress') + ->body('An active run already exists for this policy. Opening run details.') + ->actions([ + \Filament\Actions\Action::make('view_run') + ->label('View run') + ->url(BulkOperationRunResource::getUrl('view', ['record' => $existingRun], tenant: $tenant)), + ]) + ->info() + ->send(); + + $this->redirect(BulkOperationRunResource::getUrl('view', ['record' => $existingRun], tenant: $tenant)); + + return; + } + + $bulkOperationService = app(BulkOperationService::class); + + $run = $bulkOperationService->createRun( + tenant: $tenant, + user: $user, + resource: 'policies', + action: 'capture_snapshot', + itemIds: [(string) $policy->getKey()], + totalItems: 1 + ); + + $run->update(['idempotency_key' => $idempotencyKey]); + + CapturePolicySnapshotJob::dispatch( + bulkOperationRunId: $run->getKey(), + policyId: $policy->getKey(), + includeAssignments: (bool) ($data['include_assignments'] ?? false), + includeScopeTags: (bool) ($data['include_scope_tags'] ?? false), + createdBy: $user->email ? Str::limit($user->email, 255, '') : null + ); + + Notification::make() + ->title('Snapshot queued') + ->body('A background job has been queued. You can monitor progress in the run details.') + ->actions([ + \Filament\Actions\Action::make('view_run') + ->label('View run') + ->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)), + ]) + ->success() + ->send(); + + $this->redirect(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)); }) ->color('primary'), ]; diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 516e6c1..9dbc2d6 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -11,12 +11,14 @@ use App\Models\BackupSet; use App\Models\RestoreRun; use App\Models\Tenant; +use App\Rules\SkipOrUuidRule; use App\Services\BulkOperationService; use App\Services\Intune\AuditLogger; use App\Services\Intune\RestoreDiffGenerator; use App\Services\Intune\RestoreRiskChecker; use App\Services\Intune\RestoreService; use App\Support\RestoreRunStatus; +use App\Support\RunIdempotency; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; @@ -36,6 +38,7 @@ use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\QueryException; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; @@ -115,31 +118,7 @@ public static function form(Schema $schema): Schema return Forms\Components\TextInput::make("group_mapping.{$groupId}") ->label($label) ->placeholder('SKIP or target group Object ID (GUID)') - ->rules([ - static function (string $attribute, mixed $value, \Closure $fail): void { - if (! is_string($value)) { - $fail('Please enter SKIP or a valid UUID.'); - - return; - } - - $value = trim($value); - - if ($value === '') { - $fail('Please enter SKIP or a valid UUID.'); - - return; - } - - if (strtoupper($value) === 'SKIP') { - return; - } - - if (! Str::isUuid($value)) { - $fail('Please enter SKIP or a valid UUID.'); - } - }, - ]) + ->rules([new SkipOrUuidRule]) ->required() ->helperText('Paste the target Entra ID group Object ID (GUID). Names are not resolved in this phase. Use SKIP to omit the assignment.'); }, $unresolved); @@ -345,31 +324,7 @@ public static function getWizardSteps(): array return Forms\Components\TextInput::make("group_mapping.{$groupId}") ->label($label) ->placeholder('SKIP or target group Object ID (GUID)') - ->rules([ - static function (string $attribute, mixed $value, \Closure $fail): void { - if (! is_string($value)) { - $fail('Please enter SKIP or a valid UUID.'); - - return; - } - - $value = trim($value); - - if ($value === '') { - $fail('Please enter SKIP or a valid UUID.'); - - return; - } - - if (strtoupper($value) === 'SKIP') { - return; - } - - if (! Str::isUuid($value)) { - $fail('Please enter SKIP or a valid UUID.'); - } - }, - ]) + ->rules([new SkipOrUuidRule]) ->reactive() ->afterStateUpdated(function (Set $set): void { $set('check_summary', null); @@ -678,6 +633,15 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('backupSet.name')->label('Backup set'), Tables\Columns\IconColumn::make('is_dry_run')->label('Dry-run')->boolean(), Tables\Columns\TextColumn::make('status')->badge(), + Tables\Columns\TextColumn::make('summary_total') + ->label('Total') + ->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)), + Tables\Columns\TextColumn::make('summary_succeeded') + ->label('Succeeded') + ->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['succeeded'] ?? 0)), + Tables\Columns\TextColumn::make('summary_failed') + ->label('Failed') + ->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['failed'] ?? 0)), Tables\Columns\TextColumn::make('started_at')->dateTime()->since(), Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(), Tables\Columns\TextColumn::make('requested_by')->label('Requested by'), @@ -722,6 +686,116 @@ public static function table(Table $table): Table return; } + if (! (bool) $record->is_dry_run) { + $selectedItemIds = is_array($record->requested_items) ? $record->requested_items : null; + $groupMapping = is_array($record->group_mapping) ? $record->group_mapping : []; + $actorEmail = auth()->user()?->email; + $actorName = auth()->user()?->name; + $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; + $highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()); + + $preview = $restoreService->preview($tenant, $backupSet, $selectedItemIds); + $metadata = [ + 'scope_mode' => $selectedItemIds === null ? 'all' : 'selected', + 'environment' => app()->environment('production') ? 'prod' : 'test', + 'highlander_label' => $highlanderLabel, + 'confirmed_at' => now()->toIso8601String(), + 'confirmed_by' => $actorEmail, + 'confirmed_by_name' => $actorName, + 'rerun_of_restore_run_id' => $record->id, + ]; + + $idempotencyKey = RunIdempotency::restoreExecuteKey( + tenantId: (int) $tenant->getKey(), + backupSetId: (int) $backupSet->getKey(), + selectedItemIds: $selectedItemIds, + groupMapping: $groupMapping, + ); + + $existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); + + if ($existing) { + Notification::make() + ->title('Restore already queued') + ->body('Reusing the active restore run.') + ->info() + ->send(); + + return; + } + + try { + $newRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'requested_by' => $actorEmail, + 'is_dry_run' => false, + 'status' => RestoreRunStatus::Queued->value, + 'idempotency_key' => $idempotencyKey, + 'requested_items' => $selectedItemIds, + 'preview' => $preview, + 'metadata' => $metadata, + 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, + ]); + } catch (QueryException $exception) { + $existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); + + if ($existing) { + Notification::make() + ->title('Restore already queued') + ->body('Reusing the active restore run.') + ->info() + ->send(); + + return; + } + + throw $exception; + } + + $auditLogger->log( + tenant: $tenant, + action: 'restore.queued', + context: [ + 'metadata' => [ + 'restore_run_id' => $newRun->id, + 'backup_set_id' => $backupSet->id, + 'rerun_of_restore_run_id' => $record->id, + ], + ], + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'restore_run', + resourceId: (string) $newRun->id, + status: 'success', + ); + + ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName); + + $auditLogger->log( + tenant: $tenant, + action: 'restore_run.rerun', + resourceType: 'restore_run', + resourceId: (string) $newRun->id, + status: 'success', + context: [ + 'metadata' => [ + 'original_restore_run_id' => $record->id, + 'backup_set_id' => $backupSet->id, + ], + ], + actorEmail: $actorEmail, + actorName: $actorName, + ); + + Notification::make() + ->title('Restore run queued') + ->success() + ->send(); + + return; + } + try { $newRun = $restoreService->execute( tenant: $tenant, @@ -1008,6 +1082,16 @@ public static function infolist(Schema $schema): Schema ->schema([ Infolists\Components\TextEntry::make('backupSet.name')->label('Backup set'), Infolists\Components\TextEntry::make('status')->badge(), + Infolists\Components\TextEntry::make('counts') + ->label('Counts') + ->state(function (RestoreRun $record): string { + $meta = $record->metadata ?? []; + $total = (int) ($meta['total'] ?? 0); + $succeeded = (int) ($meta['succeeded'] ?? 0); + $failed = (int) ($meta['failed'] ?? 0); + + return sprintf('Total: %d • Succeeded: %d • Failed: %d', $total, $succeeded, $failed); + }), Infolists\Components\TextEntry::make('is_dry_run') ->label('Dry-run') ->formatStateUsing(fn ($state) => $state ? 'Yes' : 'No') @@ -1327,17 +1411,53 @@ public static function createRestoreRun(array $data): RestoreRun $metadata['preview_ran_at'] = $previewRanAt; } - $restoreRun = RestoreRun::create([ - 'tenant_id' => $tenant->id, - 'backup_set_id' => $backupSet->id, - 'requested_by' => $actorEmail, - 'is_dry_run' => false, - 'status' => RestoreRunStatus::Queued->value, - 'requested_items' => $selectedItemIds, - 'preview' => $preview, - 'metadata' => $metadata, - 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, - ]); + $idempotencyKey = RunIdempotency::restoreExecuteKey( + tenantId: (int) $tenant->getKey(), + backupSetId: (int) $backupSet->getKey(), + selectedItemIds: $selectedItemIds, + groupMapping: $groupMapping, + ); + + $existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); + + if ($existing) { + Notification::make() + ->title('Restore already queued') + ->body('Reusing the active restore run.') + ->info() + ->send(); + + return $existing; + } + + try { + $restoreRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'requested_by' => $actorEmail, + 'is_dry_run' => false, + 'status' => RestoreRunStatus::Queued->value, + 'idempotency_key' => $idempotencyKey, + 'requested_items' => $selectedItemIds, + 'preview' => $preview, + 'metadata' => $metadata, + 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, + ]); + } catch (QueryException $exception) { + $existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); + + if ($existing) { + Notification::make() + ->title('Restore already queued') + ->body('Reusing the active restore run.') + ->info() + ->send(); + + return $existing; + } + + throw $exception; + } app(AuditLogger::class)->log( tenant: $tenant, diff --git a/app/Jobs/CapturePolicySnapshotJob.php b/app/Jobs/CapturePolicySnapshotJob.php new file mode 100644 index 0000000..f5f2917 --- /dev/null +++ b/app/Jobs/CapturePolicySnapshotJob.php @@ -0,0 +1,107 @@ +with(['tenant', 'user'])->find($this->bulkOperationRunId); + + if (! $run) { + return; + } + + $policy = Policy::query()->with('tenant')->find($this->policyId); + + if (! $policy || ! $policy->tenant) { + $bulkOperationService->abort($run, 'policy_not_found'); + $this->notifyStatus($run, 'failed'); + + return; + } + + $this->notifyStatus($run, 'queued'); + $bulkOperationService->start($run); + $this->notifyStatus($run, 'running'); + + try { + $versionService->captureFromGraph( + tenant: $policy->tenant, + policy: $policy, + createdBy: $this->createdBy, + includeAssignments: $this->includeAssignments, + includeScopeTags: $this->includeScopeTags, + ); + + $bulkOperationService->recordSuccess($run); + $bulkOperationService->complete($run); + + $this->notifyStatus($run, $run->refresh()->status); + } catch (Throwable $e) { + $bulkOperationService->recordFailure( + run: $run, + itemId: (string) $policy->getKey(), + reason: $bulkOperationService->sanitizeFailureReason($e->getMessage()) + ); + + $bulkOperationService->complete($run); + + $this->notifyStatus($run->refresh(), $run->status); + + throw $e; + } + } + + private function notifyStatus(BulkOperationRun $run, string $status): void + { + if (! $run->relationLoaded('user')) { + $run->loadMissing('user'); + } + + if (! $run->user) { + return; + } + + $normalizedStatus = $status === 'pending' ? 'queued' : $status; + + $run->user->notify(new RunStatusChangedNotification([ + 'tenant_id' => (int) $run->tenant_id, + 'run_type' => 'bulk_operation', + 'run_id' => (int) $run->getKey(), + 'status' => (string) $normalizedStatus, + 'counts' => [ + 'total' => (int) $run->total_items, + 'processed' => (int) $run->processed_items, + 'succeeded' => (int) $run->succeeded, + 'failed' => (int) $run->failed, + 'skipped' => (int) $run->skipped, + ], + ])); + } +} diff --git a/app/Jobs/ExecuteRestoreRunJob.php b/app/Jobs/ExecuteRestoreRunJob.php index dd57d20..2216120 100644 --- a/app/Jobs/ExecuteRestoreRunJob.php +++ b/app/Jobs/ExecuteRestoreRunJob.php @@ -3,6 +3,9 @@ namespace App\Jobs; use App\Models\RestoreRun; +use App\Models\User; +use App\Notifications\RunStatusChangedNotification; +use App\Services\BulkOperationService; use App\Services\Intune\AuditLogger; use App\Services\Intune\RestoreService; use App\Support\RestoreRunStatus; @@ -24,7 +27,7 @@ public function __construct( public ?string $actorName = null, ) {} - public function handle(RestoreService $restoreService, AuditLogger $auditLogger): void + public function handle(RestoreService $restoreService, AuditLogger $auditLogger, BulkOperationService $bulkOperationService): void { $restoreRun = RestoreRun::with(['tenant', 'backupSet'])->find($this->restoreRunId); @@ -36,6 +39,8 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger) return; } + $this->notifyStatus($restoreRun, 'queued'); + $tenant = $restoreRun->tenant; $backupSet = $restoreRun->backupSet; @@ -46,6 +51,8 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger) 'completed_at' => CarbonImmutable::now(), ]); + $this->notifyStatus($restoreRun->refresh(), 'failed'); + if ($tenant) { $auditLogger->log( tenant: $tenant, @@ -74,6 +81,8 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger) 'failure_reason' => null, ]); + $this->notifyStatus($restoreRun->refresh(), 'running'); + $auditLogger->log( tenant: $tenant, action: 'restore.started', @@ -98,17 +107,23 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger) actorEmail: $this->actorEmail, actorName: $this->actorName, ); + + $this->notifyStatus($restoreRun->refresh(), (string) $restoreRun->status); } catch (Throwable $throwable) { $restoreRun->refresh(); + $safeReason = $bulkOperationService->sanitizeFailureReason($throwable->getMessage()); + if ($restoreRun->status === RestoreRunStatus::Running->value) { $restoreRun->update([ 'status' => RestoreRunStatus::Failed->value, - 'failure_reason' => $throwable->getMessage(), + 'failure_reason' => $safeReason, 'completed_at' => CarbonImmutable::now(), ]); } + $this->notifyStatus($restoreRun->refresh(), (string) $restoreRun->status); + if ($tenant) { $auditLogger->log( tenant: $tenant, @@ -117,7 +132,7 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger) 'metadata' => [ 'restore_run_id' => $restoreRun->id, 'backup_set_id' => $backupSet->id, - 'reason' => $throwable->getMessage(), + 'reason' => $safeReason, ], ], actorEmail: $this->actorEmail, @@ -131,4 +146,45 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger) throw $throwable; } } + + private function notifyStatus(RestoreRun $restoreRun, string $status): void + { + $email = $this->actorEmail; + + if (! is_string($email) || $email === '') { + $email = is_string($restoreRun->requested_by) ? $restoreRun->requested_by : null; + } + + if (! is_string($email) || $email === '') { + return; + } + + $user = User::query()->where('email', $email)->first(); + + if (! $user) { + return; + } + + $metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : []; + $counts = []; + + foreach (['total', 'succeeded', 'failed', 'skipped'] as $key) { + if (array_key_exists($key, $metadata) && is_numeric($metadata[$key])) { + $counts[$key] = (int) $metadata[$key]; + } + } + + $payload = [ + 'tenant_id' => (int) $restoreRun->tenant_id, + 'run_type' => 'restore', + 'run_id' => (int) $restoreRun->getKey(), + 'status' => $status, + ]; + + if ($counts !== []) { + $payload['counts'] = $counts; + } + + $user->notify(new RunStatusChangedNotification($payload)); + } } diff --git a/app/Models/BulkOperationRun.php b/app/Models/BulkOperationRun.php index 9342a26..5880dba 100644 --- a/app/Models/BulkOperationRun.php +++ b/app/Models/BulkOperationRun.php @@ -15,6 +15,7 @@ class BulkOperationRun extends Model 'user_id', 'resource', 'action', + 'idempotency_key', 'status', 'total_items', 'processed_items', diff --git a/app/Models/RestoreRun.php b/app/Models/RestoreRun.php index e4d3ce0..19966ea 100644 --- a/app/Models/RestoreRun.php +++ b/app/Models/RestoreRun.php @@ -18,6 +18,7 @@ class RestoreRun extends Model protected $casts = [ 'is_dry_run' => 'boolean', + 'idempotency_key' => 'string', 'requested_items' => 'array', 'preview' => 'array', 'results' => 'array', @@ -104,6 +105,15 @@ public function getAssignmentRestoreOutcomes(): array return $results['assignment_outcomes']; } + if (isset($results['items']) && is_array($results['items'])) { + return collect($results['items']) + ->pluck('assignment_outcomes') + ->flatten(1) + ->filter() + ->values() + ->all(); + } + if (! is_array($results)) { return []; } diff --git a/app/Notifications/RunStatusChangedNotification.php b/app/Notifications/RunStatusChangedNotification.php new file mode 100644 index 0000000..2faab26 --- /dev/null +++ b/app/Notifications/RunStatusChangedNotification.php @@ -0,0 +1,94 @@ + + */ + public function via(object $notifiable): array + { + return ['database']; + } + + /** + * @return array + */ + public function toDatabase(object $notifiable): array + { + $status = (string) ($this->metadata['status'] ?? 'queued'); + $runType = (string) ($this->metadata['run_type'] ?? 'run'); + $tenantId = (int) ($this->metadata['tenant_id'] ?? 0); + $runId = (int) ($this->metadata['run_id'] ?? 0); + + $title = match ($status) { + 'queued' => 'Run queued', + 'running' => 'Run started', + 'completed', 'succeeded' => 'Run completed', + 'partial', 'completed_with_errors' => 'Run completed (partial)', + 'failed' => 'Run failed', + default => 'Run updated', + }; + + $body = sprintf('A %s run changed status to: %s.', str_replace('_', ' ', $runType), $status); + + $color = match ($status) { + 'queued', 'running' => 'gray', + 'completed', 'succeeded' => 'success', + 'partial', 'completed_with_errors' => 'warning', + 'failed' => 'danger', + default => 'gray', + }; + + $actions = []; + + if (in_array($runType, ['bulk_operation', 'restore'], true) && $tenantId > 0 && $runId > 0) { + $tenant = Tenant::query()->find($tenantId); + + if ($tenant) { + $url = $runType === 'bulk_operation' + ? BulkOperationRunResource::getUrl('view', ['record' => $runId], tenant: $tenant) + : RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant); + + $actions[] = Action::make('view_run') + ->label('View run') + ->url($url) + ->toArray(); + } + } + + return [ + 'format' => 'filament', + 'title' => $title, + 'body' => $body, + 'color' => $color, + 'duration' => 'persistent', + 'actions' => $actions, + 'icon' => null, + 'iconColor' => null, + 'status' => null, + 'view' => null, + 'viewData' => [ + 'metadata' => $this->metadata, + ], + ]; + } +} diff --git a/app/Policies/BulkOperationRunPolicy.php b/app/Policies/BulkOperationRunPolicy.php new file mode 100644 index 0000000..6ee6a87 --- /dev/null +++ b/app/Policies/BulkOperationRunPolicy.php @@ -0,0 +1,39 @@ +canAccessTenant($tenant); + } + + public function view(User $user, BulkOperationRun $run): bool + { + $tenant = Tenant::current(); + + if (! $tenant) { + return false; + } + + if (! $user->canAccessTenant($tenant)) { + return false; + } + + return (int) $run->tenant_id === (int) $tenant->getKey(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 248c52c..a605e4a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -107,5 +107,6 @@ public function boot(): void }); Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class); + Gate::policy(BulkOperationRun::class, BulkOperationRunPolicy::class); } } diff --git a/app/Rules/SkipOrUuidRule.php b/app/Rules/SkipOrUuidRule.php new file mode 100644 index 0000000..57aa15c --- /dev/null +++ b/app/Rules/SkipOrUuidRule.php @@ -0,0 +1,37 @@ +allowSkip && strtoupper($value) === 'SKIP') { + return; + } + + if (! Str::isUuid($value)) { + $fail('Please enter SKIP or a valid UUID.'); + } + } +} diff --git a/app/Services/BulkOperationService.php b/app/Services/BulkOperationService.php index 8f3f158..b85193a 100644 --- a/app/Services/BulkOperationService.php +++ b/app/Services/BulkOperationService.php @@ -13,6 +13,30 @@ public function __construct( protected AuditLogger $auditLogger ) {} + public function sanitizeFailureReason(string $reason): string + { + $reason = trim($reason); + + if ($reason === '') { + return 'error'; + } + + $lower = mb_strtolower($reason); + + if ( + str_contains($lower, 'bearer ') || + str_contains($lower, 'access_token') || + str_contains($lower, 'client_secret') || + str_contains($lower, 'authorization') + ) { + return 'redacted'; + } + + $reason = preg_replace("/\s+/u", ' ', $reason) ?? $reason; + + return mb_substr($reason, 0, 200); + } + public function createRun( Tenant $tenant, User $user, @@ -70,6 +94,8 @@ public function recordSuccess(BulkOperationRun $run): void public function recordFailure(BulkOperationRun $run, string $itemId, string $reason): void { + $reason = $this->sanitizeFailureReason($reason); + $failures = $run->failures ?? []; $failures[] = [ 'item_id' => $itemId, @@ -92,6 +118,8 @@ public function recordSkipped(BulkOperationRun $run): void public function recordSkippedWithReason(BulkOperationRun $run, string $itemId, string $reason): void { + $reason = $this->sanitizeFailureReason($reason); + $failures = $run->failures ?? []; $failures[] = [ 'item_id' => $itemId, @@ -164,6 +192,8 @@ public function fail(BulkOperationRun $run, string $reason): void { $run->update(['status' => 'failed']); + $reason = $this->sanitizeFailureReason($reason); + $this->auditLogger->log( tenant: $run->tenant, action: "bulk.{$run->resource}.{$run->action}.failed", @@ -184,6 +214,8 @@ public function abort(BulkOperationRun $run, string $reason): void { $run->update(['status' => 'aborted']); + $reason = $this->sanitizeFailureReason($reason); + $this->auditLogger->log( tenant: $run->tenant, action: "bulk.{$run->resource}.{$run->action}.aborted", diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index b62ed49..6c00464 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -327,6 +327,26 @@ public function execute( $foundationEntries = $foundationOutcome['entries'] ?? []; $foundationFailures = (int) ($foundationOutcome['failed'] ?? 0); $foundationSkipped = (int) ($foundationOutcome['skipped'] ?? 0); + + $foundationBackupItemIdsBySourceId = $foundationItems + ->pluck('id', 'policy_identifier') + ->map(fn ($id) => is_int($id) ? $id : null) + ->filter() + ->all(); + + $foundationEntries = array_values(array_map(function (mixed $entry) use ($foundationBackupItemIdsBySourceId): mixed { + if (! is_array($entry)) { + return $entry; + } + + $sourceId = $entry['sourceId'] ?? null; + + if (is_string($sourceId) && isset($foundationBackupItemIdsBySourceId[$sourceId])) { + $entry['backup_item_id'] = (int) $foundationBackupItemIdsBySourceId[$sourceId]; + } + + return $entry; + }, $foundationEntries)); $foundationMappingByType = $this->buildFoundationMappingByType($foundationEntries); $scopeTagMapping = $foundationMappingByType['roleScopeTag'] ?? []; $scopeTagNamesById = $this->buildScopeTagNameLookup($foundationEntries); @@ -866,14 +886,62 @@ public function execute( default => 'completed', }); + $isFoundationEntry = static function (mixed $item): bool { + return is_array($item) + && array_key_exists('decision', $item) + && array_key_exists('sourceId', $item); + }; + + $foundationResults = collect($results)->filter($isFoundationEntry)->values(); + $policyResults = collect($results)->reject($isFoundationEntry)->values(); + + $itemsByBackupItemId = $policyResults + ->filter(fn (mixed $item): bool => is_array($item) && isset($item['backup_item_id']) && is_numeric($item['backup_item_id'])) + ->keyBy(fn (array $item): string => (string) $item['backup_item_id']) + ->all(); + + $policyStatusCounts = collect($itemsByBackupItemId) + ->map(fn (array $item): string => (string) ($item['status'] ?? 'unknown')) + ->countBy(); + + $foundationDecisionCounts = $foundationResults + ->map(fn (array $entry): string => (string) ($entry['decision'] ?? 'unknown')) + ->countBy(); + + $policyTotal = count($itemsByBackupItemId); + $foundationTotal = $foundationResults->count(); + $total = $policyTotal + $foundationTotal; + + $succeeded = (int) ($policyStatusCounts['applied'] ?? 0) + + $foundationDecisionCounts + ->except(['failed', 'skipped']) + ->sum(); + + $failed = (int) ($policyStatusCounts['failed'] ?? 0) + + (int) ($foundationDecisionCounts['failed'] ?? 0); + + $skipped = (int) ($policyStatusCounts['skipped'] ?? 0) + + (int) ($foundationDecisionCounts['skipped'] ?? 0); + + $partialCount = (int) ($policyStatusCounts['partial'] ?? 0) + + (int) ($policyStatusCounts['manual_required'] ?? 0); + + $persistedResults = [ + 'foundations' => $foundationResults->all(), + 'items' => $itemsByBackupItemId, + ]; + $restoreRun->update([ 'status' => $status, - 'results' => $results, + 'results' => $persistedResults, 'completed_at' => CarbonImmutable::now(), 'metadata' => array_merge($restoreRun->metadata ?? [], [ - 'failed' => $hardFailures, + 'total' => $total, + 'succeeded' => $succeeded, + 'failed' => $failed, + 'skipped' => $skipped, + 'partial' => $partialCount, 'non_applied' => $nonApplied, - 'total' => $totalCount, 'foundations_skipped' => $foundationSkipped, ]), ]); diff --git a/app/Support/RunIdempotency.php b/app/Support/RunIdempotency.php new file mode 100644 index 0000000..574afcf --- /dev/null +++ b/app/Support/RunIdempotency.php @@ -0,0 +1,95 @@ + $context + */ + public static function buildKey(int $tenantId, string $operationType, string|int|null $targetId = null, array $context = []): string + { + $payload = [ + 'tenant_id' => $tenantId, + 'operation_type' => trim($operationType), + 'target_id' => $targetId === null ? null : (string) $targetId, + 'context' => self::canonicalize($context), + ]; + + return hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)); + } + + public static function findActiveBulkOperationRun(int $tenantId, string $idempotencyKey): ?BulkOperationRun + { + return BulkOperationRun::query() + ->where('tenant_id', $tenantId) + ->where('idempotency_key', $idempotencyKey) + ->whereIn('status', ['pending', 'running']) + ->latest('id') + ->first(); + } + + public static function findActiveRestoreRun(int $tenantId, string $idempotencyKey): ?RestoreRun + { + return RestoreRun::query() + ->where('tenant_id', $tenantId) + ->where('idempotency_key', $idempotencyKey) + ->whereIn('status', ['queued', 'running']) + ->latest('id') + ->first(); + } + + /** + * Deterministic idempotency key for a live restore execution. + * + * @param array|null $selectedItemIds + * @param array $groupMapping + */ + public static function restoreExecuteKey( + int $tenantId, + int $backupSetId, + ?array $selectedItemIds, + array $groupMapping = [], + ): string { + $scopeIds = $selectedItemIds; + + if (is_array($scopeIds)) { + $scopeIds = array_values(array_unique(array_map('intval', $scopeIds))); + sort($scopeIds); + } + + return self::buildKey( + tenantId: $tenantId, + operationType: 'restore.execute', + targetId: (string) $backupSetId, + context: [ + 'scope' => $scopeIds, + 'group_mapping' => $groupMapping, + ], + ); + } + + /** + * @param array $value + * @return array + */ + private static function canonicalize(array $value): array + { + $value = Arr::map($value, function (mixed $item): mixed { + if (is_array($item)) { + /** @var array $item */ + return static::canonicalize($item); + } + + return $item; + }); + + ksort($value); + + return $value; + } +} diff --git a/database/migrations/2026_01_11_120001_add_idempotency_key_to_bulk_operation_runs_table.php b/database/migrations/2026_01_11_120001_add_idempotency_key_to_bulk_operation_runs_table.php new file mode 100644 index 0000000..9f9926b --- /dev/null +++ b/database/migrations/2026_01_11_120001_add_idempotency_key_to_bulk_operation_runs_table.php @@ -0,0 +1,32 @@ +string('idempotency_key', 64)->nullable()->after('action'); + }); + + Schema::table('bulk_operation_runs', function (Blueprint $table) { + $table->index(['tenant_id', 'idempotency_key'], 'bulk_runs_tenant_idempotency'); + }); + + DB::statement("CREATE UNIQUE INDEX bulk_runs_idempotency_active ON bulk_operation_runs (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL AND status IN ('pending', 'running')"); + } + + public function down(): void + { + DB::statement('DROP INDEX IF EXISTS bulk_runs_idempotency_active'); + + Schema::table('bulk_operation_runs', function (Blueprint $table) { + $table->dropIndex('bulk_runs_tenant_idempotency'); + $table->dropColumn('idempotency_key'); + }); + } +}; diff --git a/database/migrations/2026_01_11_120002_add_idempotency_key_to_restore_runs_table.php b/database/migrations/2026_01_11_120002_add_idempotency_key_to_restore_runs_table.php new file mode 100644 index 0000000..4c5cbc1 --- /dev/null +++ b/database/migrations/2026_01_11_120002_add_idempotency_key_to_restore_runs_table.php @@ -0,0 +1,32 @@ +string('idempotency_key', 64)->nullable()->after('status'); + }); + + Schema::table('restore_runs', function (Blueprint $table) { + $table->index(['tenant_id', 'idempotency_key'], 'restore_runs_tenant_idempotency'); + }); + + DB::statement("CREATE UNIQUE INDEX restore_runs_idempotency_active ON restore_runs (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL AND status IN ('queued', 'running')"); + } + + public function down(): void + { + DB::statement('DROP INDEX IF EXISTS restore_runs_idempotency_active'); + + Schema::table('restore_runs', function (Blueprint $table) { + $table->dropIndex('restore_runs_tenant_idempotency'); + $table->dropColumn('idempotency_key'); + }); + } +}; diff --git a/resources/views/filament/infolists/entries/restore-results.blade.php b/resources/views/filament/infolists/entries/restore-results.blade.php index 0c9e694..96a6b81 100644 --- a/resources/views/filament/infolists/entries/restore-results.blade.php +++ b/resources/views/filament/infolists/entries/restore-results.blade.php @@ -1,14 +1,21 @@ @php - $results = $getState() ?? []; - $foundationItems = collect($results)->filter(function ($item) { + $state = $getState() ?? []; + $isFoundationEntry = function ($item) { return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item); - }); - $policyItems = collect($results)->reject(function ($item) { - return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item); - }); + }; + + if (is_array($state) && array_key_exists('items', $state)) { + $foundationItems = collect($state['foundations'] ?? [])->filter($isFoundationEntry); + $policyItems = collect($state['items'] ?? [])->values(); + $results = $state; + } else { + $results = $state; + $foundationItems = collect($results)->filter($isFoundationEntry); + $policyItems = collect($results)->reject($isFoundationEntry); + } @endphp -@if (empty($results)) +@if ($foundationItems->isEmpty() && $policyItems->isEmpty())

No results recorded.

@else @php