diff --git a/app/Services/Graph/GraphContractRegistry.php b/app/Services/Graph/GraphContractRegistry.php index 40b6a56..37afda0 100644 --- a/app/Services/Graph/GraphContractRegistry.php +++ b/app/Services/Graph/GraphContractRegistry.php @@ -69,8 +69,7 @@ public function sanitizeUpdatePayload(string $policyType, array $snapshot): arra $whitelist = $contract['update_whitelist'] ?? null; $stripKeys = array_merge($this->readOnlyKeys(), $contract['update_strip_keys'] ?? []); $mapping = $contract['update_map'] ?? []; - - $stripOdata = $whitelist !== null || ! empty($contract['update_strip_keys']); + $stripOdata = $contract['strip_odata'] ?? ($whitelist !== null || ! empty($contract['update_strip_keys'])); $result = $this->sanitizeArray($snapshot, $whitelist, $stripKeys, $stripOdata, $mapping); diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index 8fc3aab..0cbb466 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -330,6 +330,26 @@ public function execute( $payload, $graphOptions ); + + if ($item->policy_type === 'windowsAutopilotDeploymentProfile' && $response->failed()) { + $createOutcome = $this->createAutopilotDeploymentProfileIfMissing( + originalPayload: $originalPayload, + graphOptions: $graphOptions, + context: $context, + policyId: $item->policy_identifier, + ); + + if ($createOutcome['attempted']) { + $response = $createOutcome['response'] ?? $response; + + if ($createOutcome['success']) { + $createdPolicyId = $createOutcome['policy_id']; + $createdPolicyMode = 'created'; + $itemStatus = 'applied'; + $resultReason = 'Policy missing; created new Autopilot profile.'; + } + } + } } } catch (Throwable $throwable) { $mapped = GraphErrorMapper::fromThrowable($throwable, $context); @@ -1324,6 +1344,91 @@ private function createSettingsCatalogPolicy( ]; } + /** + * @return array{attempted:bool,success:bool,policy_id:?string,response:?object} + */ + private function createAutopilotDeploymentProfileIfMissing( + array $originalPayload, + array $graphOptions, + array $context, + string $policyId, + ): array { + if (! $this->shouldAttemptAutopilotCreate($policyId, $graphOptions)) { + return [ + 'attempted' => false, + 'success' => false, + 'policy_id' => null, + 'response' => null, + ]; + } + + $resource = $this->contracts->resourcePath('windowsAutopilotDeploymentProfile') + ?? 'deviceManagement/windowsAutopilotDeploymentProfiles'; + $payload = $this->contracts->sanitizeUpdatePayload('windowsAutopilotDeploymentProfile', $originalPayload); + + if ($payload === []) { + return [ + 'attempted' => true, + 'success' => false, + 'policy_id' => null, + 'response' => null, + ]; + } + + $this->graphLogger->logRequest('create_autopilot_profile', $context + [ + 'endpoint' => $resource, + 'method' => 'POST', + ]); + + $response = $this->graphClient->request( + 'POST', + $resource, + ['json' => $payload] + Arr::except($graphOptions, ['platform']) + ); + + $this->graphLogger->logResponse('create_autopilot_profile', $response, $context + [ + 'endpoint' => $resource, + 'method' => 'POST', + ]); + + $policyId = $this->extractCreatedPolicyId($response); + + return [ + 'attempted' => true, + 'success' => $response->successful(), + 'policy_id' => $policyId, + 'response' => $response, + ]; + } + + private function shouldAttemptAutopilotCreate(string $policyId, array $graphOptions): bool + { + $response = $this->graphClient->getPolicy( + 'windowsAutopilotDeploymentProfile', + $policyId, + $graphOptions + ); + + if ($response->successful()) { + return false; + } + + if ($response->status === 404) { + return true; + } + + $code = strtolower((string) ($response->meta['error_code'] ?? '')); + $message = strtolower((string) ($response->meta['error_message'] ?? '')); + + if (str_contains($code, 'notfound') || str_contains($code, 'resource')) { + return true; + } + + return str_contains($message, 'not found') + || str_contains($message, 'resource not found') + || str_contains($message, 'does not exist'); + } + private function shouldRetrySettingsCatalogCreateWithoutSettings(object $response): bool { $code = strtolower((string) ($response->meta['error_code'] ?? '')); diff --git a/config/graph_contracts.php b/config/graph_contracts.php index e760db8..d637985 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -258,11 +258,26 @@ 'allowed_expand' => [], 'type_family' => [ '#microsoft.graph.windowsAutopilotDeploymentProfile', + '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile', + '#microsoft.graph.activeDirectoryWindowsAutopilotDeploymentProfile', ], 'create_method' => 'POST', 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'strip_odata' => false, + 'update_strip_keys' => [ + 'assignments', + 'managementServiceAppId', + 'outOfBoxExperienceSetting', + 'hardwareHashExtractionEnabled', + 'locale', + ], + 'assignments_list_path' => '/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments', + 'assignments_create_method' => 'POST', + 'assignments_delete_path' => '/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', ], 'windowsEnrollmentStatusPage' => [ 'resource' => 'deviceManagement/deviceEnrollmentConfigurations', diff --git a/resources/views/filament/infolists/entries/restore-results.blade.php b/resources/views/filament/infolists/entries/restore-results.blade.php index 7adccee..6d288b9 100644 --- a/resources/views/filament/infolists/entries/restore-results.blade.php +++ b/resources/views/filament/infolists/entries/restore-results.blade.php @@ -237,9 +237,11 @@ @if (! empty($item['created_policy_id'])) @php $createdMode = $item['created_policy_mode'] ?? null; - $createdMessage = $createdMode === 'metadata_only' - ? 'New policy created (metadata only). Apply settings manually.' - : 'New policy created (manual cleanup required).'; + $createdMessage = match ($createdMode) { + 'metadata_only' => 'New policy created (metadata only). Apply settings manually.', + 'created' => 'New policy created.', + default => 'New policy created (manual cleanup required).', + }; @endphp
{{ $createdMessage }} ID: {{ $item['created_policy_id'] }} diff --git a/specs/007-device-config-compliance/tasks.md b/specs/007-device-config-compliance/tasks.md index 1843d9c..c84d79d 100644 --- a/specs/007-device-config-compliance/tasks.md +++ b/specs/007-device-config-compliance/tasks.md @@ -15,10 +15,10 @@ ## Phase 1: Policy Types, Contracts, Permissions **Purpose**: Add missing device configuration, compliance, scripts, and update ring types with Graph contract coverage. -- [ ] T001 [P] Expand policy type registry for device configuration, compliance, scripts, and update rings in `config/tenantpilot.php` (labels, categories, restore mode, risk). -- [ ] T002 [P] Add/update Graph contracts and assignment endpoints for new policy types in `config/graph_contracts.php`. -- [ ] T003 [P] Verify and extend permissions for the new workloads in `config/intune_permissions.php`. -- [ ] T004 Update type metadata helpers and filters in `app/Filament/Resources/PolicyResource.php` and `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`. +- [x] T001 [P] Expand policy type registry for device configuration, compliance, scripts, and update rings in `config/tenantpilot.php` (labels, categories, restore mode, risk). +- [x] T002 [P] Add/update Graph contracts and assignment endpoints for new policy types in `config/graph_contracts.php`. +- [x] T003 [P] Verify and extend permissions for the new workloads in `config/intune_permissions.php`. +- [x] T004 Update type metadata helpers and filters in `app/Filament/Resources/PolicyResource.php` and `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`. **Checkpoint**: New policy types are recognized across UI metadata and Graph contract registry. diff --git a/tests/Feature/Filament/RestoreExecutionTest.php b/tests/Feature/Filament/RestoreExecutionTest.php index e13c564..b9bf0d0 100644 --- a/tests/Feature/Filament/RestoreExecutionTest.php +++ b/tests/Feature/Filament/RestoreExecutionTest.php @@ -282,3 +282,109 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon 'resource_id' => (string) $run->id, ]); }); + +test('restore execution creates an autopilot profile when missing', function () { + $graphClient = new class implements GraphClientInterface + { + public int $applyCalls = 0; + + public int $getCalls = 0; + + public int $createCalls = 0; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + $this->getCalls++; + + return new GraphResponse(false, [], 404, [], [], [ + 'error_code' => 'ResourceNotFound', + 'error_message' => 'Resource not found.', + ]); + } + + 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, [], 500, [], [], [ + 'error_code' => 'InternalServerError', + 'error_message' => 'An internal server error has occurred.', + ]); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + if ($method === 'POST' && str_contains($path, 'windowsAutopilotDeploymentProfiles')) { + $this->createCalls++; + + return new GraphResponse(true, ['id' => 'autopilot-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-1', + 'name' => 'Tenant One', + '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' => 'autopilot-1', + 'policy_type' => 'windowsAutopilotDeploymentProfile', + 'platform' => 'windows', + 'payload' => [ + '@odata.type' => '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile', + 'displayName' => 'Autopilot Profile', + 'language' => 'en-US', + ], + ]) + ->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->getCalls)->toBe(1); + expect($graphClient->createCalls)->toBe(1); + expect($run->status)->toBe('completed'); + expect($run->results[0]['status'])->toBe('applied'); + expect($run->results[0]['created_policy_id'])->toBe('autopilot-created'); +}); diff --git a/tests/Unit/GraphContractRegistryActualDataTest.php b/tests/Unit/GraphContractRegistryActualDataTest.php index 0bb4065..813aecf 100644 --- a/tests/Unit/GraphContractRegistryActualDataTest.php +++ b/tests/Unit/GraphContractRegistryActualDataTest.php @@ -49,3 +49,47 @@ // Null values should be preserved (Graph might need them) expect(array_key_exists('settingValueTemplateReference', $sanitized[0]['settingInstance']['choiceSettingValue']))->toBeTrue(); }); + +it('exposes autopilot assignments paths', function () { + $contract = $this->registry->get('windowsAutopilotDeploymentProfile'); + + expect($contract)->not->toBeEmpty(); + expect($contract['assignments_list_path'] ?? null) + ->toBe('/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments'); + expect($contract['assignments_create_path'] ?? null) + ->toBe('/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments'); + expect($contract['assignments_delete_path'] ?? null) + ->toBe('/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments/{assignmentId}'); + expect($this->registry->matchesTypeFamily( + 'windowsAutopilotDeploymentProfile', + '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile' + ))->toBeTrue(); + expect($this->registry->matchesTypeFamily( + 'windowsAutopilotDeploymentProfile', + '#microsoft.graph.activeDirectoryWindowsAutopilotDeploymentProfile' + ))->toBeTrue(); +}); + +it('sanitizes autopilot update payload by stripping odata and assignments', function () { + $payload = [ + '@odata.type' => '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile', + 'id' => 'profile-1', + 'displayName' => 'Autopilot Profile', + 'assignments' => [['id' => 'assignment-1']], + 'managementServiceAppId' => 'service-app', + 'outOfBoxExperienceSetting' => ['deviceUsageType' => 'shared'], + 'hardwareHashExtractionEnabled' => true, + 'locale' => 'de-DE', + ]; + + $sanitized = $this->registry->sanitizeUpdatePayload('windowsAutopilotDeploymentProfile', $payload); + + expect($sanitized)->toHaveKey('displayName'); + expect($sanitized)->toHaveKey('@odata.type'); + expect($sanitized)->not->toHaveKey('id'); + expect($sanitized)->not->toHaveKey('assignments'); + expect($sanitized)->not->toHaveKey('managementServiceAppId'); + expect($sanitized)->not->toHaveKey('outOfBoxExperienceSetting'); + expect($sanitized)->not->toHaveKey('hardwareHashExtractionEnabled'); + expect($sanitized)->not->toHaveKey('locale'); +});