|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 = []; $hardFailures = 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', ]; $hardFailures++; continue; } if ($dryRun) { $results[] = $context + ['status' => 'dry_run']; continue; } $this->graphLogger->logRequest('apply_policy', $context); try { $originalPayload = is_array($item->payload) ? $item->payload : []; // sanitize high-level fields according to contract $payload = $this->contracts->sanitizeUpdatePayload($item->policy_type, $originalPayload); $graphOptions = [ 'tenant' => $tenantIdentifier, 'client_id' => $tenant->app_client_id, 'client_secret' => $tenant->app_client_secret, 'platform' => $item->platform, ]; $settingsApply = null; $itemStatus = 'applied'; $settings = []; $resultReason = null; $createdPolicyId = null; $createdPolicyMode = null; if ($item->policy_type === 'settingsCatalogPolicy') { $settings = $this->extractSettingsCatalogSettings($originalPayload); $policyPayload = $this->stripSettingsFromPayload($payload); $response = $this->graphClient->applyPolicy( $item->policy_type, $item->policy_identifier, $policyPayload, $graphOptions ); if ($response->successful() && $settings !== []) { [$settingsApply, $itemStatus] = $this->applySettingsCatalogPolicySettings( policyId: $item->policy_identifier, settings: $settings, graphOptions: $graphOptions, context: $context, ); if ($itemStatus === 'manual_required' && $settingsApply !== null && $this->shouldAttemptSettingsCatalogCreate($settingsApply)) { $createOutcome = $this->createSettingsCatalogPolicy( originalPayload: $originalPayload, settings: $settings, graphOptions: $graphOptions, context: $context, fallbackName: $item->policy_identifier, ); if ($createOutcome['success']) { $createdPolicyId = $createOutcome['policy_id']; $createdPolicyMode = $createOutcome['mode'] ?? null; $itemStatus = 'partial'; $mode = $createOutcome['mode'] ?? 'settings'; $resultReason = $mode === 'metadata_only' ? 'Settings endpoint unsupported; created metadata-only policy. Manual settings apply required.' : 'Settings endpoint unsupported; created new policy. Manual cleanup required.'; if ($settingsApply !== null && $createdPolicyId) { $settingsApply['created_policy_id'] = $createdPolicyId; $settingsApply['created_policy_mode'] = $mode; } } elseif ($settingsApply !== null && $createOutcome['response']) { $settingsApply['issues'][] = [ 'setting_id' => null, 'status' => 'manual_required', 'reason' => 'Fallback policy create failed', 'graph_error_message' => $createOutcome['response']->meta['error_message'] ?? null, 'graph_error_code' => $createOutcome['response']->meta['error_code'] ?? null, 'graph_request_id' => $createOutcome['response']->meta['request_id'] ?? null, 'graph_client_request_id' => $createOutcome['response']->meta['client_request_id'] ?? null, ]; } } } elseif ($settings !== []) { $settingsApply = [ 'total' => count($settings), 'applied' => 0, 'failed' => count($settings), 'manual_required' => 0, 'issues' => [], ]; } } else { $response = $this->graphClient->applyPolicy( $item->policy_type, $item->policy_identifier, $payload, $graphOptions ); } } catch (Throwable $throwable) { $mapped = GraphErrorMapper::fromThrowable($throwable, $context); $results[] = $context + [ 'status' => 'failed', 'reason' => $mapped->getMessage(), 'code' => $mapped->status, 'graph_error_message' => $mapped->getMessage(), 'graph_error_code' => $mapped->status, ]; $hardFailures++; continue; } $this->graphLogger->logResponse('apply_policy', $response, $context); if ($response->failed()) { $results[] = $context + [ 'status' => 'failed', 'reason' => 'Graph apply failed', 'code' => $response->status, 'graph_error_message' => $response->meta['error_message'] ?? null, 'graph_error_code' => $response->meta['error_code'] ?? null, 'graph_request_id' => $response->meta['request_id'] ?? null, 'graph_client_request_id' => $response->meta['client_request_id'] ?? null, ]; $hardFailures++; continue; } $result = $context + ['status' => $itemStatus]; if ($settingsApply !== null) { $result['settings_apply'] = $settingsApply; } if ($createdPolicyId) { $result['created_policy_id'] = $createdPolicyId; } if ($createdPolicyMode) { $result['created_policy_mode'] = $createdPolicyMode; } if ($resultReason !== null) { $result['reason'] = $resultReason; } elseif ($itemStatus !== 'applied') { $result['reason'] = 'Some settings require attention'; } $results[] = $result; $appliedPolicyId = $item->policy_identifier; $policy = Policy::query() ->where('tenant_id', $tenant->id) ->where('external_id', $appliedPolicyId) ->where('policy_type', $item->policy_type) ->first(); if ($policy && $itemStatus === 'applied') { $this->versionService->captureVersion( policy: $policy, payload: $item->payload, createdBy: $actorEmail, metadata: [ 'source' => 'restore', 'restore_run_id' => $restoreRun->id, 'backup_item_id' => $item->id, ] ); } } $resultStatuses = collect($results)->pluck('status')->all(); $nonApplied = collect($resultStatuses)->filter(fn (string $status) => $status !== 'applied' && $status !== 'dry_run')->count(); $allHardFailed = count($results) > 0 && $hardFailures === count($results); $status = $dryRun ? 'previewed' : (match (true) { $allHardFailed => 'failed', $nonApplied > 0 => 'partial', default => 'completed', }); $restoreRun->update([ 'status' => $status, 'results' => $results, 'completed_at' => CarbonImmutable::now(), 'metadata' => [ 'failed' => $hardFailures, 'non_applied' => $nonApplied, '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; } /** * @return array */ private function extractSettingsCatalogSettings(array $payload): array { foreach ($payload as $key => $value) { if (strtolower((string) $key) !== 'settings') { continue; } return is_array($value) ? $value : []; } return []; } private function stripSettingsFromPayload(array $payload): array { foreach (array_keys($payload) as $key) { if (strtolower((string) $key) === 'settings') { unset($payload[$key]); } } return $payload; } private function resolveSettingsCatalogSettingId(array $setting): ?string { foreach ($setting as $key => $value) { if (strtolower((string) $key) !== 'id') { continue; } if (is_string($value) || is_int($value)) { return (string) $value; } return null; } return null; } /** * @param array $settings * @param array $graphOptions * @param array $context * @return array{0: array{total:int,applied:int,failed:int,manual_required:int,issues:array>}, 1: string} */ private function applySettingsCatalogPolicySettings( string $policyId, array $settings, array $graphOptions, array $context, ): array { $method = $this->contracts->settingsWriteMethod('settingsCatalogPolicy'); $path = $this->contracts->settingsWritePath('settingsCatalogPolicy', $policyId); $bodyShape = strtolower($this->contracts->settingsWriteBodyShape('settingsCatalogPolicy')); $fallbackShape = $this->contracts->settingsWriteFallbackBodyShape('settingsCatalogPolicy'); $buildIssues = function (string $reason) use ($settings): array { $issues = []; foreach ($settings as $setting) { if (! is_array($setting)) { continue; } $issues[] = [ 'setting_id' => $this->resolveSettingsCatalogSettingId($setting), 'status' => 'manual_required', 'reason' => $reason, ]; } return $issues; }; if (! $method || ! $path) { return [ [ 'total' => count($settings), 'applied' => 0, 'failed' => 0, 'manual_required' => count($settings), 'issues' => $buildIssues('Settings write contract is not configured (cannot apply automatically).'), ], 'manual_required', ]; } $sanitized = $this->contracts->sanitizeSettingsApplyPayload('settingsCatalogPolicy', $settings); if (! is_array($sanitized) || $sanitized === []) { return [ [ 'total' => count($settings), 'applied' => 0, 'failed' => 0, 'manual_required' => count($settings), 'issues' => $buildIssues('Settings payload could not be sanitized (empty payload).'), ], 'manual_required', ]; } $buildPayload = function (string $shape) use ($sanitized): array { return match ($shape) { 'wrapped' => ['settings' => $sanitized], default => $sanitized, }; }; $payload = $buildPayload($bodyShape); $this->graphLogger->logRequest('apply_settings_bulk', $context + [ 'endpoint' => $path, 'method' => $method, 'settings_count' => count($sanitized), 'body_shape' => $bodyShape, ]); $response = $this->graphClient->request($method, $path, ['json' => $payload] + Arr::except($graphOptions, ['platform'])); $this->graphLogger->logResponse('apply_settings_bulk', $response, $context + [ 'endpoint' => $path, 'method' => $method, 'settings_count' => count($sanitized), 'body_shape' => $bodyShape, ]); if ($response->failed() && is_string($fallbackShape) && strtolower($fallbackShape) !== $bodyShape) { $fallbackShape = strtolower($fallbackShape); if ($this->shouldRetrySettingsBulkApply($response->meta['error_message'] ?? null)) { $fallbackPayload = $buildPayload($fallbackShape); $this->graphLogger->logRequest('apply_settings_bulk_retry', $context + [ 'endpoint' => $path, 'method' => $method, 'settings_count' => count($sanitized), 'body_shape' => $fallbackShape, ]); $response = $this->graphClient->request($method, $path, ['json' => $fallbackPayload] + Arr::except($graphOptions, ['platform'])); $this->graphLogger->logResponse('apply_settings_bulk_retry', $response, $context + [ 'endpoint' => $path, 'method' => $method, 'settings_count' => count($sanitized), 'body_shape' => $fallbackShape, ]); } } if ($response->successful()) { return [ [ 'total' => count($settings), 'applied' => count($settings), 'failed' => 0, 'manual_required' => 0, 'issues' => [], ], 'applied', ]; } return [ [ 'total' => count($settings), 'applied' => 0, 'failed' => 0, 'manual_required' => count($settings), 'issues' => [[ 'setting_id' => null, 'status' => 'manual_required', 'reason' => 'Graph bulk apply failed', 'http_status' => $response->status, 'graph_error_message' => $response->meta['error_message'] ?? null, 'graph_error_code' => $response->meta['error_code'] ?? null, 'graph_request_id' => $response->meta['request_id'] ?? null, 'graph_client_request_id' => $response->meta['client_request_id'] ?? null, ]], ], 'manual_required', ]; } private function shouldRetrySettingsBulkApply(?string $errorMessage): bool { if (! is_string($errorMessage) || $errorMessage === '') { return false; } $message = strtolower($errorMessage); return str_contains($message, 'empty payload') || str_contains($message, 'json content expected') || str_contains($message, 'request body'); } private function shouldAttemptSettingsCatalogCreate(array $settingsApply): bool { $issues = $settingsApply['issues'] ?? []; foreach ($issues as $issue) { $message = strtolower((string) ($issue['graph_error_message'] ?? $issue['reason'] ?? '')); if ($message === '') { continue; } if (str_contains($message, 'no odata route exists') || str_contains($message, 'no method match route template')) { return true; } } return false; } /** * @return array{success:bool,policy_id:?string,response:?object,mode:string} */ private function createSettingsCatalogPolicy( array $originalPayload, array $settings, array $graphOptions, array $context, string $fallbackName, ): array { $resource = $this->contracts->resourcePath('settingsCatalogPolicy') ?? 'deviceManagement/configurationPolicies'; $sanitizedSettings = $this->contracts->sanitizeSettingsApplyPayload('settingsCatalogPolicy', $settings); if ($sanitizedSettings === []) { return [ 'success' => false, 'policy_id' => null, 'response' => null, 'mode' => 'failed', ]; } $payload = $this->buildSettingsCatalogCreatePayload($originalPayload, $sanitizedSettings, $fallbackName, true); $this->graphLogger->logRequest('create_settings_catalog_policy', $context + [ 'endpoint' => $resource, 'method' => 'POST', 'settings_count' => count($sanitizedSettings), ]); $response = $this->graphClient->request('POST', $resource, ['json' => $payload] + Arr::except($graphOptions, ['platform'])); $this->graphLogger->logResponse('create_settings_catalog_policy', $response, $context + [ 'endpoint' => $resource, 'method' => 'POST', 'settings_count' => count($sanitizedSettings), ]); $policyId = $this->extractCreatedPolicyId($response); $mode = 'settings'; if ($response->failed() && $this->shouldRetrySettingsCatalogCreateWithoutSettings($response)) { $fallbackPayload = $this->buildSettingsCatalogCreatePayload($originalPayload, $sanitizedSettings, $fallbackName, false); $this->graphLogger->logRequest('create_settings_catalog_policy_fallback', $context + [ 'endpoint' => $resource, 'method' => 'POST', ]); $response = $this->graphClient->request('POST', $resource, ['json' => $fallbackPayload] + Arr::except($graphOptions, ['platform'])); $this->graphLogger->logResponse('create_settings_catalog_policy_fallback', $response, $context + [ 'endpoint' => $resource, 'method' => 'POST', ]); $policyId = $this->extractCreatedPolicyId($response); $mode = 'metadata_only'; } return [ 'success' => $response->successful(), 'policy_id' => $policyId, 'response' => $response, 'mode' => $mode, ]; } private function shouldRetrySettingsCatalogCreateWithoutSettings(object $response): bool { $code = strtolower((string) ($response->meta['error_code'] ?? '')); $message = strtolower((string) ($response->meta['error_message'] ?? '')); if ($code === 'notsupported' || str_contains($code, 'notsupported')) { return true; } return str_contains($message, 'not supported'); } private function extractCreatedPolicyId(object $response): ?string { if ($response->successful() && isset($response->data['id']) && is_string($response->data['id'])) { return $response->data['id']; } return null; } /** * @param array $settings * @return array */ private function buildSettingsCatalogCreatePayload( array $originalPayload, array $settings, string $fallbackName, bool $includeSettings, ): array { $payload = []; $name = $this->resolvePayloadString($originalPayload, ['name', 'displayName']); $payload['name'] = $name ?? sprintf('Restored %s', $fallbackName); $description = $this->resolvePayloadString($originalPayload, ['description', 'Description']); if ($description !== null) { $payload['description'] = $description; } $platforms = $this->resolvePayloadArray($originalPayload, ['platforms', 'Platforms']); if ($platforms !== null) { $payload['platforms'] = array_values($platforms); } $technologies = $this->resolvePayloadArray($originalPayload, ['technologies', 'Technologies']); if ($technologies !== null) { $payload['technologies'] = array_values($technologies); } $roleScopeTagIds = $this->resolvePayloadArray($originalPayload, ['roleScopeTagIds', 'RoleScopeTagIds']); if ($roleScopeTagIds !== null) { $payload['roleScopeTagIds'] = array_values($roleScopeTagIds); } $templateReference = $this->resolvePayloadArray($originalPayload, ['templateReference', 'TemplateReference']); if ($templateReference !== null) { $payload['templateReference'] = $this->stripOdataAndReadOnly($templateReference); } if ($includeSettings && $settings !== []) { $payload['settings'] = $settings; } return $payload; } /** * @param array $payload * @param array $keys */ private function resolvePayloadString(array $payload, array $keys): ?string { $value = $this->resolvePayloadValue($payload, $keys); if (! is_string($value) || trim($value) === '') { return null; } return $value; } /** * @param array $payload * @param array $keys * @return array|null */ private function resolvePayloadArray(array $payload, array $keys): ?array { $value = $this->resolvePayloadValue($payload, $keys); if (! is_array($value) || $value === []) { return null; } return $value; } /** * @param array $payload * @param array $keys */ private function resolvePayloadValue(array $payload, array $keys): mixed { $normalized = array_map('strtolower', $keys); foreach ($payload as $key => $value) { if (in_array(strtolower((string) $key), $normalized, true)) { return $value; } } return null; } /** * @param array $payload * @return array */ private function stripOdataAndReadOnly(array $payload): array { $clean = []; $readOnlyKeys = ['id', 'createddatetime', 'lastmodifieddatetime', 'version']; foreach ($payload as $key => $value) { $normalizedKey = strtolower((string) $key); if (str_starts_with($normalizedKey, '@odata')) { continue; } if (in_array($normalizedKey, $readOnlyKeys, true)) { continue; } if (is_array($value)) { if (array_is_list($value)) { $items = array_map(function ($item) { if (is_array($item)) { return $this->stripOdataAndReadOnly($item); } return $item; }, $value); $clean[$key] = array_values(array_filter($items, static fn ($item) => $item !== [])); continue; } $clean[$key] = $this->stripOdataAndReadOnly($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.'); } } }