TenantAtlas/app/Console/Commands/ReclassifyEnrollmentConfigurations.php
ahmido 6a1809fbe9 014-enrollment-autopilot (#20)
This PR completes Feature 014 (Enrollment & Autopilot).

Adds normalization for:
Autopilot deployment profiles (windowsAutopilotDeploymentProfile)
Enrollment Status Page / ESP (windowsEnrollmentStatusPage)
Enrollment Restrictions (enrollmentRestriction, restore remains preview-only)
Improves settings readability:
Autopilot OOBE settings are expanded into readable key/value entries
Enrollment restriction platform restrictions are shown as explicit fields (with sensible defaults)
Array/list values render as badges (avoids Blade rendering crashes on non-string values)
Fixes enrollment configuration type collisions during sync:
Canonical type resolution prevents enrollmentRestriction from “claiming” ESP items
Safe reclassification updates existing wrong rows instead of skipping
Enhances reclassification command:
Can detect ESP even if a policy has no local versions (fetches snapshot from Graph)
Dry-run by default; apply with --write
Tests

Added/updated unit + Filament feature tests for normalization and UI rendering.
Preview-only enforcement for enrollment restrictions is covered.
Targeted test suite and Pint are green.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #20
2026-01-02 11:59:21 +00:00

163 lines
4.7 KiB
PHP

<?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();
}
}