From a985bff2870af9a81df1688484f995d03e7e7fff Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 28 Dec 2025 16:04:08 +0100 Subject: [PATCH] fix: create missing policies on restore --- app/Services/Intune/RestoreService.php | 242 +++++++++++++++++- specs/007-device-config-compliance/tasks.md | 2 +- .../Feature/Filament/RestoreExecutionTest.php | 102 ++++++++ 3 files changed, 341 insertions(+), 5 deletions(-) diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index c5a36a1..7735824 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -242,6 +242,7 @@ public function execute( 'client_secret' => $tenant->app_client_secret, 'platform' => $item->platform, ]; + $updateMethod = $this->resolveUpdateMethod($item->policy_type); $settingsApply = null; $itemStatus = 'applied'; @@ -249,6 +250,7 @@ public function execute( $resultReason = null; $createdPolicyId = null; $createdPolicyMode = null; + $settingsApplyEligible = false; if ($item->policy_type === 'settingsCatalogPolicy') { $settings = $this->extractSettingsCatalogSettings($originalPayload); @@ -258,10 +260,55 @@ public function execute( $item->policy_type, $item->policy_identifier, $policyPayload, - $graphOptions + $graphOptions + ['method' => $updateMethod] ); - if ($response->successful() && $settings !== []) { + $settingsApplyEligible = $response->successful(); + + if ($response->failed() && $this->shouldAttemptPolicyCreate($item->policy_type, $response)) { + $createOutcome = $this->createSettingsCatalogPolicy( + originalPayload: $originalPayload, + settings: $settings, + graphOptions: $graphOptions, + context: $context, + fallbackName: $item->resolvedDisplayName(), + ); + + $response = $createOutcome['response'] ?? $response; + + if ($createOutcome['success']) { + $createdPolicyId = $createOutcome['policy_id']; + $createdPolicyMode = $createOutcome['mode'] ?? null; + $mode = $createOutcome['mode'] ?? 'settings'; + + $itemStatus = $mode === 'settings' ? 'applied' : 'partial'; + $resultReason = $mode === 'metadata_only' + ? 'Policy missing; created metadata-only policy. Manual settings apply required.' + : 'Policy missing; created new policy with settings.'; + + if ($settings !== []) { + $settingsApply = $mode === 'metadata_only' + ? [ + 'total' => count($settings), + 'applied' => 0, + 'failed' => 0, + 'manual_required' => count($settings), + 'issues' => [], + ] + : [ + 'total' => count($settings), + 'applied' => count($settings), + 'failed' => 0, + 'manual_required' => 0, + 'issues' => [], + ]; + } + + $settingsApplyEligible = false; + } + } + + if ($settingsApplyEligible && $settings !== []) { [$settingsApply, $itemStatus] = $this->applySettingsCatalogPolicySettings( policyId: $item->policy_identifier, settings: $settings, @@ -314,7 +361,7 @@ public function execute( ]; } } - } elseif ($settings !== []) { + } elseif ($settingsApplyEligible && $settings !== []) { $settingsApply = [ 'total' => count($settings), 'applied' => 0, @@ -328,7 +375,7 @@ public function execute( $item->policy_type, $item->policy_identifier, $payload, - $graphOptions + $graphOptions + ['method' => $updateMethod] ); if ($item->policy_type === 'windowsAutopilotDeploymentProfile' && $response->failed()) { @@ -349,6 +396,26 @@ public function execute( $resultReason = 'Policy missing; created new Autopilot profile.'; } } + } elseif ($response->failed() && $this->shouldAttemptPolicyCreate($item->policy_type, $response)) { + $createOutcome = $this->createPolicyFromSnapshot( + policyType: $item->policy_type, + payload: $payload, + originalPayload: $originalPayload, + graphOptions: $graphOptions, + context: $context, + fallbackName: $item->resolvedDisplayName(), + ); + + if ($createOutcome['attempted']) { + $response = $createOutcome['response'] ?? $response; + + if ($createOutcome['success']) { + $createdPolicyId = $createOutcome['policy_id']; + $createdPolicyMode = 'created'; + $itemStatus = 'applied'; + $resultReason = 'Policy missing; created new policy.'; + } + } } } } catch (Throwable $throwable) { @@ -602,6 +669,52 @@ private function resolveRestoreMode(string $policyType): string return $restore; } + private function resolveUpdateMethod(string $policyType): string + { + $contract = $this->contracts->get($policyType); + $method = strtoupper((string) ($contract['update_method'] ?? 'PATCH')); + + return $method !== '' ? $method : 'PATCH'; + } + + private function resolveCreateMethod(string $policyType): ?string + { + $contract = $this->contracts->get($policyType); + $method = strtoupper((string) ($contract['create_method'] ?? 'POST')); + + return $method !== '' ? $method : null; + } + + private function shouldAttemptPolicyCreate(string $policyType, object $response): bool + { + if (! $this->isNotFoundResponse($response)) { + return false; + } + + $resource = $this->contracts->resourcePath($policyType); + $method = $this->resolveCreateMethod($policyType); + + return is_string($resource) && $resource !== '' && $method !== null; + } + + private function isNotFoundResponse(object $response): bool + { + if (($response->status ?? null) === 404) { + return true; + } + + $code = strtolower((string) ($response->meta['error_code'] ?? '')); + $message = strtolower((string) ($response->meta['error_message'] ?? '')); + + if ($code !== '' && (str_contains($code, 'notfound') || str_contains($code, 'resource'))) { + return true; + } + + return $message !== '' && (str_contains($message, 'not found') + || str_contains($message, 'resource not found') + || str_contains($message, 'does not exist')); + } + /** * @param array> $entries * @return array> @@ -1359,6 +1472,70 @@ private function createSettingsCatalogPolicy( ]; } + /** + * @return array{attempted:bool,success:bool,policy_id:?string,response:?object} + */ + private function createPolicyFromSnapshot( + string $policyType, + array $payload, + array $originalPayload, + array $graphOptions, + array $context, + string $fallbackName, + ): array { + $resource = $this->contracts->resourcePath($policyType); + $method = $this->resolveCreateMethod($policyType); + + if (! is_string($resource) || $resource === '' || $method === null) { + return [ + 'attempted' => false, + 'success' => false, + 'policy_id' => null, + 'response' => null, + ]; + } + + $createPayload = Arr::except($payload, ['assignments']); + $createPayload = $this->applyOdataTypeForCreate($policyType, $createPayload, $originalPayload); + $createPayload = $this->applyRestoredNameToPayload($createPayload, $originalPayload, $fallbackName); + + if ($createPayload === []) { + return [ + 'attempted' => true, + 'success' => false, + 'policy_id' => null, + 'response' => null, + ]; + } + + $this->graphLogger->logRequest('create_policy', $context + [ + 'endpoint' => $resource, + 'method' => $method, + 'policy_type' => $policyType, + ]); + + $response = $this->graphClient->request( + $method, + $resource, + ['json' => $createPayload] + Arr::except($graphOptions, ['platform']) + ); + + $this->graphLogger->logResponse('create_policy', $response, $context + [ + 'endpoint' => $resource, + 'method' => $method, + 'policy_type' => $policyType, + ]); + + $policyId = $this->extractCreatedPolicyId($response); + + return [ + 'attempted' => true, + 'success' => $response->successful(), + 'policy_id' => $policyId, + 'response' => $response, + ]; + } + /** * @return array{attempted:bool,success:bool,policy_id:?string,response:?object} */ @@ -1525,6 +1702,63 @@ private function buildSettingsCatalogCreatePayload( return $payload; } + /** + * @param array $payload + * @param array $originalPayload + * @return array + */ + private function applyRestoredNameToPayload(array $payload, array $originalPayload, string $fallbackName): array + { + $displayName = $this->resolvePayloadString($payload, ['displayName']); + $name = $this->resolvePayloadString($payload, ['name']); + $originalDisplayName = $this->resolvePayloadString($originalPayload, ['displayName']); + $originalName = $this->resolvePayloadString($originalPayload, ['name']); + $baseName = $displayName ?? $originalDisplayName ?? $name ?? $originalName ?? $fallbackName; + $restoredName = $this->prefixRestoredName($baseName, $fallbackName); + + if (array_key_exists('displayName', $payload) || $originalDisplayName !== null || $displayName !== null) { + $payload['displayName'] = $restoredName; + + return $payload; + } + + if (array_key_exists('name', $payload) || $originalName !== null || $name !== null) { + $payload['name'] = $restoredName; + + return $payload; + } + + $payload['displayName'] = $restoredName; + + return $payload; + } + + /** + * @param array $payload + * @param array $originalPayload + * @return array + */ + private function applyOdataTypeForCreate(string $policyType, array $payload, array $originalPayload): array + { + if (array_key_exists('@odata.type', $payload)) { + return $payload; + } + + $odataType = $this->resolvePayloadString($originalPayload, ['@odata.type']); + + if ($odataType === null) { + return $payload; + } + + if (! $this->contracts->matchesTypeFamily($policyType, $odataType)) { + return $payload; + } + + $payload['@odata.type'] = $odataType; + + return $payload; + } + private function prefixRestoredName(?string $name, string $fallback): string { $prefix = 'Restored_'; diff --git a/specs/007-device-config-compliance/tasks.md b/specs/007-device-config-compliance/tasks.md index e5a8981..36113d0 100644 --- a/specs/007-device-config-compliance/tasks.md +++ b/specs/007-device-config-compliance/tasks.md @@ -41,7 +41,7 @@ ## Phase 3: Restore Logic and Mapping **Purpose**: Restore new policy types safely using assignment and foundation mappings. -- [ ] T009 Update `app/Services/Intune/RestoreService.php` to restore the new policy types using Graph contracts. +- [x] T009 Update `app/Services/Intune/RestoreService.php` to restore the new policy types using Graph contracts. - [x] T010 Extend `app/Services/AssignmentRestoreService.php` for assignment endpoints of the new types. - [x] T011 Ensure compliance notification templates are restored and referenced via mapping in `app/Services/Intune/RestoreService.php`. - [x] T012 Add audit coverage for compliance action mapping outcomes in `app/Services/Intune/AuditLogger.php`. diff --git a/tests/Feature/Filament/RestoreExecutionTest.php b/tests/Feature/Filament/RestoreExecutionTest.php index 706e2a0..7e873b7 100644 --- a/tests/Feature/Filament/RestoreExecutionTest.php +++ b/tests/Feature/Filament/RestoreExecutionTest.php @@ -392,3 +392,105 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon expect($run->results[0]['status'])->toBe('applied'); expect($run->results[0]['created_policy_id'])->toBe('autopilot-created'); }); + +test('restore execution creates missing policy using contracts', function () { + $graphClient = new class implements GraphClientInterface + { + public int $applyCalls = 0; + + public int $createCalls = 0; + + public array $createPayloads = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, ['payload' => []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applyCalls++; + + return new GraphResponse(false, [], 404, [], [], [ + 'error_code' => 'ResourceNotFound', + 'error_message' => 'Resource not found.', + ]); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + if ($method === 'POST' && $path === 'deviceManagement/deviceCompliancePolicies') { + $this->createCalls++; + $this->createPayloads[] = $options['json'] ?? []; + + return new GraphResponse(true, ['id' => 'compliance-created']); + } + + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }; + + app()->instance(GraphClientInterface::class, $graphClient); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-4', + 'name' => 'Tenant Four', + 'metadata' => [], + ]); + + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory() + ->for($tenant) + ->for($backupSet) + ->state([ + 'policy_id' => null, + 'policy_identifier' => 'compliance-1', + 'policy_type' => 'deviceCompliancePolicy', + 'platform' => 'windows', + 'payload' => [ + '@odata.type' => '#microsoft.graph.windows10CompliancePolicy', + 'displayName' => 'Compliance Policy', + 'description' => 'Test policy', + ], + ]) + ->create(); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + ); + + expect($graphClient->applyCalls)->toBe(1); + expect($graphClient->createCalls)->toBe(1); + expect($graphClient->createPayloads[0]['displayName'] ?? null)->toBe('Restored_Compliance Policy'); + expect($run->status)->toBe('completed'); + expect($run->results[0]['status'])->toBe('applied'); + expect($run->results[0]['created_policy_id'])->toBe('compliance-created'); +});