014-enrollment-autopilot #20

Merged
ahmido merged 9 commits from 014-enrollment-autopilot into dev 2026-01-02 11:59:21 +00:00
5 changed files with 421 additions and 0 deletions
Showing only changes of commit 9b5bc7c09a - Show all commits

View File

@ -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,

View File

@ -0,0 +1,230 @@
<?php
namespace App\Services\Intune;
use Illuminate\Support\Arr;
class EnrollmentAutopilotPolicyNormalizer implements PolicyTypeNormalizer
{
public function __construct(private readonly DefaultPolicyNormalizer $defaultNormalizer) {}
public function supports(string $policyType): bool
{
return in_array($policyType, [
'windowsAutopilotDeploymentProfile',
'windowsEnrollmentStatusPage',
'enrollmentRestriction',
], true);
}
/**
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>}
*/
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<int, array{key: string, value: mixed}>}|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<int, array{key: string, value: mixed}>}|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<int, array{key: string, value: mixed}>}|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<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
$normalized = $this->normalize($snapshot ?? [], $policyType, $platform);
return $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
}
}

View File

@ -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.

View File

@ -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.

View File

@ -0,0 +1,111 @@
<?php
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\RestoreService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('enrollment restriction restores are preview-only and skipped on execution', function () {
$client = new class implements GraphClientInterface
{
public int $applyCalls = 0;
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(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);
});