diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index dbf8384..eba5c33 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,6 +8,7 @@ use App\Services\Intune\AppProtectionPolicyNormalizer; use App\Services\Intune\CompliancePolicyNormalizer; use App\Services\Intune\DeviceConfigurationPolicyNormalizer; +use App\Services\Intune\EnrollmentAutopilotPolicyNormalizer; use App\Services\Intune\GroupPolicyConfigurationNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer; use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer; @@ -41,6 +42,7 @@ public function register(): void AppProtectionPolicyNormalizer::class, CompliancePolicyNormalizer::class, DeviceConfigurationPolicyNormalizer::class, + EnrollmentAutopilotPolicyNormalizer::class, GroupPolicyConfigurationNormalizer::class, SettingsCatalogPolicyNormalizer::class, WindowsFeatureUpdateProfileNormalizer::class, diff --git a/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php b/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php new file mode 100644 index 0000000..f84777c --- /dev/null +++ b/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php @@ -0,0 +1,230 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = is_array($snapshot) ? $snapshot : []; + + $displayName = Arr::get($snapshot, 'displayName') ?? Arr::get($snapshot, 'name'); + $description = Arr::get($snapshot, 'description'); + + $warnings = []; + + if ($policyType === 'enrollmentRestriction') { + $warnings[] = 'Restore is preview-only for Enrollment Restrictions.'; + } + + $generalEntries = [ + ['key' => 'Type', 'value' => $policyType], + ]; + + if (is_string($displayName) && $displayName !== '') { + $generalEntries[] = ['key' => 'Display name', 'value' => $displayName]; + } + + if (is_string($description) && $description !== '') { + $generalEntries[] = ['key' => 'Description', 'value' => $description]; + } + + $odataType = Arr::get($snapshot, '@odata.type'); + if (is_string($odataType) && $odataType !== '') { + $generalEntries[] = ['key' => '@odata.type', 'value' => $odataType]; + } + + $roleScopeTagIds = Arr::get($snapshot, 'roleScopeTagIds'); + if (is_array($roleScopeTagIds) && $roleScopeTagIds !== []) { + $generalEntries[] = ['key' => 'Scope tag IDs', 'value' => array_values($roleScopeTagIds)]; + } + + $settings = [ + [ + 'type' => 'keyValue', + 'title' => 'General', + 'entries' => $generalEntries, + ], + ]; + + $typeBlock = match ($policyType) { + 'windowsAutopilotDeploymentProfile' => $this->buildAutopilotBlock($snapshot), + 'windowsEnrollmentStatusPage' => $this->buildEnrollmentStatusPageBlock($snapshot), + 'enrollmentRestriction' => $this->buildEnrollmentRestrictionBlock($snapshot), + default => null, + }; + + if ($typeBlock !== null) { + $settings[] = $typeBlock; + } + + $settings = array_values(array_filter($settings)); + + return [ + 'status' => 'ok', + 'settings' => $settings, + 'warnings' => $warnings, + ]; + } + + /** + * @return array{type: string, title: string, entries: array}|null + */ + private function buildAutopilotBlock(array $snapshot): ?array + { + $entries = []; + + foreach ([ + 'deviceNameTemplate' => 'Device name template', + 'language' => 'Language', + 'locale' => 'Locale', + 'deploymentMode' => 'Deployment mode', + 'deviceType' => 'Device type', + 'enableWhiteGlove' => 'Pre-provisioning (White Glove)', + 'hybridAzureADJoinSkipConnectivityCheck' => 'Skip Hybrid AAD connectivity check', + ] as $key => $label) { + $value = Arr::get($snapshot, $key); + + if (is_string($value) && $value !== '') { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_bool($value)) { + $entries[] = ['key' => $label, 'value' => $value ? 'Enabled' : 'Disabled']; + } + } + + $oobe = Arr::get($snapshot, 'outOfBoxExperienceSettings'); + if (is_array($oobe) && $oobe !== []) { + $entries[] = ['key' => 'Out-of-box experience', 'value' => Arr::except($oobe, ['@odata.type'])]; + } + + $assignments = Arr::get($snapshot, 'assignments'); + if (is_array($assignments) && $assignments !== []) { + $entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]']; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Autopilot profile', + 'entries' => $entries, + ]; + } + + /** + * @return array{type: string, title: string, entries: array}|null + */ + private function buildEnrollmentStatusPageBlock(array $snapshot): ?array + { + $entries = []; + + foreach ([ + 'priority' => 'Priority', + 'showInstallationProgress' => 'Show installation progress', + 'blockDeviceSetupRetryByUser' => 'Block retry by user', + 'allowDeviceResetOnInstallFailure' => 'Allow device reset on install failure', + 'installProgressTimeoutInMinutes' => 'Install progress timeout (minutes)', + 'allowLogCollectionOnInstallFailure' => 'Allow log collection on failure', + ] as $key => $label) { + $value = Arr::get($snapshot, $key); + + if (is_int($value) || is_float($value)) { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_bool($value)) { + $entries[] = ['key' => $label, 'value' => $value ? 'Enabled' : 'Disabled']; + } + } + + $selected = Arr::get($snapshot, 'selectedMobileAppIds'); + if (is_array($selected) && $selected !== []) { + $entries[] = ['key' => 'Selected mobile app IDs', 'value' => array_values($selected)]; + } + + $assigned = Arr::get($snapshot, 'assignments'); + if (is_array($assigned) && $assigned !== []) { + $entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]']; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Enrollment Status Page (ESP)', + 'entries' => $entries, + ]; + } + + /** + * @return array{type: string, title: string, entries: array}|null + */ + private function buildEnrollmentRestrictionBlock(array $snapshot): ?array + { + $entries = []; + + foreach ([ + 'priority' => 'Priority', + 'version' => 'Version', + 'deviceEnrollmentConfigurationType' => 'Configuration type', + ] as $key => $label) { + $value = Arr::get($snapshot, $key); + + if (is_int($value) || is_float($value)) { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => $label, 'value' => $value]; + } + } + + $platforms = Arr::get($snapshot, 'platformRestrictions'); + if (is_array($platforms) && $platforms !== []) { + $entries[] = ['key' => 'Platform restrictions', 'value' => Arr::except($platforms, ['@odata.type'])]; + } + + $assigned = Arr::get($snapshot, 'assignments'); + if (is_array($assigned) && $assigned !== []) { + $entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]']; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Enrollment restrictions', + 'entries' => $entries, + ]; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $normalized = $this->normalize($snapshot ?? [], $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } +} diff --git a/specs/014-enrollment-autopilot/plan.md b/specs/014-enrollment-autopilot/plan.md new file mode 100644 index 0000000..a283ad3 --- /dev/null +++ b/specs/014-enrollment-autopilot/plan.md @@ -0,0 +1,48 @@ +# Plan: Enrollment & Autopilot (014) + +**Branch**: `014-enrollment-autopilot` +**Date**: 2026-01-01 +**Input**: [spec.md](./spec.md) + +## Goal +Provide end-to-end support for enrollment & Autopilot configuration items with readable normalized settings and safe restore behavior. + +## Scope + +### In scope +- Policy types: + - `windowsAutopilotDeploymentProfile` (restore enabled) + - `windowsEnrollmentStatusPage` (restore enabled) + - `enrollmentRestriction` (restore preview-only) +- Readable “Normalized settings” for the above types. +- Restore behavior: + - Autopilot/ESP: apply via existing restore mechanisms (create-if-missing allowed) + - Enrollment restrictions: must be skipped on execution by default (preview-only) +- Tests for normalization + UI rendering + preview-only enforcement. + +### Out of scope +- New restore wizard flows/pages. +- Enabling execution for enrollment restrictions (requires product decision). +- New external services. + +## Approach +1. Verify `config/graph_contracts.php` and `config/tenantpilot.php` entries for the three policy types. +2. Implement a new policy type normalizer to provide stable, enrollment-relevant blocks for: + - Autopilot deployment profiles + - Enrollment Status Page + - Enrollment restrictions +3. Register the normalizer with the `policy-type-normalizers` tag. +4. Add tests: + - Unit tests for normalized output stability/shape. + - Filament feature tests verifying “Normalized settings” renders for each type. + - Feature test verifying `enrollmentRestriction` restore is preview-only and skipped on execution. +5. Run targeted tests and Pint. + +## Risks & Mitigations +- Payload shape variance across tenants: normalizer must handle missing keys safely. +- Enrollment restrictions are high impact: execution must remain disabled by default (preview-only). + +## Success Criteria +- Normalized settings are stable and readable for all in-scope types. +- Restore execution skips preview-only types and reports clear result reasons. +- Tests cover normalization and preview-only enforcement. diff --git a/specs/014-enrollment-autopilot/tasks.md b/specs/014-enrollment-autopilot/tasks.md new file mode 100644 index 0000000..481bb85 --- /dev/null +++ b/specs/014-enrollment-autopilot/tasks.md @@ -0,0 +1,30 @@ +# Tasks: Enrollment & Autopilot (014) + +**Branch**: `014-enrollment-autopilot` | **Date**: 2026-01-01 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Contracts Review +- [x] T001 Verify `config/graph_contracts.php` entries for: + - `windowsAutopilotDeploymentProfile` + - `windowsEnrollmentStatusPage` + - `enrollmentRestriction` + (resource, type_family, create/update methods, assignment paths/payload keys) +- [x] T002 Verify `config/tenantpilot.php` entries and restore modes: + - Autopilot/ESP = `enabled` + - Enrollment restrictions = `preview-only` + +## Phase 2: UI Normalization +- [x] T003 Add an `EnrollmentAutopilotPolicyNormalizer` (or equivalent) that produces readable normalized settings for the three policy types. +- [x] T004 Register the normalizer in the app container/provider (tag `policy-type-normalizers`). + +## Phase 3: Restore Safety +- [x] T005 Add a feature test verifying `enrollmentRestriction` restore is preview-only and skipped on execution (no Graph apply calls). + +## Phase 4: Tests + Verification +- [ ] T006 Add unit tests for normalized output (shape + stability) for the three policy types. +- [ ] T007 Add Filament render tests for “Normalized settings” tab for the three policy types. +- [ ] T008 Run targeted tests. +- [ ] T009 Run Pint (`./vendor/bin/pint --dirty`). + +## Open TODOs (Follow-up) +- None. diff --git a/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php b/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php new file mode 100644 index 0000000..051fcb0 --- /dev/null +++ b/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php @@ -0,0 +1,111 @@ + []]); + } + + 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(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }; + + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-enrollment-restriction', + 'name' => 'Tenant Enrollment Restriction', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'enrollment-restriction-1', + 'policy_type' => 'enrollmentRestriction', + 'display_name' => 'Enrollment Restriction', + 'platform' => 'all', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Enrollment Restriction Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => [ + '@odata.type' => '#microsoft.graph.deviceEnrollmentConfiguration', + 'id' => $policy->external_id, + 'displayName' => $policy->display_name, + ], + ]); + + $service = app(RestoreService::class); + $preview = $service->preview($tenant, $backupSet, [$backupItem->id]); + + $previewItem = collect($preview)->first(fn (array $item) => ($item['policy_type'] ?? null) === 'enrollmentRestriction'); + + expect($previewItem)->not->toBeNull() + ->and($previewItem['restore_mode'] ?? null)->toBe('preview-only'); + + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: 'tester@example.com', + actorName: 'Tester', + ); + + expect($run->results)->toHaveCount(1); + expect($run->results[0]['status'])->toBe('skipped'); + expect($run->results[0]['reason'])->toBe('preview_only'); + + expect($client->applyCalls)->toBe(0); +});