|null $selectedItemIds */ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedItemIds = null): array { $this->assertActiveContext($tenant, $backupSet); $items = $this->loadItems($backupSet, $selectedItemIds); [$foundationItems, $policyItems] = $this->splitItems($items); $notificationTemplateIds = $foundationItems ->where('policy_type', 'notificationMessageTemplate') ->pluck('policy_identifier') ->filter() ->values() ->all(); $foundationPreview = $this->foundationMappingService->map($tenant, $foundationItems, false)['entries'] ?? []; $policyPreview = $policyItems->map(function (BackupItem $item) use ($tenant, $notificationTemplateIds) { $existing = Policy::query() ->where('tenant_id', $tenant->id) ->where('external_id', $item->policy_identifier) ->where('policy_type', $item->policy_type) ->first(); $restoreMode = $this->resolveRestoreMode($item->policy_type); $preview = [ 'backup_item_id' => $item->id, 'policy_identifier' => $item->policy_identifier, 'policy_type' => $item->policy_type, 'platform' => $item->platform, 'action' => $existing ? 'update' : 'create', 'conflict' => false, 'restore_mode' => $restoreMode, '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), ]; if ($item->policy_type === 'deviceCompliancePolicy') { $preview = array_merge( $preview, $this->previewComplianceNotificationTemplates( payload: is_array($item->payload) ? $item->payload : [], availableTemplateIds: $notificationTemplateIds ) ); } return $preview; })->all(); return array_merge($foundationPreview, $policyPreview); } /** * 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, array $groupMapping = [], ): RestoreRun { $this->assertActiveContext($tenant, $backupSet); $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; $items = $this->loadItems($backupSet, $selectedItemIds); [$foundationItems, $policyItems] = $this->splitItems($items); $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' => [], 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, ]); if ($groupMapping !== []) { $this->auditLogger->log( tenant: $tenant, action: 'restore.group_mapping.applied', context: [ 'metadata' => [ 'restore_run_id' => $restoreRun->id, 'backup_set_id' => $backupSet->id, 'mapped_groups' => count($groupMapping), ], ], actorEmail: $actorEmail, actorName: $actorName, resourceType: 'restore_run', resourceId: (string) $restoreRun->id, status: 'success' ); } $foundationOutcome = $this->foundationMappingService->map($tenant, $foundationItems, ! $dryRun); $foundationEntries = $foundationOutcome['entries'] ?? []; $foundationFailures = (int) ($foundationOutcome['failed'] ?? 0); $foundationSkipped = (int) ($foundationOutcome['skipped'] ?? 0); $foundationMappingByType = $this->buildFoundationMappingByType($foundationEntries); $scopeTagMapping = $foundationMappingByType['roleScopeTag'] ?? []; if (! $dryRun) { $this->auditFoundationMapping( tenant: $tenant, restoreRun: $restoreRun, entries: $foundationEntries, actorEmail: $actorEmail, actorName: $actorName ); } $results = $foundationEntries; $hardFailures = $foundationFailures; foreach ($policyItems as $item) { $context = [ 'tenant' => $tenantIdentifier, 'policy_type' => $item->policy_type, 'policy_id' => $item->policy_identifier, 'backup_item_id' => $item->id, ]; $restoreMode = $this->resolveRestoreMode($item->policy_type); if ($restoreMode === 'preview-only') { $results[] = $context + [ 'status' => $dryRun ? 'dry_run' : 'skipped', 'reason' => 'preview_only', 'restore_mode' => $restoreMode, ]; continue; } $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', 'restore_mode' => $restoreMode, ]; continue; } $this->graphLogger->logRequest('apply_policy', $context); try { $originalPayload = is_array($item->payload) ? $item->payload : []; $originalPayload = $this->applyScopeTagMapping($originalPayload, $scopeTagMapping); $complianceActionSummary = null; $complianceActionOutcomes = null; if ($item->policy_type === 'deviceCompliancePolicy') { [$originalPayload, $complianceActionSummary, $complianceActionOutcomes] = $this->applyComplianceNotificationTemplateMapping( payload: $originalPayload, templateMapping: $foundationMappingByType['notificationMessageTemplate'] ?? [] ); } $mappedScopeTagIds = $this->resolvePayloadArray($originalPayload, ['roleScopeTagIds', 'RoleScopeTagIds']); // sanitize high-level fields according to contract $payload = $this->contracts->sanitizeUpdatePayload($item->policy_type, $originalPayload); $payload = $this->applyScopeTagIdsToPayload($payload, $mappedScopeTagIds, $scopeTagMapping); $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; $mode = $createOutcome['mode'] ?? 'settings'; // When settings are included in CREATE, mark as applied instead of partial $itemStatus = $mode === 'settings' ? 'applied' : 'partial'; $resultReason = $mode === 'metadata_only' ? 'Settings endpoint unsupported; created metadata-only policy. Manual settings apply required.' : 'Settings endpoint unsupported; created new policy with settings. Manual cleanup required.'; if ($settingsApply !== null && $createdPolicyId) { $settingsApply['created_policy_id'] = $createdPolicyId; $settingsApply['created_policy_mode'] = $mode; // Update statistics when settings were included in CREATE if ($mode === 'settings') { $settingsApply['applied'] = $settingsApply['total'] ?? count($settings); $settingsApply['manual_required'] = 0; $settingsApply['issues'] = []; } } } 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; } $assignmentOutcomes = null; $assignmentSummary = null; if (! $dryRun && is_array($item->assignments) && $item->assignments !== []) { $assignmentPolicyId = $createdPolicyId ?? $item->policy_identifier; $assignmentOutcomes = $this->assignmentRestoreService->restore( tenant: $tenant, policyType: $item->policy_type, policyId: $assignmentPolicyId, assignments: $item->assignments, groupMapping: $groupMapping, foundationMapping: $foundationMappingByType, restoreRun: $restoreRun, actorEmail: $actorEmail, actorName: $actorName, ); $assignmentSummary = $assignmentOutcomes['summary'] ?? null; if (is_array($assignmentSummary) && ($assignmentSummary['failed'] ?? 0) > 0 && $itemStatus === 'applied') { $itemStatus = 'partial'; $resultReason = 'Assignments restored with failures'; } } if (is_array($complianceActionSummary) && ($complianceActionSummary['skipped'] ?? 0) > 0 && $itemStatus === 'applied') { $itemStatus = 'partial'; $resultReason = 'Compliance notification actions skipped'; } if ($complianceActionSummary !== null) { $this->auditComplianceActionMapping( tenant: $tenant, restoreRun: $restoreRun, policyId: $item->policy_identifier, policyType: $item->policy_type, summary: $complianceActionSummary, outcomes: $complianceActionOutcomes ?? [], actorEmail: $actorEmail, actorName: $actorName ); } $result = $context + [ 'status' => $itemStatus, 'restore_mode' => $restoreMode, ]; 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'; } if ($assignmentOutcomes !== null) { $result['assignment_outcomes'] = $assignmentOutcomes['outcomes'] ?? []; } if ($assignmentSummary !== null) { $result['assignment_summary'] = $assignmentSummary; } if ($complianceActionSummary !== null) { $result['compliance_action_summary'] = $complianceActionSummary; } if ($complianceActionOutcomes !== null) { $result['compliance_action_outcomes'] = $complianceActionOutcomes; } $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 ($status) => is_string($status) && $status !== 'applied' && $status !== 'dry_run')->count(); $foundationNonApplied = collect($foundationEntries)->filter(function (array $entry): bool { $decision = $entry['decision'] ?? null; return in_array($decision, ['failed', 'skipped'], true); })->count(); $nonApplied += $foundationNonApplied; $totalCount = count($results); $allHardFailed = $totalCount > 0 && $hardFailures === $totalCount; $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' => $totalCount, 'foundations_skipped' => $foundationSkipped, ], ]); $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 Collection $items * @return array{0: Collection, 1: Collection} */ private function splitItems(Collection $items): array { $foundationItems = $items->filter(fn (BackupItem $item) => $item->isFoundation())->values(); $policyItems = $items->reject(fn (BackupItem $item) => $item->isFoundation())->values(); return [$foundationItems, $policyItems]; } /** * @return array */ private function resolveTypeMeta(string $policyType): array { $types = array_merge( config('tenantpilot.supported_policy_types', []), config('tenantpilot.foundation_types', []) ); foreach ($types as $typeConfig) { if (($typeConfig['type'] ?? null) === $policyType) { return $typeConfig; } } return []; } private function resolveRestoreMode(string $policyType): string { $meta = $this->resolveTypeMeta($policyType); $restore = $meta['restore'] ?? 'enabled'; if (! is_string($restore) || $restore === '') { return 'enabled'; } return $restore; } /** * @param array> $entries * @return array> */ private function buildFoundationMappingByType(array $entries): array { $mapping = []; foreach ($entries as $entry) { if (! is_array($entry)) { continue; } $type = $entry['type'] ?? null; $sourceId = $entry['sourceId'] ?? null; $targetId = $entry['targetId'] ?? null; if (! is_string($type) || $type === '') { continue; } if (! is_string($sourceId) || $sourceId === '') { continue; } if (! is_string($targetId) || $targetId === '') { continue; } $mapping[$type][$sourceId] = $targetId; } return $mapping; } /** * @param array $payload * @param array $scopeTagMapping * @return array */ private function applyScopeTagMapping(array $payload, array $scopeTagMapping): array { if ($scopeTagMapping === []) { return $payload; } $roleScopeTagIds = $this->resolvePayloadArray($payload, ['roleScopeTagIds', 'RoleScopeTagIds']); if ($roleScopeTagIds === null) { return $payload; } $mapped = []; foreach ($roleScopeTagIds as $id) { if (is_string($id) || is_int($id)) { $stringId = (string) $id; $mapped[] = $scopeTagMapping[$stringId] ?? $stringId; } } if ($mapped === []) { return $payload; } $payload['roleScopeTagIds'] = array_values(array_unique($mapped)); unset($payload['RoleScopeTagIds']); return $payload; } /** * @param array $payload * @param array|null $scopeTagIds * @param array $scopeTagMapping * @return array */ private function applyScopeTagIdsToPayload(array $payload, ?array $scopeTagIds, array $scopeTagMapping): array { if ($scopeTagIds === null || $scopeTagMapping === []) { return $payload; } $payload['roleScopeTagIds'] = array_values($scopeTagIds); return $payload; } /** * @param array $payload * @param array $availableTemplateIds * @return array */ private function previewComplianceNotificationTemplates(array $payload, array $availableTemplateIds): array { $templateIds = $this->collectComplianceNotificationTemplateIds($payload); if ($templateIds === []) { return []; } $available = array_values(array_unique($availableTemplateIds)); $missing = array_values(array_diff($templateIds, $available)); $summary = [ 'total' => count($templateIds), 'missing' => count($missing), ]; $warning = null; if ($missing !== []) { $warning = sprintf('Missing %d notification template(s); notification actions may be skipped.', count($missing)); } return array_filter([ 'compliance_action_summary' => $summary, 'compliance_action_warning' => $warning, 'compliance_action_missing_templates' => $missing !== [] ? $missing : null, ], static fn ($value) => $value !== null); } /** * @param array $payload * @param array $templateMapping * @return array{0: array, 1: ?array{total:int,mapped:int,skipped:int}, 2: ?array>} */ private function applyComplianceNotificationTemplateMapping(array $payload, array $templateMapping): array { $scheduled = $payload['scheduledActionsForRule'] ?? null; if (! is_array($scheduled)) { return [$payload, null, null]; } $rules = []; $total = 0; $mapped = 0; $skipped = 0; $outcomes = []; foreach ($scheduled as $rule) { if (! is_array($rule)) { continue; } $configs = $rule['scheduledActionConfigurations'] ?? null; if (! is_array($configs)) { $rules[] = $rule; continue; } $ruleName = $rule['ruleName'] ?? null; $updatedConfigs = []; foreach ($configs as $config) { if (! is_array($config)) { $updatedConfigs[] = $config; continue; } $actionType = $config['actionType'] ?? null; $templateKey = $this->resolveNotificationTemplateKey($config); if ($actionType !== 'notification' || $templateKey === null) { $updatedConfigs[] = $config; continue; } $templateId = $config[$templateKey] ?? null; if (! is_string($templateId) || $templateId === '' || $this->isEmptyGuid($templateId)) { $updatedConfigs[] = $config; continue; } $total++; if ($templateMapping === []) { $outcomes[] = [ 'status' => 'skipped', 'template_id' => $templateId, 'rule_name' => $ruleName, 'reason' => 'Notification template mapping unavailable.', ]; $skipped++; continue; } $mappedTemplateId = $templateMapping[$templateId] ?? null; if (! is_string($mappedTemplateId) || $mappedTemplateId === '') { $outcomes[] = [ 'status' => 'skipped', 'template_id' => $templateId, 'rule_name' => $ruleName, 'reason' => 'Notification template mapping missing for template ID.', ]; $skipped++; continue; } $config[$templateKey] = $mappedTemplateId; $updatedConfigs[] = $config; $mapped++; $outcomes[] = [ 'status' => 'mapped', 'template_id' => $templateId, 'mapped_template_id' => $mappedTemplateId, 'rule_name' => $ruleName, ]; } if ($updatedConfigs === []) { continue; } $rule['scheduledActionConfigurations'] = array_values($updatedConfigs); $rules[] = $rule; } if ($rules !== []) { $payload['scheduledActionsForRule'] = array_values($rules); } else { unset($payload['scheduledActionsForRule']); } if ($total === 0) { return [$payload, null, null]; } return [$payload, ['total' => $total, 'mapped' => $mapped, 'skipped' => $skipped], $outcomes]; } /** * @param array $payload * @return array */ private function collectComplianceNotificationTemplateIds(array $payload): array { $scheduled = $payload['scheduledActionsForRule'] ?? null; if (! is_array($scheduled)) { return []; } $ids = []; foreach ($scheduled as $rule) { if (! is_array($rule)) { continue; } $configs = $rule['scheduledActionConfigurations'] ?? null; if (! is_array($configs)) { continue; } foreach ($configs as $config) { if (! is_array($config)) { continue; } if (($config['actionType'] ?? null) !== 'notification') { continue; } $templateKey = $this->resolveNotificationTemplateKey($config); if ($templateKey === null) { continue; } $templateId = $config[$templateKey] ?? null; if (! is_string($templateId) || $templateId === '' || $this->isEmptyGuid($templateId)) { continue; } $ids[] = $templateId; } } return array_values(array_unique($ids)); } private function resolveNotificationTemplateKey(array $config): ?string { if (array_key_exists('notificationTemplateId', $config)) { return 'notificationTemplateId'; } if (array_key_exists('notificationMessageTemplateId', $config)) { return 'notificationMessageTemplateId'; } return null; } private function isEmptyGuid(string $value): bool { return strtolower($value) === '00000000-0000-0000-0000-000000000000'; } /** * @param array> $entries */ private function auditFoundationMapping( Tenant $tenant, RestoreRun $restoreRun, array $entries, ?string $actorEmail, ?string $actorName ): void { foreach ($entries as $entry) { if (! is_array($entry)) { continue; } $decision = $entry['decision'] ?? 'mapped_existing'; $action = match ($decision) { 'created_copy' => 'restore.foundation.created_copy', 'created' => 'restore.foundation.created', 'failed' => 'restore.foundation.failed', 'skipped' => 'restore.foundation.skipped', default => 'restore.foundation.mapped', }; $status = match ($decision) { 'failed' => 'failed', 'skipped' => 'warning', default => 'success', }; $this->auditLogger->log( tenant: $tenant, action: $action, context: [ 'metadata' => [ 'restore_run_id' => $restoreRun->id, 'type' => $entry['type'] ?? null, 'source_id' => $entry['sourceId'] ?? null, 'source_name' => $entry['sourceName'] ?? null, 'target_id' => $entry['targetId'] ?? null, 'target_name' => $entry['targetName'] ?? null, 'decision' => $decision, 'reason' => $entry['reason'] ?? null, ], ], actorEmail: $actorEmail, actorName: $actorName, resourceType: 'restore_run', resourceId: (string) $restoreRun->id, status: $status ); } } /** * @param array{total:int,mapped:int,skipped:int} $summary * @param array> $outcomes */ private function auditComplianceActionMapping( Tenant $tenant, RestoreRun $restoreRun, string $policyId, string $policyType, array $summary, array $outcomes, ?string $actorEmail, ?string $actorName ): void { $skipped = (int) ($summary['skipped'] ?? 0); $status = $skipped > 0 ? 'warning' : 'success'; $skippedTemplates = collect($outcomes) ->filter(fn (array $outcome) => ($outcome['status'] ?? null) === 'skipped') ->pluck('template_id') ->filter() ->values() ->all(); $this->auditLogger->log( tenant: $tenant, action: 'restore.compliance.actions.mapped', context: [ 'metadata' => [ 'restore_run_id' => $restoreRun->id, 'policy_id' => $policyId, 'policy_type' => $policyType, 'total' => (int) ($summary['total'] ?? 0), 'mapped' => (int) ($summary['mapped'] ?? 0), 'skipped' => $skipped, 'skipped_template_ids' => $skippedTemplates, ], ], actorEmail: $actorEmail, actorName: $actorName, resourceType: 'restore_run', resourceId: (string) $restoreRun->id, status: $status ); } /** * @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 and technologies must be singular strings for CREATE (not arrays) // Graph API inconsistency: GET returns arrays, but POST expects strings $platforms = $this->resolvePayloadArray($originalPayload, ['platforms', 'Platforms']); if ($platforms !== null && $platforms !== []) { $payload['platforms'] = is_array($platforms) ? $platforms[0] : $platforms; } elseif ($platforms === null) { // Fallback: extract from policy_type or default to windows10 $payload['platforms'] = 'windows10'; } $technologies = $this->resolvePayloadArray($originalPayload, ['technologies', 'Technologies']); if ($technologies !== null && $technologies !== []) { $payload['technologies'] = is_array($technologies) ? $technologies[0] : $technologies; } elseif ($technologies === null) { // Default to mdm if not present $payload['technologies'] = 'mdm'; } $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.'); } } }