014-enrollment-autopilot #20
162
app/Console/Commands/ReclassifyEnrollmentConfigurations.php
Normal file
162
app/Console/Commands/ReclassifyEnrollmentConfigurations.php
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class ReclassifyEnrollmentConfigurations extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'intune:reclassify-enrollment-configurations {--tenant=} {--write : Write changes (default is dry-run)}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Reclassify enrollment configuration items (e.g. ESP) that were synced under the wrong policy type.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function __construct(private readonly GraphClientInterface $graphClient)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$tenant = $this->resolveTenantOrNull();
|
||||||
|
$dryRun = ! (bool) $this->option('write');
|
||||||
|
|
||||||
|
$query = Policy::query()
|
||||||
|
->with(['tenant'])
|
||||||
|
->active()
|
||||||
|
->where('policy_type', 'enrollmentRestriction');
|
||||||
|
|
||||||
|
if ($tenant) {
|
||||||
|
$query->where('tenant_id', $tenant->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = $query->get();
|
||||||
|
|
||||||
|
$changedVersions = 0;
|
||||||
|
$changedPolicies = 0;
|
||||||
|
$ignoredPolicies = 0;
|
||||||
|
|
||||||
|
foreach ($candidates as $policy) {
|
||||||
|
$latestVersion = $policy->versions()->latest('version_number')->first();
|
||||||
|
$snapshot = $latestVersion?->snapshot;
|
||||||
|
|
||||||
|
if (! is_array($snapshot)) {
|
||||||
|
$snapshot = $this->fetchSnapshotOrNull($policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($snapshot)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->isEspSnapshot($snapshot)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(sprintf(
|
||||||
|
'ESP detected: policy=%s tenant_id=%s external_id=%s',
|
||||||
|
(string) $policy->getKey(),
|
||||||
|
(string) $policy->tenant_id,
|
||||||
|
(string) $policy->external_id,
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingTarget = Policy::query()
|
||||||
|
->where('tenant_id', $policy->tenant_id)
|
||||||
|
->where('external_id', $policy->external_id)
|
||||||
|
->where('policy_type', 'windowsEnrollmentStatusPage')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existingTarget) {
|
||||||
|
$policy->forceFill(['ignored_at' => now()])->save();
|
||||||
|
$ignoredPolicies++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$policy->forceFill([
|
||||||
|
'policy_type' => 'windowsEnrollmentStatusPage',
|
||||||
|
])->save();
|
||||||
|
$changedPolicies++;
|
||||||
|
|
||||||
|
$changedVersions += PolicyVersion::query()
|
||||||
|
->where('policy_id', $policy->id)
|
||||||
|
->where('policy_type', 'enrollmentRestriction')
|
||||||
|
->update(['policy_type' => 'windowsEnrollmentStatusPage']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Done.');
|
||||||
|
$this->info('PolicyVersions changed: '.$changedVersions);
|
||||||
|
$this->info('Policies changed: '.$changedPolicies);
|
||||||
|
$this->info('Policies ignored: '.$ignoredPolicies);
|
||||||
|
$this->info('Mode: '.($dryRun ? 'dry-run' : 'write'));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isEspSnapshot(array $snapshot): bool
|
||||||
|
{
|
||||||
|
$odataType = $snapshot['@odata.type'] ?? null;
|
||||||
|
$configurationType = $snapshot['deviceEnrollmentConfigurationType'] ?? null;
|
||||||
|
|
||||||
|
return (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration') === 0)
|
||||||
|
|| (is_string($configurationType) && $configurationType === 'windows10EnrollmentCompletionPageConfiguration');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchSnapshotOrNull(Policy $policy): ?array
|
||||||
|
{
|
||||||
|
$tenant = $policy->tenant;
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
||||||
|
|
||||||
|
$response = $this->graphClient->getPolicy('enrollmentRestriction', $policy->external_id, [
|
||||||
|
'tenant' => $tenantIdentifier,
|
||||||
|
'client_id' => $tenant->app_client_id,
|
||||||
|
'client_secret' => $tenant->app_client_secret,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $response->data['payload'] ?? null;
|
||||||
|
|
||||||
|
return is_array($payload) ? $payload : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTenantOrNull(): ?Tenant
|
||||||
|
{
|
||||||
|
$tenantOption = $this->option('tenant');
|
||||||
|
|
||||||
|
if (! $tenantOption) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Tenant::query()
|
||||||
|
->forTenant($tenantOption)
|
||||||
|
->firstOrFail();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@
|
|||||||
use App\Services\Intune\AppProtectionPolicyNormalizer;
|
use App\Services\Intune\AppProtectionPolicyNormalizer;
|
||||||
use App\Services\Intune\CompliancePolicyNormalizer;
|
use App\Services\Intune\CompliancePolicyNormalizer;
|
||||||
use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
|
use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
|
||||||
|
use App\Services\Intune\EnrollmentAutopilotPolicyNormalizer;
|
||||||
use App\Services\Intune\GroupPolicyConfigurationNormalizer;
|
use App\Services\Intune\GroupPolicyConfigurationNormalizer;
|
||||||
use App\Services\Intune\ScriptsPolicyNormalizer;
|
use App\Services\Intune\ScriptsPolicyNormalizer;
|
||||||
use App\Services\Intune\SettingsCatalogPolicyNormalizer;
|
use App\Services\Intune\SettingsCatalogPolicyNormalizer;
|
||||||
@ -42,6 +43,7 @@ public function register(): void
|
|||||||
AppProtectionPolicyNormalizer::class,
|
AppProtectionPolicyNormalizer::class,
|
||||||
CompliancePolicyNormalizer::class,
|
CompliancePolicyNormalizer::class,
|
||||||
DeviceConfigurationPolicyNormalizer::class,
|
DeviceConfigurationPolicyNormalizer::class,
|
||||||
|
EnrollmentAutopilotPolicyNormalizer::class,
|
||||||
GroupPolicyConfigurationNormalizer::class,
|
GroupPolicyConfigurationNormalizer::class,
|
||||||
ScriptsPolicyNormalizer::class,
|
ScriptsPolicyNormalizer::class,
|
||||||
SettingsCatalogPolicyNormalizer::class,
|
SettingsCatalogPolicyNormalizer::class,
|
||||||
|
|||||||
331
app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php
Normal file
331
app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Intune;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
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 !== []) {
|
||||||
|
$oobe = Arr::except($oobe, ['@odata.type']);
|
||||||
|
|
||||||
|
foreach ($this->expandOutOfBoxExperienceEntries($oobe) as $entry) {
|
||||||
|
$entries[] = $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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<int, array{key: string, value: mixed}>
|
||||||
|
*/
|
||||||
|
private function expandOutOfBoxExperienceEntries(array $oobe): array
|
||||||
|
{
|
||||||
|
$knownKeys = [
|
||||||
|
'hideEULA' => 'Hide EULA',
|
||||||
|
'userType' => 'User type',
|
||||||
|
'hideEscapeLink' => 'Hide escape link',
|
||||||
|
'deviceUsageType' => 'Device usage type',
|
||||||
|
'hidePrivacySettings' => 'Hide privacy settings',
|
||||||
|
'skipKeyboardSelectionPage' => 'Skip keyboard selection page',
|
||||||
|
'skipExpressSettings' => 'Skip express settings',
|
||||||
|
];
|
||||||
|
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
foreach ($knownKeys as $key => $label) {
|
||||||
|
if (! array_key_exists($key, $oobe)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $oobe[$key];
|
||||||
|
|
||||||
|
if (is_bool($value)) {
|
||||||
|
$entries[] = ['key' => "OOBE: {$label}", 'value' => $value ? 'Enabled' : 'Disabled'];
|
||||||
|
} elseif (is_string($value) && $value !== '') {
|
||||||
|
$entries[] = ['key' => "OOBE: {$label}", 'value' => $value];
|
||||||
|
} elseif (is_int($value) || is_float($value)) {
|
||||||
|
$entries[] = ['key' => "OOBE: {$label}", 'value' => $value];
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($oobe[$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($oobe as $key => $value) {
|
||||||
|
$label = Str::headline((string) $key);
|
||||||
|
|
||||||
|
if (is_bool($value)) {
|
||||||
|
$entries[] = ['key' => "OOBE: {$label}", 'value' => $value ? 'Enabled' : 'Disabled'];
|
||||||
|
} elseif (is_string($value) && $value !== '') {
|
||||||
|
$entries[] = ['key' => "OOBE: {$label}", 'value' => $value];
|
||||||
|
} elseif (is_int($value) || is_float($value)) {
|
||||||
|
$entries[] = ['key' => "OOBE: {$label}", 'value' => $value];
|
||||||
|
} elseif (is_array($value) && $value !== []) {
|
||||||
|
$entries[] = ['key' => "OOBE: {$label}", 'value' => $value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$platformRestrictions = Arr::get($snapshot, 'platformRestrictions');
|
||||||
|
$platformRestriction = Arr::get($snapshot, 'platformRestriction');
|
||||||
|
|
||||||
|
$platformPayload = is_array($platformRestrictions) && $platformRestrictions !== []
|
||||||
|
? $platformRestrictions
|
||||||
|
: (is_array($platformRestriction) ? $platformRestriction : null);
|
||||||
|
|
||||||
|
if (is_array($platformPayload) && $platformPayload !== []) {
|
||||||
|
$platformPayload = Arr::except($platformPayload, ['@odata.type']);
|
||||||
|
|
||||||
|
$platformBlocked = Arr::get($platformPayload, 'platformBlocked');
|
||||||
|
if (is_bool($platformBlocked)) {
|
||||||
|
$entries[] = ['key' => 'Platform blocked', 'value' => $platformBlocked ? 'Enabled' : 'Disabled'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$personalBlocked = Arr::get($platformPayload, 'personalDeviceEnrollmentBlocked');
|
||||||
|
if (is_bool($personalBlocked)) {
|
||||||
|
$entries[] = ['key' => 'Personal device enrollment blocked', 'value' => $personalBlocked ? 'Enabled' : 'Disabled'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$osMin = Arr::get($platformPayload, 'osMinimumVersion');
|
||||||
|
$entries[] = [
|
||||||
|
'key' => 'OS minimum version',
|
||||||
|
'value' => (is_string($osMin) && $osMin !== '') ? $osMin : 'None',
|
||||||
|
];
|
||||||
|
|
||||||
|
$osMax = Arr::get($platformPayload, 'osMaximumVersion');
|
||||||
|
$entries[] = [
|
||||||
|
'key' => 'OS maximum version',
|
||||||
|
'value' => (is_string($osMax) && $osMax !== '') ? $osMax : 'None',
|
||||||
|
];
|
||||||
|
|
||||||
|
$blockedManufacturers = Arr::get($platformPayload, 'blockedManufacturers');
|
||||||
|
$entries[] = [
|
||||||
|
'key' => 'Blocked manufacturers',
|
||||||
|
'value' => (is_array($blockedManufacturers) && $blockedManufacturers !== [])
|
||||||
|
? array_values($blockedManufacturers)
|
||||||
|
: ['None'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$blockedSkus = Arr::get($platformPayload, 'blockedSkus');
|
||||||
|
$entries[] = [
|
||||||
|
'key' => 'Blocked SKUs',
|
||||||
|
'value' => (is_array($blockedSkus) && $blockedSkus !== [])
|
||||||
|
? array_values($blockedSkus)
|
||||||
|
: ['None'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Services\Intune;
|
namespace App\Services\Intune;
|
||||||
|
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Graph\GraphErrorMapper;
|
use App\Services\Graph\GraphErrorMapper;
|
||||||
@ -78,6 +79,12 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$canonicalPolicyType = $this->resolveCanonicalPolicyType($policyType, $policyData);
|
||||||
|
|
||||||
|
if ($canonicalPolicyType !== $policyType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if ($policyType === 'appProtectionPolicy') {
|
if ($policyType === 'appProtectionPolicy') {
|
||||||
$odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
|
$odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
|
||||||
|
|
||||||
@ -96,15 +103,11 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
|
|||||||
$displayName = $policyData['displayName'] ?? $policyData['name'] ?? 'Unnamed policy';
|
$displayName = $policyData['displayName'] ?? $policyData['name'] ?? 'Unnamed policy';
|
||||||
$policyPlatform = $platform ?? ($policyData['platform'] ?? null);
|
$policyPlatform = $platform ?? ($policyData['platform'] ?? null);
|
||||||
|
|
||||||
$existingWithDifferentType = Policy::query()
|
$this->reclassifyEnrollmentConfigurationPoliciesIfNeeded(
|
||||||
->where('tenant_id', $tenant->id)
|
tenantId: $tenant->id,
|
||||||
->where('external_id', $externalId)
|
externalId: $externalId,
|
||||||
->where('policy_type', '!=', $policyType)
|
policyType: $policyType,
|
||||||
->exists();
|
);
|
||||||
|
|
||||||
if ($existingWithDifferentType) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$policy = Policy::updateOrCreate(
|
$policy = Policy::updateOrCreate(
|
||||||
[
|
[
|
||||||
@ -128,6 +131,106 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
|
|||||||
return $synced;
|
return $synced;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveCanonicalPolicyType(string $policyType, array $policyData): string
|
||||||
|
{
|
||||||
|
if (! in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) {
|
||||||
|
return $policyType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isEnrollmentStatusPageItem($policyData)) {
|
||||||
|
return 'windowsEnrollmentStatusPage';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isEnrollmentRestrictionItem($policyData)) {
|
||||||
|
return 'enrollmentRestriction';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $policyType;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isEnrollmentStatusPageItem(array $policyData): bool
|
||||||
|
{
|
||||||
|
$odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
|
||||||
|
$configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null;
|
||||||
|
|
||||||
|
return (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration') === 0)
|
||||||
|
|| (is_string($configurationType) && $configurationType === 'windows10EnrollmentCompletionPageConfiguration');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isEnrollmentRestrictionItem(array $policyData): bool
|
||||||
|
{
|
||||||
|
$odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
|
||||||
|
$configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null;
|
||||||
|
|
||||||
|
$restrictionOdataTypes = [
|
||||||
|
'#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration',
|
||||||
|
'#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration',
|
||||||
|
'#microsoft.graph.deviceEnrollmentLimitConfiguration',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (is_string($odataType)) {
|
||||||
|
foreach ($restrictionOdataTypes as $expected) {
|
||||||
|
if (strcasecmp($odataType, $expected) === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_string($configurationType)
|
||||||
|
&& in_array($configurationType, [
|
||||||
|
'deviceEnrollmentPlatformRestrictionConfiguration',
|
||||||
|
'deviceEnrollmentPlatformRestrictionsConfiguration',
|
||||||
|
'deviceEnrollmentLimitConfiguration',
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void
|
||||||
|
{
|
||||||
|
if (! in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$enrollmentTypes = ['enrollmentRestriction', 'windowsEnrollmentStatusPage'];
|
||||||
|
|
||||||
|
$existingCorrect = Policy::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('external_id', $externalId)
|
||||||
|
->where('policy_type', $policyType)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existingCorrect) {
|
||||||
|
Policy::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('external_id', $externalId)
|
||||||
|
->whereIn('policy_type', $enrollmentTypes)
|
||||||
|
->where('policy_type', '!=', $policyType)
|
||||||
|
->whereNull('ignored_at')
|
||||||
|
->update(['ignored_at' => now()]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingWrong = Policy::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('external_id', $externalId)
|
||||||
|
->whereIn('policy_type', $enrollmentTypes)
|
||||||
|
->where('policy_type', '!=', $policyType)
|
||||||
|
->whereNull('ignored_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $existingWrong) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingWrong->forceFill([
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
PolicyVersion::query()
|
||||||
|
->where('policy_id', $existingWrong->id)
|
||||||
|
->update(['policy_type' => $policyType]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-fetch a single policy from Graph and update local metadata.
|
* Re-fetch a single policy from Graph and update local metadata.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -340,8 +340,9 @@
|
|||||||
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'],
|
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'],
|
||||||
'allowed_expand' => [],
|
'allowed_expand' => [],
|
||||||
'type_family' => [
|
'type_family' => [
|
||||||
'#microsoft.graph.deviceEnrollmentConfiguration',
|
'#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration',
|
||||||
'#microsoft.graph.windows10EnrollmentCompletionPageConfiguration',
|
'#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration',
|
||||||
|
'#microsoft.graph.deviceEnrollmentLimitConfiguration',
|
||||||
],
|
],
|
||||||
'create_method' => 'POST',
|
'create_method' => 'POST',
|
||||||
'update_method' => 'PATCH',
|
'update_method' => 'PATCH',
|
||||||
|
|||||||
@ -124,16 +124,6 @@
|
|||||||
'restore' => 'enabled',
|
'restore' => 'enabled',
|
||||||
'risk' => 'medium',
|
'risk' => 'medium',
|
||||||
],
|
],
|
||||||
[
|
|
||||||
'type' => 'enrollmentRestriction',
|
|
||||||
'label' => 'Enrollment Restrictions',
|
|
||||||
'category' => 'Enrollment',
|
|
||||||
'platform' => 'all',
|
|
||||||
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations',
|
|
||||||
'backup' => 'full',
|
|
||||||
'restore' => 'preview-only',
|
|
||||||
'risk' => 'high',
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
'type' => 'windowsAutopilotDeploymentProfile',
|
'type' => 'windowsAutopilotDeploymentProfile',
|
||||||
'label' => 'Windows Autopilot Profiles',
|
'label' => 'Windows Autopilot Profiles',
|
||||||
@ -155,6 +145,16 @@
|
|||||||
'restore' => 'enabled',
|
'restore' => 'enabled',
|
||||||
'risk' => 'medium',
|
'risk' => 'medium',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'type' => 'enrollmentRestriction',
|
||||||
|
'label' => 'Enrollment Restrictions',
|
||||||
|
'category' => 'Enrollment',
|
||||||
|
'platform' => 'all',
|
||||||
|
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations',
|
||||||
|
'backup' => 'full',
|
||||||
|
'restore' => 'preview-only',
|
||||||
|
'risk' => 'high',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'type' => 'endpointSecurityIntent',
|
'type' => 'endpointSecurityIntent',
|
||||||
'label' => 'Endpoint Security Intents',
|
'label' => 'Endpoint Security Intents',
|
||||||
|
|||||||
@ -7,7 +7,58 @@
|
|||||||
$warnings = $state['warnings'] ?? [];
|
$warnings = $state['warnings'] ?? [];
|
||||||
$settings = $state['settings'] ?? [];
|
$settings = $state['settings'] ?? [];
|
||||||
$settingsTable = $state['settings_table'] ?? null;
|
$settingsTable = $state['settings_table'] ?? null;
|
||||||
|
|
||||||
$policyType = $state['policy_type'] ?? null;
|
$policyType = $state['policy_type'] ?? null;
|
||||||
|
|
||||||
|
$stringifyValue = function (mixed $value): string {
|
||||||
|
if (is_null($value)) {
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return $value ? 'Enabled' : 'Disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_scalar($value)) {
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
return is_string($encoded) ? $encoded : 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_object($value)) {
|
||||||
|
if (method_exists($value, '__toString')) {
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$encoded = json_encode((array) $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
return is_string($encoded) ? $encoded : 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'N/A';
|
||||||
|
};
|
||||||
|
|
||||||
|
$shouldRenderBadges = function (mixed $value): bool {
|
||||||
|
if (! is_array($value) || $value === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! array_is_list($value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($value as $item) {
|
||||||
|
if (! is_scalar($item) && ! is_null($item)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@ -99,16 +150,17 @@
|
|||||||
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white">
|
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
{{ $row['value'] }}
|
{{ $row['value'] }}
|
||||||
</span>
|
</span>
|
||||||
|
@elseif($shouldRenderBadges($row['value'] ?? null))
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
@foreach(($row['value'] ?? []) as $item)
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
{{ is_bool($item) ? ($item ? 'Enabled' : 'Disabled') : (string) $item }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
@else
|
@else
|
||||||
@php
|
|
||||||
$value = $row['value'] ?? 'N/A';
|
|
||||||
|
|
||||||
if (is_array($value) || is_object($value)) {
|
|
||||||
$value = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
||||||
}
|
|
||||||
@endphp
|
|
||||||
<span class="text-sm text-gray-900 dark:text-white break-words">
|
<span class="text-sm text-gray-900 dark:text-white break-words">
|
||||||
{{ Str::limit((string) $value, 200) }}
|
{{ Str::limit($stringifyValue($row['value'] ?? null), 200) }}
|
||||||
</span>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
</dd>
|
</dd>
|
||||||
@ -136,18 +188,15 @@
|
|||||||
</dt>
|
</dt>
|
||||||
<dd class="mt-1 sm:mt-0 sm:col-span-2">
|
<dd class="mt-1 sm:mt-0 sm:col-span-2">
|
||||||
@php
|
@php
|
||||||
$value = $entry['value'] ?? 'N/A';
|
$rawValue = $entry['value'] ?? null;
|
||||||
|
|
||||||
$isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true)
|
$isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true)
|
||||||
&& (bool) config('tenantpilot.display.show_script_content', false);
|
&& (bool) config('tenantpilot.display.show_script_content', false);
|
||||||
|
|
||||||
if (is_array($value) || is_object($value)) {
|
|
||||||
$value = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
||||||
}
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if($isScriptContent)
|
@if($isScriptContent)
|
||||||
@php
|
@php
|
||||||
$code = (string) $value;
|
$code = is_string($rawValue) ? $rawValue : $stringifyValue($rawValue);
|
||||||
$firstLine = strtok($code, "\n") ?: '';
|
$firstLine = strtok($code, "\n") ?: '';
|
||||||
|
|
||||||
$grammar = 'powershell';
|
$grammar = 'powershell';
|
||||||
@ -219,9 +268,17 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@elseif($shouldRenderBadges($rawValue))
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
@foreach(($rawValue ?? []) as $item)
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
{{ is_bool($item) ? ($item ? 'Enabled' : 'Disabled') : (string) $item }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
@else
|
@else
|
||||||
<span class="text-sm text-gray-900 dark:text-white break-words">
|
<span class="text-sm text-gray-900 dark:text-white break-words">
|
||||||
{{ Str::limit((string) $value, 200) }}
|
{{ Str::limit($stringifyValue($rawValue), 200) }}
|
||||||
</span>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
</dd>
|
</dd>
|
||||||
|
|||||||
34
specs/014-enrollment-autopilot/checklists/requirements.md
Normal file
34
specs/014-enrollment-autopilot/checklists/requirements.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: Enrollment & Autopilot
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-01-01
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Assumptions: Restore behavior for enrollment restrictions remains preview-only until a separate product decision explicitly enables it.
|
||||||
48
specs/014-enrollment-autopilot/plan.md
Normal file
48
specs/014-enrollment-autopilot/plan.md
Normal 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.
|
||||||
111
specs/014-enrollment-autopilot/spec.md
Normal file
111
specs/014-enrollment-autopilot/spec.md
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
# Feature Specification: Enrollment & Autopilot
|
||||||
|
|
||||||
|
**Feature Branch**: `014-enrollment-autopilot`
|
||||||
|
**Created**: 2026-01-01
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Improve enrollment and Autopilot configuration safety by adding readable normalized settings, reliable snapshot capture, and safe restore behavior for enrollment restrictions, enrollment status page, and Autopilot deployment profiles."
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
<!--
|
||||||
|
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
|
||||||
|
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
|
||||||
|
you should still have a viable MVP (Minimum Viable Product) that delivers value.
|
||||||
|
|
||||||
|
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
|
||||||
|
Think of each story as a standalone slice of functionality that can be:
|
||||||
|
- Developed independently
|
||||||
|
- Tested independently
|
||||||
|
- Deployed independently
|
||||||
|
- Demonstrated to users independently
|
||||||
|
-->
|
||||||
|
|
||||||
|
### User Story 1 - Restore Autopilot/ESP safely (Priority: P1)
|
||||||
|
|
||||||
|
As an admin, I want to restore Autopilot deployment profiles and the Enrollment Status Page configuration from saved snapshots so I can recover enrollment readiness after changes.
|
||||||
|
|
||||||
|
**Why this priority**: Enrollment misconfiguration blocks device onboarding; fast recovery is critical.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by restoring one Autopilot profile and one Enrollment Status Page item from snapshots into a target tenant and verifying they match the snapshot.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a saved Autopilot deployment profile snapshot and a target tenant where the profile is missing, **When** I restore it, **Then** a new profile is created and restore reports success.
|
||||||
|
2. **Given** a saved Enrollment Status Page snapshot and a target tenant where the item exists with differences, **When** I restore it, **Then** the configuration is updated to match the snapshot and restore reports success.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Restore behavior is explicit for high-risk enrollment restrictions (Priority: P2)
|
||||||
|
|
||||||
|
As an admin, I want high-risk enrollment restrictions to be handled explicitly (preview-only unless intentionally enabled) so I do not accidentally break enrollment flows.
|
||||||
|
|
||||||
|
**Why this priority**: Enrollment restrictions can lock out device onboarding; accidental changes are high impact.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by attempting restore of an enrollment restriction item and verifying the system does not apply changes when it is configured as preview-only.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an enrollment restriction snapshot and the feature is allowed for preview-only, **When** I run restore execution, **Then** the system skips applying changes and records a result indicating preview-only behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Readable normalized settings (Priority: P3)
|
||||||
|
|
||||||
|
As an admin, I want to view readable normalized settings for Autopilot and Enrollment configurations so I can understand what will happen during device onboarding.
|
||||||
|
|
||||||
|
**Why this priority**: Enrollment troubleshooting is faster when key settings are visible and consistent.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by opening a version details page and confirming a stable normalized settings view is present and readable.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a saved Autopilot/ESP snapshot, **When** I view the policy version, **Then** I see a normalized settings view that highlights key enrollment-relevant fields.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[Add more user stories as needed, each with an assigned priority]
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- Autopilot or ESP configuration in the target tenant is missing: system must create or clearly fail with an actionable reason.
|
||||||
|
- Restoring Enrollment Status Page items must not silently drop settings; failures must be explicit.
|
||||||
|
- Enrollment restrictions remain preview-only unless explicitly enabled by product decision; execution must not apply them by default.
|
||||||
|
- Assignments (if present for these types) that cannot be mapped must be reported as manual-required.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: The content in this section represents placeholders.
|
||||||
|
Fill them out with the right functional requirements.
|
||||||
|
-->
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUST support listing and viewing enrollment and Autopilot configuration items for the supported types.
|
||||||
|
- **FR-002**: System MUST capture snapshots for these configuration items that are sufficient for later restore.
|
||||||
|
- **FR-003**: System MUST support restore for Autopilot deployment profiles and Enrollment Status Page configuration.
|
||||||
|
- **FR-004**: System MUST treat enrollment restrictions as high risk and default them to preview-only behavior unless explicitly enabled.
|
||||||
|
- **FR-005**: System MUST present a readable normalized settings view for these configuration items and their versions.
|
||||||
|
- **FR-006**: System MUST prevent restore execution if the snapshot type does not match the target item type.
|
||||||
|
- **FR-007**: System MUST record audit entries for restore preview and restore execution attempts.
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Autopilot Deployment Profile**: A configuration object that defines device provisioning behavior during Autopilot.
|
||||||
|
- **Enrollment Status Page Configuration**: A configuration object that defines the onboarding status experience during enrollment.
|
||||||
|
- **Enrollment Restriction**: A high-risk configuration object that can block or constrain enrollment.
|
||||||
|
- **Snapshot**: An immutable capture of a configuration object at a point in time.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: Define measurable success criteria.
|
||||||
|
These must be technology-agnostic and measurable.
|
||||||
|
-->
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: An admin can complete a restore preview for a single Autopilot/ESP item in under 1 minute.
|
||||||
|
- **SC-002**: In a test tenant, restoring Autopilot deployment profiles and Enrollment Status Page results in configurations matching the snapshot for 100% of supported items.
|
||||||
|
- **SC-003**: Enrollment restrictions remain non-executable by default (preview-only) with clear status reporting in 100% of attempts.
|
||||||
|
- **SC-004**: Normalized settings views for these items are stable and readable (same snapshot yields identical normalized output).
|
||||||
33
specs/014-enrollment-autopilot/tasks.md
Normal file
33
specs/014-enrollment-autopilot/tasks.md
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# 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 3b: Enrollment Configuration Type Collisions
|
||||||
|
- [x] T005b Fix ESP vs enrollment restriction collision on `deviceEnrollmentConfigurations` sync (canonical type resolution + safe reclassification).
|
||||||
|
|
||||||
|
## Phase 4: Tests + Verification
|
||||||
|
- [x] T006 Add unit tests for normalized output (shape + stability) for the three policy types.
|
||||||
|
- [x] T007 Add Filament render tests for “Normalized settings” tab for the three policy types.
|
||||||
|
- [x] T008 Run targeted tests.
|
||||||
|
- [x] T009 Run Pint (`./vendor/bin/pint --dirty`).
|
||||||
|
|
||||||
|
## Open TODOs (Follow-up)
|
||||||
|
- None.
|
||||||
@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\PolicyResource;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'local-tenant',
|
||||||
|
'name' => 'Tenant One',
|
||||||
|
'metadata' => [],
|
||||||
|
'is_current' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$this->tenant = $tenant;
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('policy detail renders normalized settings for Autopilot profiles', function () {
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $this->tenant->id,
|
||||||
|
'external_id' => 'autopilot-1',
|
||||||
|
'policy_type' => 'windowsAutopilotDeploymentProfile',
|
||||||
|
'display_name' => 'Autopilot Profile A',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::create([
|
||||||
|
'tenant_id' => $this->tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'created_by' => 'tester@example.com',
|
||||||
|
'captured_at' => CarbonImmutable::now(),
|
||||||
|
'snapshot' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile',
|
||||||
|
'displayName' => 'Autopilot Profile A',
|
||||||
|
'deviceNameTemplate' => 'DEV-%SERIAL%',
|
||||||
|
'enableWhiteGlove' => true,
|
||||||
|
'outOfBoxExperienceSettings' => [
|
||||||
|
'hideEULA' => true,
|
||||||
|
'userType' => 'standard',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->get(PolicyResource::getUrl('view', ['record' => $policy]));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertSee('Settings');
|
||||||
|
$response->assertSee('Autopilot profile');
|
||||||
|
$response->assertSee('Device name template');
|
||||||
|
$response->assertSee('DEV-%SERIAL%');
|
||||||
|
$response->assertSee('Pre-provisioning (White Glove)');
|
||||||
|
$response->assertSee('Enabled');
|
||||||
|
$response->assertSee('OOBE: Hide EULA');
|
||||||
|
$response->assertSee('OOBE: User type');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('policy detail renders normalized settings for Enrollment Status Page (ESP)', function () {
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $this->tenant->id,
|
||||||
|
'external_id' => 'esp-1',
|
||||||
|
'policy_type' => 'windowsEnrollmentStatusPage',
|
||||||
|
'display_name' => 'ESP A',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::create([
|
||||||
|
'tenant_id' => $this->tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'created_by' => 'tester@example.com',
|
||||||
|
'captured_at' => CarbonImmutable::now(),
|
||||||
|
'snapshot' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.windowsEnrollmentStatusPageConfiguration',
|
||||||
|
'displayName' => 'ESP A',
|
||||||
|
'priority' => 1,
|
||||||
|
'showInstallationProgress' => true,
|
||||||
|
'installProgressTimeoutInMinutes' => 60,
|
||||||
|
'selectedMobileAppIds' => ['app-1', 'app-2'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->get(PolicyResource::getUrl('view', ['record' => $policy]));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertSee('Settings');
|
||||||
|
$response->assertSee('Enrollment Status Page (ESP)');
|
||||||
|
$response->assertSee('Priority');
|
||||||
|
$response->assertSee('1');
|
||||||
|
$response->assertSee('Show installation progress');
|
||||||
|
$response->assertSee('Enabled');
|
||||||
|
$response->assertSee('Selected mobile app IDs');
|
||||||
|
$response->assertSee('app-1');
|
||||||
|
$response->assertSee('app-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('policy detail renders normalized settings for enrollment restrictions', function () {
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $this->tenant->id,
|
||||||
|
'external_id' => 'enroll-restrict-1',
|
||||||
|
'policy_type' => 'enrollmentRestriction',
|
||||||
|
'display_name' => 'Restriction A',
|
||||||
|
'platform' => 'all',
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::create([
|
||||||
|
'tenant_id' => $this->tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'created_by' => 'tester@example.com',
|
||||||
|
'captured_at' => CarbonImmutable::now(),
|
||||||
|
'snapshot' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration',
|
||||||
|
'displayName' => 'Restriction A',
|
||||||
|
'deviceEnrollmentConfigurationType' => 'deviceEnrollmentPlatformRestrictionConfiguration',
|
||||||
|
'platformRestriction' => [
|
||||||
|
'platformBlocked' => false,
|
||||||
|
'personalDeviceEnrollmentBlocked' => true,
|
||||||
|
'blockedSkus' => ['sku-1'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->get(PolicyResource::getUrl('view', ['record' => $policy]));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertSee('Settings');
|
||||||
|
$response->assertSee('Enrollment restrictions');
|
||||||
|
$response->assertSee('Personal device enrollment blocked');
|
||||||
|
$response->assertSee('Enabled');
|
||||||
|
$response->assertSee('Blocked SKUs');
|
||||||
|
$response->assertSee('sku-1');
|
||||||
|
});
|
||||||
111
tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php
Normal file
111
tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php
Normal 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);
|
||||||
|
});
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\PolicyResource;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('policy settings standard view renders array values without crashing', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-arrays',
|
||||||
|
'name' => 'Tenant Arrays',
|
||||||
|
'metadata' => [],
|
||||||
|
'is_current' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'policy-arrays-1',
|
||||||
|
'policy_type' => 'windowsAutopilotDeploymentProfile',
|
||||||
|
'display_name' => 'Autopilot Policy With Arrays',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'created_by' => 'tester@example.com',
|
||||||
|
'captured_at' => CarbonImmutable::now(),
|
||||||
|
'snapshot' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.windowsAutopilotDeploymentProfile',
|
||||||
|
'displayName' => 'Autopilot Policy With Arrays',
|
||||||
|
'roleScopeTagIds' => ['0', '1'],
|
||||||
|
'outOfBoxExperienceSettings' => [
|
||||||
|
'hideEULA' => true,
|
||||||
|
'userType' => 'standard',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)
|
||||||
|
->get(PolicyResource::getUrl('view', ['record' => $policy]).'?tab=settings');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertSee('Settings');
|
||||||
|
$response->assertSee('Scope tag IDs');
|
||||||
|
$response->assertSee('0');
|
||||||
|
$response->assertSee('1');
|
||||||
|
$response->assertSee('OOBE: Hide EULA');
|
||||||
|
$response->assertSee('OOBE: User type');
|
||||||
|
$response->assertSee('standard');
|
||||||
|
});
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use App\Services\Intune\PolicySyncService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('policy sync does not let enrollmentRestriction claim ESP items and reclassifies existing wrong rows', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-sync-collision',
|
||||||
|
'name' => 'Tenant Sync Collision',
|
||||||
|
'metadata' => [],
|
||||||
|
'is_current' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
// Simulate an older bug: ESP row was synced under enrollmentRestriction.
|
||||||
|
$wrong = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'esp-1',
|
||||||
|
'policy_type' => 'enrollmentRestriction',
|
||||||
|
'display_name' => 'ESP Misclassified',
|
||||||
|
'platform' => 'all',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->mock(GraphClientInterface::class, function (MockInterface $mock) {
|
||||||
|
$espPayload = [
|
||||||
|
'id' => 'esp-1',
|
||||||
|
'displayName' => 'Enrollment Status Page',
|
||||||
|
'@odata.type' => '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration',
|
||||||
|
'deviceEnrollmentConfigurationType' => 'windows10EnrollmentCompletionPageConfiguration',
|
||||||
|
];
|
||||||
|
|
||||||
|
$mock->shouldReceive('listPolicies')
|
||||||
|
->andReturnUsing(function (string $policyType) use ($espPayload) {
|
||||||
|
if ($policyType === 'enrollmentRestriction') {
|
||||||
|
// Shared endpoint can return ESP items if unfiltered.
|
||||||
|
return new GraphResponse(true, [$espPayload]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($policyType === 'windowsEnrollmentStatusPage') {
|
||||||
|
return new GraphResponse(true, [$espPayload]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$service = app(PolicySyncService::class);
|
||||||
|
|
||||||
|
$service->syncPolicies($tenant, [
|
||||||
|
[
|
||||||
|
'type' => 'enrollmentRestriction',
|
||||||
|
'platform' => 'all',
|
||||||
|
'filter' => null,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'type' => 'windowsEnrollmentStatusPage',
|
||||||
|
'platform' => 'all',
|
||||||
|
'filter' => null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$wrong->refresh();
|
||||||
|
|
||||||
|
expect($wrong->policy_type)->toBe('windowsEnrollmentStatusPage');
|
||||||
|
});
|
||||||
106
tests/Feature/ReclassifyEnrollmentConfigurationsCommandTest.php
Normal file
106
tests/Feature/ReclassifyEnrollmentConfigurationsCommandTest.php
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('reclassify command moves ESP versions out of enrollmentRestriction', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-reclassify',
|
||||||
|
'name' => 'Tenant Reclassify',
|
||||||
|
'metadata' => [],
|
||||||
|
'is_current' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'esp-1',
|
||||||
|
'policy_type' => 'enrollmentRestriction',
|
||||||
|
'display_name' => 'ESP Misclassified',
|
||||||
|
'platform' => 'all',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = PolicyVersion::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => 'enrollmentRestriction',
|
||||||
|
'platform' => 'all',
|
||||||
|
'created_by' => 'tester@example.com',
|
||||||
|
'captured_at' => CarbonImmutable::now(),
|
||||||
|
'snapshot' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration',
|
||||||
|
'deviceEnrollmentConfigurationType' => 'windows10EnrollmentCompletionPageConfiguration',
|
||||||
|
'displayName' => 'ESP Misclassified',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('intune:reclassify-enrollment-configurations', ['--tenant' => $tenant->tenant_id])
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$version->refresh();
|
||||||
|
$policy->refresh();
|
||||||
|
|
||||||
|
expect($version->policy_type)->toBe('enrollmentRestriction');
|
||||||
|
expect($policy->policy_type)->toBe('enrollmentRestriction');
|
||||||
|
|
||||||
|
$this->artisan('intune:reclassify-enrollment-configurations', ['--tenant' => $tenant->tenant_id, '--write' => true])
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$version->refresh();
|
||||||
|
$policy->refresh();
|
||||||
|
|
||||||
|
expect($version->policy_type)->toBe('windowsEnrollmentStatusPage');
|
||||||
|
expect($policy->policy_type)->toBe('windowsEnrollmentStatusPage');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reclassify command can detect ESP even when a policy has no versions', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-reclassify-no-versions',
|
||||||
|
'name' => 'Tenant Reclassify (No Versions)',
|
||||||
|
'metadata' => [],
|
||||||
|
'is_current' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'esp-2',
|
||||||
|
'policy_type' => 'enrollmentRestriction',
|
||||||
|
'display_name' => 'ESP Misclassified (No Versions)',
|
||||||
|
'platform' => 'all',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->mock(GraphClientInterface::class, function (MockInterface $mock) {
|
||||||
|
$mock->shouldReceive('getPolicy')
|
||||||
|
->andReturn(new GraphResponse(true, [
|
||||||
|
'payload' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration',
|
||||||
|
'deviceEnrollmentConfigurationType' => 'windows10EnrollmentCompletionPageConfiguration',
|
||||||
|
'displayName' => 'ESP Misclassified (No Versions)',
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->artisan('intune:reclassify-enrollment-configurations', ['--tenant' => $tenant->tenant_id])
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$policy->refresh();
|
||||||
|
expect($policy->policy_type)->toBe('enrollmentRestriction');
|
||||||
|
|
||||||
|
$this->artisan('intune:reclassify-enrollment-configurations', ['--tenant' => $tenant->tenant_id, '--write' => true])
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$policy->refresh();
|
||||||
|
expect($policy->policy_type)->toBe('windowsEnrollmentStatusPage');
|
||||||
|
});
|
||||||
@ -66,3 +66,95 @@
|
|||||||
|
|
||||||
expect(collect($result['warnings'])->join(' '))->toContain('@odata.type mismatch');
|
expect(collect($result['warnings'])->join(' '))->toContain('@odata.type mismatch');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes enrollment restrictions platform restriction payload', function () {
|
||||||
|
$snapshot = [
|
||||||
|
'@odata.type' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration',
|
||||||
|
'deviceEnrollmentConfigurationType' => 'deviceEnrollmentPlatformRestrictionConfiguration',
|
||||||
|
'displayName' => 'DeviceTypeRestriction',
|
||||||
|
'version' => 2,
|
||||||
|
// Graph uses this singular shape for platform restriction configs.
|
||||||
|
'platformRestriction' => [
|
||||||
|
'platformBlocked' => false,
|
||||||
|
'personalDeviceEnrollmentBlocked' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->normalizer->normalize($snapshot, 'enrollmentRestriction', 'all');
|
||||||
|
|
||||||
|
$block = collect($result['settings'])->firstWhere('title', 'Enrollment restrictions');
|
||||||
|
expect($block)->not->toBeNull();
|
||||||
|
|
||||||
|
$platformEntry = collect($block['entries'] ?? [])->firstWhere('key', 'Platform restrictions');
|
||||||
|
expect($platformEntry)->toBeNull();
|
||||||
|
|
||||||
|
expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform blocked')['value'] ?? null)->toBe('Disabled');
|
||||||
|
expect(collect($block['entries'] ?? [])->firstWhere('key', 'Personal device enrollment blocked')['value'] ?? null)->toBe('Enabled');
|
||||||
|
|
||||||
|
expect(collect($block['entries'] ?? [])->firstWhere('key', 'OS minimum version')['value'] ?? null)->toBe('None');
|
||||||
|
expect(collect($block['entries'] ?? [])->firstWhere('key', 'OS maximum version')['value'] ?? null)->toBe('None');
|
||||||
|
expect(collect($block['entries'] ?? [])->firstWhere('key', 'Blocked manufacturers')['value'] ?? null)->toBe(['None']);
|
||||||
|
expect(collect($block['entries'] ?? [])->firstWhere('key', 'Blocked SKUs')['value'] ?? null)->toBe(['None']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes Autopilot deployment profile key fields', function () {
|
||||||
|
$snapshot = [
|
||||||
|
'@odata.type' => '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile',
|
||||||
|
'displayName' => 'Autopilot Profile A',
|
||||||
|
'description' => 'Used for standard devices',
|
||||||
|
'deviceNameTemplate' => 'DEV-%SERIAL%',
|
||||||
|
'deploymentMode' => 'singleUser',
|
||||||
|
'deviceType' => 'windowsPc',
|
||||||
|
'enableWhiteGlove' => true,
|
||||||
|
'outOfBoxExperienceSettings' => [
|
||||||
|
'hideEULA' => true,
|
||||||
|
'userType' => 'standard',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->normalizer->normalize($snapshot, 'windowsAutopilotDeploymentProfile', 'windows');
|
||||||
|
|
||||||
|
expect($result['status'])->toBe('ok');
|
||||||
|
expect($result['warnings'])->toBe([]);
|
||||||
|
|
||||||
|
$general = collect($result['settings'])->firstWhere('title', 'General');
|
||||||
|
expect($general)->not->toBeNull();
|
||||||
|
expect(collect($general['entries'] ?? [])->firstWhere('key', 'Type')['value'] ?? null)->toBe('windowsAutopilotDeploymentProfile');
|
||||||
|
expect(collect($general['entries'] ?? [])->firstWhere('key', 'Display name')['value'] ?? null)->toBe('Autopilot Profile A');
|
||||||
|
|
||||||
|
$block = collect($result['settings'])->firstWhere('title', 'Autopilot profile');
|
||||||
|
expect($block)->not->toBeNull();
|
||||||
|
expect(collect($block['entries'] ?? [])->firstWhere('key', 'Device name template')['value'] ?? null)->toBe('DEV-%SERIAL%');
|
||||||
|
expect(collect($block['entries'] ?? [])->firstWhere('key', 'Pre-provisioning (White Glove)')['value'] ?? null)->toBe('Enabled');
|
||||||
|
expect(collect($block['entries'] ?? [])->firstWhere('key', 'OOBE: Hide EULA')['value'] ?? null)->toBe('Enabled');
|
||||||
|
expect(collect($block['entries'] ?? [])->firstWhere('key', 'OOBE: User type')['value'] ?? null)->toBe('standard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes Enrollment Status Page key fields', function () {
|
||||||
|
$snapshot = [
|
||||||
|
'@odata.type' => '#microsoft.graph.windowsEnrollmentStatusPageConfiguration',
|
||||||
|
'displayName' => 'ESP A',
|
||||||
|
'priority' => 1,
|
||||||
|
'showInstallationProgress' => true,
|
||||||
|
'blockDeviceSetupRetryByUser' => false,
|
||||||
|
'installProgressTimeoutInMinutes' => 60,
|
||||||
|
'selectedMobileAppIds' => ['app-1', 'app-2'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->normalizer->normalize($snapshot, 'windowsEnrollmentStatusPage', 'windows');
|
||||||
|
|
||||||
|
expect($result['status'])->toBe('ok');
|
||||||
|
expect($result['warnings'])->toBe([]);
|
||||||
|
|
||||||
|
$general = collect($result['settings'])->firstWhere('title', 'General');
|
||||||
|
expect($general)->not->toBeNull();
|
||||||
|
expect(collect($general['entries'] ?? [])->firstWhere('key', 'Type')['value'] ?? null)->toBe('windowsEnrollmentStatusPage');
|
||||||
|
expect(collect($general['entries'] ?? [])->firstWhere('key', 'Display name')['value'] ?? null)->toBe('ESP A');
|
||||||
|
|
||||||
|
$block = collect($result['settings'])->firstWhere('title', 'Enrollment Status Page (ESP)');
|
||||||
|
expect($block)->not->toBeNull();
|
||||||
|
expect(collect($block['entries'] ?? [])->firstWhere('key', 'Priority')['value'] ?? null)->toBe(1);
|
||||||
|
expect(collect($block['entries'] ?? [])->firstWhere('key', 'Show installation progress')['value'] ?? null)->toBe('Enabled');
|
||||||
|
expect(collect($block['entries'] ?? [])->firstWhere('key', 'Block retry by user')['value'] ?? null)->toBe('Disabled');
|
||||||
|
expect(collect($block['entries'] ?? [])->firstWhere('key', 'Selected mobile app IDs')['value'] ?? null)->toBe(['app-1', 'app-2']);
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user