|null $selectedItemIds */ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedItemIds = null): array { $this->assertActiveContext($tenant, $backupSet); $items = $this->loadItems($backupSet, $selectedItemIds); return $items->map(function (BackupItem $item) use ($tenant) { $existing = Policy::query() ->where('tenant_id', $tenant->id) ->where('external_id', $item->policy_identifier) ->where('policy_type', $item->policy_type) ->first(); return [ 'backup_item_id' => $item->id, 'policy_identifier' => $item->policy_identifier, 'policy_type' => $item->policy_type, 'platform' => $item->platform, 'action' => $existing ? 'update' : 'create', 'conflict' => false, 'validation_warning' => BackupItem::odataTypeWarning( is_array($item->payload) ? $item->payload : [], $item->policy_type, $item->platform ) ?? ($this->snapshotValidator->validate(is_array($item->payload) ? $item->payload : [])['warnings'][0] ?? null), ]; })->all(); } /** * Execute restore or dry-run for selected items. * * @param array|null $selectedItemIds */ public function execute( Tenant $tenant, BackupSet $backupSet, ?array $selectedItemIds = null, bool $dryRun = true, ?string $actorEmail = null, ?string $actorName = null, ): RestoreRun { $this->assertActiveContext($tenant, $backupSet); $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; $items = $this->loadItems($backupSet, $selectedItemIds); $preview = $this->preview($tenant, $backupSet, $selectedItemIds); $restoreRun = RestoreRun::create([ 'tenant_id' => $tenant->id, 'backup_set_id' => $backupSet->id, 'requested_by' => $actorEmail, 'is_dry_run' => $dryRun, 'status' => 'running', 'requested_items' => $selectedItemIds, 'preview' => $preview, 'started_at' => CarbonImmutable::now(), 'metadata' => [], ]); $results = []; $failures = 0; foreach ($items as $item) { $context = [ 'tenant' => $tenantIdentifier, 'policy_type' => $item->policy_type, 'policy_id' => $item->policy_identifier, 'backup_item_id' => $item->id, ]; $odataValidation = BackupItem::validateODataType( is_array($item->payload) ? $item->payload : [], $item->policy_type, $item->platform ); if (! $odataValidation['matches']) { $results[] = $context + [ 'status' => 'failed', 'reason' => BackupItem::odataTypeWarning( is_array($item->payload) ? $item->payload : [], $item->policy_type, $item->platform ) ?? 'Snapshot type mismatch', 'code' => 'odata_mismatch', ]; $failures++; continue; } if ($dryRun) { $results[] = $context + ['status' => 'dry_run']; continue; } $this->graphLogger->logRequest('apply_policy', $context); try { $payload = $this->sanitizePayload($item->payload); $response = $this->graphClient->applyPolicy( $item->policy_type, $item->policy_identifier, $payload, [ 'tenant' => $tenantIdentifier, 'client_id' => $tenant->app_client_id, 'client_secret' => $tenant->app_client_secret, 'platform' => $item->platform, ] ); } catch (Throwable $throwable) { $mapped = GraphErrorMapper::fromThrowable($throwable, $context); $results[] = $context + [ 'status' => 'failed', 'reason' => $mapped->getMessage(), 'code' => $mapped->status, ]; $failures++; continue; } $this->graphLogger->logResponse('apply_policy', $response, $context); if ($response->failed()) { $results[] = $context + [ 'status' => 'failed', 'reason' => 'Graph apply failed', 'code' => $response->status, ]; $failures++; continue; } $results[] = $context + ['status' => 'applied']; $policy = Policy::query() ->where('tenant_id', $tenant->id) ->where('external_id', $item->policy_identifier) ->where('policy_type', $item->policy_type) ->first(); if ($policy) { $this->versionService->captureVersion( policy: $policy, payload: $item->payload, createdBy: $actorEmail, metadata: [ 'source' => 'restore', 'restore_run_id' => $restoreRun->id, 'backup_item_id' => $item->id, ] ); } } $status = $dryRun ? 'previewed' : (match (true) { $failures === count($results) => 'failed', $failures > 0 => 'partial', default => 'completed', }); $restoreRun->update([ 'status' => $status, 'results' => $results, 'completed_at' => CarbonImmutable::now(), 'metadata' => [ 'failed' => $failures, 'total' => count($results), ], ]); $this->auditLogger->log( tenant: $tenant, action: $dryRun ? 'restore.previewed' : 'restore.executed', context: [ 'metadata' => [ 'restore_run_id' => $restoreRun->id, 'backup_set_id' => $backupSet->id, 'status' => $status, 'dry_run' => $dryRun, ], ], actorEmail: $actorEmail, actorName: $actorName, resourceType: 'restore_run', resourceId: (string) $restoreRun->id, status: $status === 'completed' || $status === 'previewed' ? 'success' : 'partial' ); return $restoreRun->refresh(); } /** * @param array|null $selectedItemIds */ private function loadItems(BackupSet $backupSet, ?array $selectedItemIds = null): Collection { $query = $backupSet->items()->getQuery(); if ($selectedItemIds !== null) { $query->whereIn('id', $selectedItemIds); } return $query->orderBy('id')->get(); } /** * Strip read-only/metadata fields before sending payload back to Graph. */ private function sanitizePayload(array $payload): array { $readOnlyKeys = [ '@odata.context', 'id', 'createdDateTime', 'lastModifiedDateTime', 'version', 'supportsScopeTags', 'roleScopeTagIds', ]; $clean = []; foreach ($payload as $key => $value) { // Drop read-only/meta keys except @odata.type which we keep for type hinting if (in_array($key, $readOnlyKeys, true)) { continue; } // Keep @odata.type for Graph type resolution if ($key === '@odata.type') { $clean[$key] = $value; continue; } if (is_array($value)) { $clean[$key] = $this->sanitizePayload($value); continue; } $clean[$key] = $value; } return $clean; } private function assertActiveContext(Tenant $tenant, BackupSet $backupSet): void { if (! $tenant->isActive()) { throw new \RuntimeException('Tenant is archived or inactive.'); } if ($backupSet->trashed()) { throw new \RuntimeException('Backup set is archived.'); } } }