diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index b2d2ed5..bea7788 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -62,7 +62,7 @@ public static function infolist(Schema $schema): Schema ->columns(2) ->columnSpanFull(), - // For Settings Catalog policies: Tabs with Settings table + JSON viewer + // Tabbed content (General / Settings / JSON) Tabs::make('policy_content') ->activeTab(1) ->persistTabInQueryString() @@ -74,10 +74,7 @@ public static function infolist(Schema $schema): Schema ->label('') ->view('filament.infolists.entries.policy-general') ->state(function (Policy $record) { - $normalized = static::normalizedPolicyState($record); - $split = static::splitGeneralBlock($normalized); - - return $split['general']; + return static::generalOverviewState($record); }), ]) ->visible(fn (Policy $record) => $record->versions()->exists()), @@ -88,12 +85,9 @@ public static function infolist(Schema $schema): Schema ->label('') ->view('filament.infolists.entries.normalized-settings') ->state(function (Policy $record) { - $normalized = static::normalizedPolicyState($record); - $split = static::splitGeneralBlock($normalized); - - return $split['normalized']; + return static::settingsTabState($record); }) - ->visible(fn (Policy $record) => $record->policy_type === 'settingsCatalogPolicy' && + ->visible(fn (Policy $record) => static::hasSettingsTable($record) && $record->versions()->exists() ), @@ -101,12 +95,9 @@ public static function infolist(Schema $schema): Schema ->label('') ->view('filament.infolists.entries.policy-settings-standard') ->state(function (Policy $record) { - $normalized = static::normalizedPolicyState($record); - $split = static::splitGeneralBlock($normalized); - - return $split['normalized']; + return static::settingsTabState($record); }) - ->visible(fn (Policy $record) => $record->policy_type !== 'settingsCatalogPolicy' && + ->visible(fn (Policy $record) => ! static::hasSettingsTable($record) && $record->versions()->exists() ), @@ -144,12 +135,9 @@ public static function infolist(Schema $schema): Schema ->visible(fn (Policy $record) => $record->versions()->exists()), ]) ->columnSpanFull() - ->visible(function (Policy $record) { - return str_contains(strtolower($record->policy_type ?? ''), 'settings') || - str_contains(strtolower($record->policy_type ?? ''), 'catalog'); - }), + ->visible(fn (Policy $record) => static::usesTabbedLayout($record)), - // For non-Settings Catalog policies: Simple sections without tabs + // Legacy layout (kept for fallback if tabs are disabled) Section::make('Settings') ->schema([ ViewEntry::make('settings') @@ -170,9 +158,7 @@ public static function infolist(Schema $schema): Schema ]) ->columnSpanFull() ->visible(function (Policy $record) { - // Show simple settings section for non-Settings Catalog policies - return ! str_contains(strtolower($record->policy_type ?? ''), 'settings') && - ! str_contains(strtolower($record->policy_type ?? ''), 'catalog'); + return ! static::usesTabbedLayout($record); }), Section::make('Policy Snapshot (JSON)') @@ -205,9 +191,7 @@ public static function infolist(Schema $schema): Schema ->description('Raw JSON configuration from Microsoft Graph API') ->columnSpanFull() ->visible(function (Policy $record) { - // Show standalone JSON section only for non-Settings Catalog policies - return ! str_contains(strtolower($record->policy_type ?? ''), 'settings') && - ! str_contains(strtolower($record->policy_type ?? ''), 'catalog'); + return ! static::usesTabbedLayout($record); }), ]); } @@ -690,4 +674,101 @@ private static function typeMeta(?string $type): array return collect(config('tenantpilot.supported_policy_types', [])) ->firstWhere('type', $type) ?? []; } + + private static function usesTabbedLayout(Policy $record): bool + { + return true; + } + + private static function hasSettingsTable(Policy $record): bool + { + $normalized = static::normalizedPolicyState($record); + $rows = $normalized['settings_table']['rows'] ?? []; + + return is_array($rows) && $rows !== []; + } + + /** + * @return array{entries: array} + */ + private static function generalOverviewState(Policy $record): array + { + $snapshot = static::latestSnapshot($record); + $entries = []; + + $name = $snapshot['displayName'] ?? $snapshot['name'] ?? $record->display_name; + if (is_string($name) && $name !== '') { + $entries[] = ['key' => 'Name', 'value' => $name]; + } + + $platforms = $snapshot['platforms'] ?? $snapshot['platform'] ?? $record->platform; + if (is_string($platforms) && $platforms !== '') { + $entries[] = ['key' => 'Platforms', 'value' => $platforms]; + } elseif (is_array($platforms) && $platforms !== []) { + $entries[] = ['key' => 'Platforms', 'value' => $platforms]; + } + + $technologies = $snapshot['technologies'] ?? null; + if (is_string($technologies) && $technologies !== '') { + $entries[] = ['key' => 'Technologies', 'value' => $technologies]; + } elseif (is_array($technologies) && $technologies !== []) { + $entries[] = ['key' => 'Technologies', 'value' => $technologies]; + } + + if (array_key_exists('templateReference', $snapshot)) { + $entries[] = ['key' => 'Template Reference', 'value' => $snapshot['templateReference']]; + } + + $settingCount = $snapshot['settingCount'] + ?? $snapshot['settingsCount'] + ?? (isset($snapshot['settings']) && is_array($snapshot['settings']) ? count($snapshot['settings']) : null); + + if (is_int($settingCount) || is_numeric($settingCount)) { + $entries[] = ['key' => 'Setting Count', 'value' => $settingCount]; + } + + $version = $snapshot['version'] ?? null; + if (is_string($version) && $version !== '') { + $entries[] = ['key' => 'Version', 'value' => $version]; + } elseif (is_numeric($version)) { + $entries[] = ['key' => 'Version', 'value' => $version]; + } + + $lastModified = $snapshot['lastModifiedDateTime'] ?? null; + if (is_string($lastModified) && $lastModified !== '') { + $entries[] = ['key' => 'Last Modified', 'value' => $lastModified]; + } + + $createdAt = $snapshot['createdDateTime'] ?? null; + if (is_string($createdAt) && $createdAt !== '') { + $entries[] = ['key' => 'Created', 'value' => $createdAt]; + } + + $description = $snapshot['description'] ?? null; + if (is_string($description) && $description !== '') { + $entries[] = ['key' => 'Description', 'value' => $description]; + } + + return [ + 'entries' => $entries, + ]; + } + + /** + * @return array + */ + private static function settingsTabState(Policy $record): array + { + $normalized = static::normalizedPolicyState($record); + $rows = $normalized['settings_table']['rows'] ?? []; + $hasSettingsTable = is_array($rows) && $rows !== []; + + if ($record->policy_type === 'settingsCatalogPolicy' && $hasSettingsTable) { + $split = static::splitGeneralBlock($normalized); + + return $split['normalized']; + } + + return $normalized; + } } diff --git a/app/Jobs/FetchAssignmentsJob.php b/app/Jobs/FetchAssignmentsJob.php index 7bb8d89..a492a4a 100644 --- a/app/Jobs/FetchAssignmentsJob.php +++ b/app/Jobs/FetchAssignmentsJob.php @@ -51,6 +51,16 @@ public function handle(AssignmentBackupService $assignmentBackupService): void return; } + $tenant = $backupItem->tenant; + + if ($tenant === null) { + Log::warning('FetchAssignmentsJob: Tenant not found for BackupItem', [ + 'backup_item_id' => $this->backupItemId, + ]); + + return; + } + // Only process Settings Catalog policies if ($backupItem->policy_type !== 'settingsCatalogPolicy') { Log::info('FetchAssignmentsJob: Skipping non-Settings Catalog policy', [ @@ -63,8 +73,9 @@ public function handle(AssignmentBackupService $assignmentBackupService): void $assignmentBackupService->enrichWithAssignments( backupItem: $backupItem, - tenantId: $this->tenantExternalId, - policyId: $this->policyExternalId, + tenant: $tenant, + policyType: $backupItem->policy_type, + policyId: $backupItem->policy_identifier, policyPayload: $this->policyPayload, includeAssignments: true ); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d691c7b..3226a0e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,9 @@ use App\Services\Graph\GraphClientInterface; use App\Services\Graph\MicrosoftGraphClient; use App\Services\Graph\NullGraphClient; +use App\Services\Intune\CompliancePolicyNormalizer; +use App\Services\Intune\DeviceConfigurationPolicyNormalizer; +use App\Services\Intune\SettingsCatalogPolicyNormalizer; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -27,6 +30,15 @@ public function register(): void return $app->make(NullGraphClient::class); }); + + $this->app->tag( + [ + CompliancePolicyNormalizer::class, + DeviceConfigurationPolicyNormalizer::class, + SettingsCatalogPolicyNormalizer::class, + ], + 'policy-type-normalizers' + ); } /** diff --git a/app/Services/AssignmentBackupService.php b/app/Services/AssignmentBackupService.php index f94c697..0c12fa3 100644 --- a/app/Services/AssignmentBackupService.php +++ b/app/Services/AssignmentBackupService.php @@ -24,6 +24,7 @@ public function __construct( * * @param BackupItem $backupItem The backup item to enrich * @param Tenant $tenant Tenant model with credentials + * @param string $policyType Policy type key (e.g. deviceConfiguration) * @param string $policyId Policy ID (external_id from Graph) * @param array $policyPayload Full policy payload from Graph * @param bool $includeAssignments Whether to fetch and include assignments @@ -32,6 +33,7 @@ public function __construct( public function enrichWithAssignments( BackupItem $backupItem, Tenant $tenant, + string $policyType, string $policyId, array $policyPayload, bool $includeAssignments = false @@ -58,7 +60,7 @@ public function enrichWithAssignments( // Fetch assignments from Graph API $graphOptions = $tenant->graphOptions(); $tenantId = $graphOptions['tenant'] ?? $tenant->external_id ?? $tenant->tenant_id; - $assignments = $this->assignmentFetcher->fetch($tenantId, $policyId, $graphOptions); + $assignments = $this->assignmentFetcher->fetch($policyType, $tenantId, $policyId, $graphOptions); if (empty($assignments)) { // No assignments or fetch failed diff --git a/app/Services/Graph/AssignmentFetcher.php b/app/Services/Graph/AssignmentFetcher.php index 53d3ec6..415881d 100644 --- a/app/Services/Graph/AssignmentFetcher.php +++ b/app/Services/Graph/AssignmentFetcher.php @@ -8,27 +8,32 @@ class AssignmentFetcher { public function __construct( private readonly MicrosoftGraphClient $graphClient, + private readonly GraphContractRegistry $contracts, ) {} /** * Fetch policy assignments with fallback strategy. * - * Primary: GET /deviceManagement/configurationPolicies/{id}/assignments - * Fallback: GET /deviceManagement/configurationPolicies?$expand=assignments&$filter=id eq '{id}' + * Primary: GET {assignments_list_path} + * Fallback: GET {resource}?$expand=assignments&$filter=id eq '{id}' * * @return array Returns assignment array or empty array on failure */ - public function fetch(string $tenantId, string $policyId, array $options = []): array + public function fetch(string $policyType, string $tenantId, string $policyId, array $options = []): array { try { + $contract = $this->contracts->get($policyType); + $listPathTemplate = $contract['assignments_list_path'] ?? null; + $resource = $contract['resource'] ?? null; $requestOptions = array_merge($options, ['tenant' => $tenantId]); // Try primary endpoint - $assignments = $this->fetchPrimary($policyId, $requestOptions); + $assignments = $this->fetchPrimary($listPathTemplate, $policyId, $requestOptions); if (! empty($assignments)) { Log::debug('Fetched assignments via primary endpoint', [ 'tenant_id' => $tenantId, + 'policy_type' => $policyType, 'policy_id' => $policyId, 'count' => count($assignments), ]); @@ -39,14 +44,26 @@ public function fetch(string $tenantId, string $policyId, array $options = []): // Try fallback with $expand Log::debug('Primary endpoint returned empty, trying fallback', [ 'tenant_id' => $tenantId, + 'policy_type' => $policyType, 'policy_id' => $policyId, ]); - $assignments = $this->fetchWithExpand($policyId, $requestOptions); + if (! is_string($resource) || $resource === '') { + Log::debug('Assignments resource not configured for policy type', [ + 'tenant_id' => $tenantId, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + ]); + + return []; + } + + $assignments = $this->fetchWithExpand($resource, $policyId, $requestOptions); if (! empty($assignments)) { Log::debug('Fetched assignments via fallback endpoint', [ 'tenant_id' => $tenantId, + 'policy_type' => $policyType, 'policy_id' => $policyId, 'count' => count($assignments), ]); @@ -57,6 +74,7 @@ public function fetch(string $tenantId, string $policyId, array $options = []): // Both methods returned empty Log::debug('No assignments found for policy', [ 'tenant_id' => $tenantId, + 'policy_type' => $policyType, 'policy_id' => $policyId, ]); @@ -64,6 +82,7 @@ public function fetch(string $tenantId, string $policyId, array $options = []): } catch (GraphException $e) { Log::warning('Failed to fetch assignments', [ 'tenant_id' => $tenantId, + 'policy_type' => $policyType, 'policy_id' => $policyId, 'error' => $e->getMessage(), 'context' => $e->context, @@ -76,9 +95,17 @@ public function fetch(string $tenantId, string $policyId, array $options = []): /** * Fetch assignments using primary endpoint. */ - private function fetchPrimary(string $policyId, array $options): array + private function fetchPrimary(?string $listPathTemplate, string $policyId, array $options): array { - $path = "/deviceManagement/configurationPolicies/{$policyId}/assignments"; + if (! is_string($listPathTemplate) || $listPathTemplate === '') { + return []; + } + + $path = $this->resolvePath($listPathTemplate, $policyId); + + if ($path === null) { + return []; + } $response = $this->graphClient->request('GET', $path, $options); @@ -88,9 +115,9 @@ private function fetchPrimary(string $policyId, array $options): array /** * Fetch assignments using $expand fallback. */ - private function fetchWithExpand(string $policyId, array $options): array + private function fetchWithExpand(string $resource, string $policyId, array $options): array { - $path = '/deviceManagement/configurationPolicies'; + $path = $resource; $params = [ '$expand' => 'assignments', '$filter' => "id eq '{$policyId}'", @@ -108,4 +135,13 @@ private function fetchWithExpand(string $policyId, array $options): array return $policies[0]['assignments'] ?? []; } + + private function resolvePath(string $template, string $policyId): ?string + { + if ($template === '') { + return null; + } + + return str_replace('{id}', urlencode($policyId), $template); + } } diff --git a/app/Services/Intune/CompliancePolicyNormalizer.php b/app/Services/Intune/CompliancePolicyNormalizer.php new file mode 100644 index 0000000..be66042 --- /dev/null +++ b/app/Services/Intune/CompliancePolicyNormalizer.php @@ -0,0 +1,296 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform); + + if ($snapshot === []) { + return $normalized; + } + + $normalized['settings'] = array_values(array_filter( + $normalized['settings'], + fn (array $block) => strtolower((string) ($block['title'] ?? '')) !== 'general' + )); + + foreach ($this->buildComplianceBlocks($snapshot) as $block) { + $normalized['settings'][] = $block; + } + + return $normalized; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + return $this->defaultNormalizer->flattenForDiff($snapshot, $policyType, $platform); + } + + /** + * @return array> + */ + private function buildComplianceBlocks(array $snapshot): array + { + $blocks = []; + $groups = $this->groupedFields(); + $usedKeys = []; + + foreach ($groups as $title => $group) { + $rows = $this->buildRows($snapshot, $group['keys'], $group['labels'] ?? []); + + if ($rows === []) { + continue; + } + + $blocks[] = [ + 'type' => 'table', + 'title' => $title, + 'rows' => $rows, + ]; + + $usedKeys = array_merge($usedKeys, $group['keys']); + } + + $additionalRows = $this->buildAdditionalRows($snapshot, $usedKeys); + + if ($additionalRows !== []) { + $blocks[] = [ + 'type' => 'table', + 'title' => 'Additional Settings', + 'rows' => $additionalRows, + ]; + } + + return $blocks; + } + + /** + * @return array{keys: array, labels?: array} + */ + private function groupedFields(): array + { + return [ + 'Password & Access' => [ + 'keys' => [ + 'passwordRequired', + 'passwordRequiredType', + 'passwordBlockSimple', + 'passwordMinimumLength', + 'passwordMinimumCharacterSetCount', + 'passwordExpirationDays', + 'passwordMinutesOfInactivityBeforeLock', + 'passwordPreviousPasswordBlockCount', + 'passwordRequiredToUnlockFromIdle', + ], + 'labels' => [ + 'passwordRequired' => 'Password required', + 'passwordRequiredType' => 'Password required type', + 'passwordBlockSimple' => 'Block simple passwords', + 'passwordMinimumLength' => 'Password minimum length', + 'passwordMinimumCharacterSetCount' => 'Password minimum character set count', + 'passwordExpirationDays' => 'Password expiration days', + 'passwordMinutesOfInactivityBeforeLock' => 'Password idle lock (minutes)', + 'passwordPreviousPasswordBlockCount' => 'Password history count', + 'passwordRequiredToUnlockFromIdle' => 'Password required to unlock from idle', + ], + ], + 'Defender & Threat Protection' => [ + 'keys' => [ + 'defenderEnabled', + 'defenderVersion', + 'antivirusRequired', + 'antiSpywareRequired', + 'rtpEnabled', + 'signatureOutOfDate', + 'deviceThreatProtectionEnabled', + 'deviceThreatProtectionRequiredSecurityLevel', + 'requireHealthyDeviceReport', + ], + 'labels' => [ + 'defenderEnabled' => 'Microsoft Defender enabled', + 'defenderVersion' => 'Defender version', + 'antivirusRequired' => 'Antivirus required', + 'antiSpywareRequired' => 'Anti-spyware required', + 'rtpEnabled' => 'Real-time protection enabled', + 'signatureOutOfDate' => 'Signature out of date (days)', + 'deviceThreatProtectionEnabled' => 'Device threat protection enabled', + 'deviceThreatProtectionRequiredSecurityLevel' => 'Threat protection required level', + 'requireHealthyDeviceReport' => 'Require healthy device report', + ], + ], + 'Encryption & Integrity' => [ + 'keys' => [ + 'bitLockerEnabled', + 'storageRequireEncryption', + 'tpmRequired', + 'secureBootEnabled', + 'codeIntegrityEnabled', + 'memoryIntegrityEnabled', + 'kernelDmaProtectionEnabled', + 'firmwareProtectionEnabled', + 'virtualizationBasedSecurityEnabled', + 'earlyLaunchAntiMalwareDriverEnabled', + ], + 'labels' => [ + 'bitLockerEnabled' => 'BitLocker required', + 'storageRequireEncryption' => 'Storage encryption required', + 'tpmRequired' => 'TPM required', + 'secureBootEnabled' => 'Secure boot required', + 'codeIntegrityEnabled' => 'Code integrity required', + 'memoryIntegrityEnabled' => 'Memory integrity required', + 'kernelDmaProtectionEnabled' => 'Kernel DMA protection required', + 'firmwareProtectionEnabled' => 'Firmware protection required', + 'virtualizationBasedSecurityEnabled' => 'Virtualization-based security required', + 'earlyLaunchAntiMalwareDriverEnabled' => 'Early launch anti-malware required', + ], + ], + 'Operating System' => [ + 'keys' => [ + 'osMinimumVersion', + 'osMaximumVersion', + 'mobileOsMinimumVersion', + 'mobileOsMaximumVersion', + 'validOperatingSystemBuildRanges', + 'wslDistributions', + ], + 'labels' => [ + 'osMinimumVersion' => 'OS minimum version', + 'osMaximumVersion' => 'OS maximum version', + 'mobileOsMinimumVersion' => 'Mobile OS minimum version', + 'mobileOsMaximumVersion' => 'Mobile OS maximum version', + 'validOperatingSystemBuildRanges' => 'Valid OS build ranges', + 'wslDistributions' => 'Allowed WSL distributions', + ], + ], + 'Firewall' => [ + 'keys' => [ + 'activeFirewallRequired', + ], + 'labels' => [ + 'activeFirewallRequired' => 'Active firewall required', + ], + ], + 'Compliance Signals' => [ + 'keys' => [ + 'configurationManagerComplianceRequired', + 'deviceCompliancePolicyScript', + ], + 'labels' => [ + 'configurationManagerComplianceRequired' => 'ConfigMgr compliance required', + 'deviceCompliancePolicyScript' => 'Compliance policy script', + ], + ], + ]; + } + + /** + * @param array $labels + * @return array> + */ + private function buildRows(array $snapshot, array $keys, array $labels = []): array + { + $rows = []; + + foreach ($keys as $key) { + if (! array_key_exists($key, $snapshot)) { + continue; + } + + $rows[] = [ + 'label' => $labels[$key] ?? Str::headline($key), + 'value' => $this->formatValue($snapshot[$key]), + ]; + } + + return $rows; + } + + /** + * @param array $usedKeys + * @return array> + */ + private function buildAdditionalRows(array $snapshot, array $usedKeys): array + { + $ignoredKeys = array_merge($this->ignoredKeys(), $usedKeys); + $rows = []; + + foreach ($snapshot as $key => $value) { + if (! is_string($key)) { + continue; + } + + if (in_array($key, $ignoredKeys, true)) { + continue; + } + + $rows[] = [ + 'label' => Str::headline($key), + 'value' => $this->formatValue($value), + ]; + } + + return $rows; + } + + /** + * @return array + */ + private function ignoredKeys(): array + { + return [ + '@odata.context', + '@odata.type', + 'id', + 'version', + 'createdDateTime', + 'lastModifiedDateTime', + 'supportsScopeTags', + 'roleScopeTagIds', + 'assignments', + 'createdBy', + 'lastModifiedBy', + 'omaSettings', + 'settings', + 'settingsDelta', + 'displayName', + 'description', + 'name', + 'platform', + 'platforms', + 'technologies', + 'settingCount', + 'settingsCount', + 'templateReference', + ]; + } + + private function formatValue(mixed $value): mixed + { + if (is_array($value)) { + return json_encode($value, JSON_PRETTY_PRINT); + } + + return $value; + } +} diff --git a/app/Services/Intune/DefaultPolicyNormalizer.php b/app/Services/Intune/DefaultPolicyNormalizer.php new file mode 100644 index 0000000..6cf7145 --- /dev/null +++ b/app/Services/Intune/DefaultPolicyNormalizer.php @@ -0,0 +1,918 @@ +>, settings_table?: array, warnings: array, context?: string, record_id?: string} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $resultWarnings = []; + $status = 'success'; + $settingsTable = null; + + $validation = $this->validator->validate($snapshot); + $resultWarnings = array_merge($resultWarnings, $validation['warnings']); + + $odataWarning = Policy::odataTypeWarning($snapshot, $policyType, $platform); + + if ($odataWarning) { + $resultWarnings[] = $odataWarning; + } + + if ($snapshot === []) { + return [ + 'status' => 'warning', + 'settings' => [], + 'warnings' => array_values(array_unique(array_merge($resultWarnings, ['No snapshot available']))), + ]; + } + + $settings = []; + + if (isset($snapshot['omaSettings']) && is_array($snapshot['omaSettings'])) { + $settings[] = $this->normalizeOmaSettings($snapshot['omaSettings']); + } + + if (isset($snapshot['settings']) && is_array($snapshot['settings'])) { + if ($policyType === 'settingsCatalogPolicy') { + $normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settings']); + $settingsTable = $normalized['table']; + $resultWarnings = array_merge($resultWarnings, $normalized['warnings']); + } else { + $settings[] = $this->normalizeSettingsCatalog($snapshot['settings']); + } + } elseif (isset($snapshot['settingsDelta']) && is_array($snapshot['settingsDelta'])) { + if ($policyType === 'settingsCatalogPolicy') { + $normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settingsDelta'], 'Settings delta'); + $settingsTable = $normalized['table']; + $resultWarnings = array_merge($resultWarnings, $normalized['warnings']); + } else { + $settings[] = $this->normalizeSettingsCatalog($snapshot['settingsDelta'], 'Settings delta'); + } + } elseif ($policyType === 'settingsCatalogPolicy') { + $resultWarnings[] = 'Settings not hydrated for this Settings Catalog policy.'; + } + + $settings[] = $this->normalizeStandard($snapshot); + + if (! empty($resultWarnings)) { + $status = 'warning'; + } + + $result = [ + 'status' => $status, + 'settings' => array_values(array_filter($settings)), + 'warnings' => array_values(array_unique($resultWarnings)), + ]; + + if (is_array($settingsTable) && ! empty($settingsTable['rows'] ?? [])) { + $result['settings_table'] = $settingsTable; + } + + return $result; + } + + /** + * Flatten normalized settings into key/value pairs for diffing. + * + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $normalized = $this->normalize($snapshot ?? [], $policyType, $platform); + $map = []; + + if (isset($normalized['settings_table']['rows']) && is_array($normalized['settings_table']['rows'])) { + foreach ($normalized['settings_table']['rows'] as $row) { + if (! is_array($row)) { + continue; + } + + $key = $row['path'] ?? $row['definition'] ?? 'entry'; + $map[$key] = $row['value'] ?? null; + } + } + + foreach ($normalized['settings'] as $block) { + if (($block['type'] ?? null) === 'table') { + foreach ($block['rows'] ?? [] as $row) { + $key = $row['path'] ?? $row['label'] ?? 'entry'; + $map[$key] = $row['value'] ?? null; + } + + continue; + } + + foreach ($block['entries'] ?? [] as $entry) { + $key = $entry['key'] ?? 'entry'; + $map[$key] = $entry['value'] ?? null; + } + } + + return $map; + } + + /** + * @param array> $omaSettings + */ + private function normalizeOmaSettings(array $omaSettings): array + { + $rows = []; + + foreach ($omaSettings as $setting) { + if (! is_array($setting)) { + continue; + } + + $rows[] = [ + 'path' => $setting['omaUri'] ?? $setting['path'] ?? 'n/a', + 'value' => $setting['value'] ?? $setting['displayValue'] ?? $setting['secretReferenceValueId'] ?? null, + 'label' => $setting['displayName'] ?? null, + 'description' => $setting['description'] ?? null, + ]; + } + + return [ + 'type' => 'table', + 'title' => 'OMA-URI settings', + 'rows' => $rows, + ]; + } + + /** + * @param array> $settings + */ + private function normalizeSettingsCatalog(array $settings, string $title = 'Settings'): array + { + $entries = []; + + foreach ($settings as $setting) { + if (! is_array($setting)) { + continue; + } + + $key = $setting['displayName'] ?? $setting['name'] ?? $setting['definitionId'] ?? 'setting'; + $value = $setting['value'] ?? $setting['settingInstance']['simpleSettingValue'] ?? $setting['simpleSettingValue'] ?? null; + + if ($value === null && isset($setting['value']['value'])) { + $value = $setting['value']['value']; + } + + if (is_array($value)) { + $value = json_encode($value, JSON_PRETTY_PRINT); + } + + $entries[] = [ + 'key' => $key, + 'value' => $value, + ]; + } + + return [ + 'type' => 'keyValue', + 'title' => $title, + 'entries' => $entries, + ]; + } + + /** + * @param array $settings + * @return array{table: array, warnings: array} + */ + private function buildSettingsCatalogSettingsTable(array $settings, string $title = 'Settings'): array + { + $flattened = $this->flattenSettingsCatalogSettingInstances($settings); + + return [ + 'table' => [ + 'title' => $title, + 'rows' => $flattened['rows'], + ], + 'warnings' => $flattened['warnings'], + ]; + } + + /** + * @param array $settings + * @return array{rows: array>, warnings: array} + */ + private function flattenSettingsCatalogSettingInstances(array $settings): array + { + $rows = []; + $warnings = []; + $rowCount = 0; + $warnedDepthLimit = false; + $warnedRowLimit = false; + + // Extract all definition IDs first to resolve display names in batch + $definitionIds = $this->extractAllDefinitionIds($settings); + $definitions = $this->definitionResolver->resolve($definitionIds); + + // Extract all category IDs and resolve them + $categoryIds = array_filter(array_unique(array_map( + fn ($def) => $def['categoryId'] ?? null, + $definitions + ))); + $categories = $this->categoryResolver->resolve($categoryIds); + $categoryNames = []; + + foreach ($categoryIds as $categoryId) { + $categoryName = $categories[$categoryId]['displayName'] ?? null; + + if (is_string($categoryName) && $categoryName !== '') { + $categoryNames[] = $categoryName; + } + } + + $categoryNames = array_values(array_unique($categoryNames)); + $defaultCategoryName = count($categoryNames) === 1 ? $categoryNames[0] : null; + + $walk = function (array $nodes, array $pathParts, int $depth) use ( + &$walk, + &$rows, + &$warnings, + &$rowCount, + &$warnedDepthLimit, + &$warnedRowLimit, + $definitions, + $categories, + $defaultCategoryName + ): void { + if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) { + if (! $warnedRowLimit) { + $warnings[] = sprintf('Settings truncated after %d rows.', self::SETTINGS_CATALOG_MAX_ROWS); + $warnedRowLimit = true; + } + + return; + } + + if ($depth > self::SETTINGS_CATALOG_MAX_DEPTH) { + if (! $warnedDepthLimit) { + $warnings[] = sprintf('Settings nesting truncated after depth %d.', self::SETTINGS_CATALOG_MAX_DEPTH); + $warnedDepthLimit = true; + } + + return; + } + + foreach ($nodes as $node) { + if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) { + break; + } + + if (! is_array($node)) { + continue; + } + + $instance = $this->extractSettingsCatalogSettingInstance($node); + $definitionId = $this->extractSettingsCatalogDefinitionId($node, $instance); + $instanceType = is_array($instance) ? ($instance['@odata.type'] ?? $node['@odata.type'] ?? null) : ($node['@odata.type'] ?? null); + $rawInstanceType = is_string($instanceType) ? ltrim($instanceType, '#') : null; + + $currentPathParts = array_merge($pathParts, [$definitionId]); + $path = implode(' > ', $currentPathParts); + + $value = $this->extractSettingsCatalogValue($node, $instance); + + // Get metadata from resolved definitions + $definition = $definitions[$definitionId] ?? null; + $displayName = $definition['displayName'] ?? + $this->definitionResolver->prettifyDefinitionId($definitionId); + $categoryId = $definition['categoryId'] ?? null; + $categoryName = $categoryId ? ($categories[$categoryId]['displayName'] ?? '-') : '-'; + $description = $definition['description'] ?? null; + + if (! $categoryId && ! empty($pathParts)) { + foreach (array_reverse($pathParts) as $ancestorDefinitionId) { + if (! is_string($ancestorDefinitionId) || $ancestorDefinitionId === '') { + continue; + } + + $ancestorDefinition = $definitions[$ancestorDefinitionId] ?? null; + $ancestorCategoryId = $ancestorDefinition['categoryId'] ?? null; + + if ($ancestorCategoryId) { + $categoryId = $ancestorCategoryId; + $categoryName = $categories[$categoryId]['displayName'] ?? '-'; + break; + } + } + } + + if ( + ! $categoryId + && $defaultCategoryName + && (str_contains($definitionId, '{') || str_contains($definitionId, '}')) + ) { + $categoryName = $defaultCategoryName; + } + + // Convert technical type to user-friendly data type + $dataType = $this->getUserFriendlyDataType($rawInstanceType, $value); + + $rows[] = [ + 'definition' => $displayName, + 'definition_id' => $definitionId, + 'category' => $categoryName, + 'data_type' => $dataType, + 'value' => $this->stringifySettingsCatalogValue($value), + 'description' => $description ? Str::limit($description, 100) : '-', + 'path' => $path, + 'raw' => $this->pruneSettingsCatalogRaw($instance ?? $node), + ]; + + $rowCount++; + + if (! is_array($instance)) { + continue; + } + + $nested = $this->extractSettingsCatalogChildren($instance); + + if (! empty($nested)) { + $walk($nested, $currentPathParts, $depth + 1); + } + + if ($this->isSettingsCatalogGroupSettingCollectionInstance($instance)) { + $collections = $instance['groupSettingCollectionValue'] ?? []; + + if (! is_array($collections)) { + continue; + } + + foreach (array_values($collections) as $index => $collection) { + if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) { + break; + } + + if (! is_array($collection)) { + continue; + } + + $children = $collection['children'] ?? []; + + if (! is_array($children) || empty($children)) { + continue; + } + + $walk($children, array_merge($currentPathParts, ['['.($index + 1).']']), $depth + 1); + } + } + } + }; + + $walk($settings, [], 1); + + return [ + 'rows' => array_slice($rows, 0, self::SETTINGS_CATALOG_MAX_ROWS), + 'warnings' => $warnings, + ]; + } + + private function extractSettingsCatalogSettingInstance(array $setting): ?array + { + $instance = $setting['settingInstance'] ?? null; + + if (is_array($instance)) { + return $instance; + } + + if (isset($setting['@odata.type']) && (isset($setting['settingDefinitionId']) || isset($setting['definitionId']))) { + return $setting; + } + + return null; + } + + private function extractSettingsCatalogDefinitionId(array $setting, ?array $instance): string + { + $candidates = [ + $setting['definitionId'] ?? null, + $setting['settingDefinitionId'] ?? null, + $setting['name'] ?? null, + $setting['displayName'] ?? null, + $instance['settingDefinitionId'] ?? null, + $instance['definitionId'] ?? null, + ]; + + foreach ($candidates as $candidate) { + if (is_string($candidate) && $candidate !== '') { + return $candidate; + } + } + + return 'setting'; + } + + private function formatSettingsCatalogInstanceType(?string $type): ?string + { + if (! $type) { + return null; + } + + $type = Str::afterLast($type, '.'); + + foreach (['deviceManagementConfiguration', 'deviceManagement'] as $prefix) { + if (Str::startsWith($type, $prefix)) { + $type = substr($type, strlen($prefix)); + + break; + } + } + + return $type !== '' ? $type : null; + } + + private function isSettingsCatalogGroupSettingCollectionInstance(array $instance): bool + { + $type = $instance['@odata.type'] ?? null; + + if (! is_string($type)) { + return false; + } + + return Str::contains($type, 'GroupSettingCollectionInstance', ignoreCase: true); + } + + /** + * @return array + */ + private function extractSettingsCatalogChildren(array $instance): array + { + foreach (['children', 'choiceSettingValue.children', 'groupSettingValue.children'] as $path) { + $children = Arr::get($instance, $path); + + if (is_array($children) && ! empty($children)) { + return $children; + } + } + + return []; + } + + private function extractSettingsCatalogValue(array $setting, ?array $instance): mixed + { + if ($instance === null) { + return $setting['value'] ?? null; + } + + $type = $instance['@odata.type'] ?? null; + $type = is_string($type) ? $type : ''; + + if (Str::contains($type, 'SimpleSettingInstance', ignoreCase: true)) { + $simple = $instance['simpleSettingValue'] ?? null; + + if (is_array($simple)) { + return $simple['value'] ?? $simple; + } + + return $simple; + } + + if (Str::contains($type, 'ChoiceSettingInstance', ignoreCase: true)) { + $choice = $instance['choiceSettingValue'] ?? null; + + if (is_array($choice)) { + return $choice['value'] ?? $choice; + } + + return $choice; + } + + if ($this->isSettingsCatalogGroupSettingCollectionInstance($instance) || Str::contains($type, 'GroupSettingInstance', ignoreCase: true)) { + return '(group)'; + } + + $fallback = $instance; + unset($fallback['children']); + + return $fallback; + } + + private function stringifySettingsCatalogValue(mixed $value): string + { + if ($value === null) { + return '-'; + } + + return $this->formatSettingsCatalogValue($value); + } + + private function pruneSettingsCatalogRaw(mixed $raw): mixed + { + if (! is_array($raw)) { + return $raw; + } + + $pruned = $raw; + unset($pruned['children'], $pruned['groupSettingCollectionValue']); + + return $pruned; + } + + private function normalizeStandard(array $snapshot): array + { + $metadataKeys = [ + '@odata.context', + '@odata.type', + 'id', + 'version', + 'createdDateTime', + 'lastModifiedDateTime', + 'supportsScopeTags', + 'roleScopeTagIds', + 'assignments', + 'createdBy', + 'lastModifiedBy', + 'omaSettings', + 'settings', + 'settingsDelta', + ]; + + $filtered = Arr::except($snapshot, $metadataKeys); + $entries = []; + + foreach ($filtered as $key => $value) { + if (is_array($value)) { + $value = json_encode($value, JSON_PRETTY_PRINT); + } + + $entries[] = [ + 'key' => Str::headline((string) $key), + 'value' => $value, + ]; + } + + return [ + 'type' => 'keyValue', + 'title' => 'General', + 'entries' => $entries, + ]; + } + + /** + * Normalize Settings Catalog policy with grouped, readable settings (T011-T014). + * + * @param array $settings + * @return array{type: string, groups: array>} + */ + public function normalizeSettingsCatalogGrouped(array $settings): array + { + // Extract all definition IDs + $definitionIds = $this->extractAllDefinitionIds($settings); + + // Resolve definitions + $definitions = $this->definitionResolver->resolve($definitionIds); + + // Flatten settings + $flattened = $this->flattenSettingsCatalogForGrouping($settings); + + // Group by category + $groups = $this->groupSettingsByCategory($flattened, $definitions); + + return [ + 'type' => 'settings_catalog_grouped', + 'groups' => $groups, + ]; + } + + /** + * Extract all definition IDs from settings array recursively. + */ + private function extractAllDefinitionIds(array $settings): array + { + $ids = []; + + foreach ($settings as $setting) { + // Top-level settings have settingInstance wrapper + if (isset($setting['settingInstance']['settingDefinitionId'])) { + $ids[] = $setting['settingInstance']['settingDefinitionId']; + $instance = $setting['settingInstance']; + } + // Nested children have settingDefinitionId directly (they ARE the instance) + elseif (isset($setting['settingDefinitionId'])) { + $ids[] = $setting['settingDefinitionId']; + $instance = $setting; + } else { + continue; + } + + // Handle nested children using the comprehensive children extraction method + $children = $this->extractSettingsCatalogChildren($instance); + if (! empty($children)) { + $childIds = $this->extractAllDefinitionIds($children); + $ids = array_merge($ids, $childIds); + } + + // Also handle nested children in group collections (fallback for legacy code) + if (isset($instance['groupSettingCollectionValue'])) { + foreach ($instance['groupSettingCollectionValue'] as $group) { + if (isset($group['children']) && is_array($group['children'])) { + $childIds = $this->extractAllDefinitionIds($group['children']); + $ids = array_merge($ids, $childIds); + } + } + } + } + + return array_unique($ids); + } + + /** + * Flatten settings for grouping with value formatting. + */ + private function flattenSettingsCatalogForGrouping(array $settings): array + { + $rows = []; + + $walk = function (array $nodes, array $pathParts) use (&$walk, &$rows): void { + foreach ($nodes as $node) { + if (! is_array($node)) { + continue; + } + + $instance = $this->extractSettingsCatalogSettingInstance($node); + $definitionId = $this->extractSettingsCatalogDefinitionId($node, $instance); + $value = $this->extractSettingsCatalogValue($node, $instance); + $isGroupCollection = $this->isSettingsCatalogGroupSettingCollectionInstance($instance); + + // Only add to rows if NOT a group collection (those are containers) + if (! $isGroupCollection) { + $rows[] = [ + 'definition_id' => $definitionId, + 'value_raw' => $value, + 'value_display' => $this->formatSettingsCatalogValue($value), + 'instance_type' => is_array($instance) ? ($instance['@odata.type'] ?? null) : null, + ]; + } + + // Handle nested children + if (is_array($instance)) { + $nested = $this->extractSettingsCatalogChildren($instance); + if (! empty($nested)) { + $walk($nested, array_merge($pathParts, [$definitionId])); + } + + // Handle group collections + if ($this->isSettingsCatalogGroupSettingCollectionInstance($instance)) { + $collections = $instance['groupSettingCollectionValue'] ?? []; + if (is_array($collections)) { + foreach ($collections as $collection) { + if (isset($collection['children']) && is_array($collection['children'])) { + $walk($collection['children'], array_merge($pathParts, [$definitionId])); + } + } + } + } + } + } + }; + + $walk($settings, []); + + return $rows; + } + + /** + * Format setting value for display (T012). + */ + private function formatSettingsCatalogValue(mixed $value): string + { + if (is_bool($value)) { + return $value ? 'Enabled' : 'Disabled'; + } + + if (is_int($value)) { + return number_format($value); + } + + if (is_string($value)) { + // Remove {tenantid} placeholder + $value = str_replace(['{tenantid}', '_tenantid_'], ['', '_'], $value); + $value = preg_replace('/_+/', '_', $value); + + // Extract choice label from choice values (last meaningful part) + // Example: "device_vendor_msft_...lowercaseletters_0" -> "Lowercase Letters: 0" + if (str_contains($value, 'device_vendor_msft') || str_contains($value, 'user_vendor_msft') || str_contains($value, '#microsoft.graph')) { + $parts = explode('_', $value); + $lastPart = end($parts); + + // Check for boolean-like values + if (in_array(strtolower($lastPart), ['true', 'false'])) { + return strtolower($lastPart) === 'true' ? 'Enabled' : 'Disabled'; + } + + // If last part is just a number, take second-to-last too + if (is_numeric($lastPart) && count($parts) > 1) { + $secondLast = $parts[count($parts) - 2]; + + // Map common values + $mapping = [ + 'lowercaseletters' => 'Lowercase Letters', + 'uppercaseletters' => 'Uppercase Letters', + 'specialcharacters' => 'Special Characters', + 'digits' => 'Digits', + ]; + + if (isset($mapping[strtolower($secondLast)])) { + return $mapping[strtolower($secondLast)].': '.$lastPart; + } + + if (in_array((string) $lastPart, ['0', '1'], true)) { + return (string) $lastPart === '1' ? 'Enabled' : 'Disabled'; + } + + return Str::title($secondLast).': '.$lastPart; + } + + return Str::title($lastPart); + } + + // Truncate long strings + return Str::limit($value, 100); + } + + if (is_array($value)) { + return json_encode($value); + } + + return (string) $value; + } + + /** + * Group settings by category (T013). + */ + private function groupSettingsByCategory(array $rows, array $definitions): array + { + $grouped = []; + + foreach ($rows as $row) { + $definitionId = $row['definition_id']; + $definition = $definitions[$definitionId] ?? null; + + // Determine category + $categoryId = $definition['categoryId'] ?? $this->extractCategoryFromDefinitionId($definitionId); + $categoryTitle = $this->formatCategoryTitle($categoryId); + + if (! isset($grouped[$categoryId])) { + $grouped[$categoryId] = [ + 'title' => $categoryTitle, + 'description' => null, + 'settings' => [], + ]; + } + + $grouped[$categoryId]['settings'][] = [ + 'label' => $definition['displayName'] ?? $row['definition_id'], + 'value' => $row['value_display'], // Primary value for display + 'value_display' => $row['value_display'], + 'value_raw' => $row['value_raw'], + 'help_text' => $definition['helpText'] ?? $definition['description'] ?? null, + 'definition_id' => $definitionId, + 'instance_type' => $row['instance_type'], + 'is_fallback' => $definition['isFallback'] ?? false, + ]; + } + + // Sort groups by title + uasort($grouped, fn ($a, $b) => strcmp($a['title'], $b['title'])); + + // Sort settings within each group by label for stable ordering + foreach ($grouped as $cid => $g) { + if (isset($g['settings']) && is_array($g['settings'])) { + usort($g['settings'], function ($a, $b) { + return strcmp(strtolower($a['label'] ?? ''), strtolower($b['label'] ?? '')); + }); + + $grouped[$cid]['settings'] = $g['settings']; + } + } + + return array_values($grouped); + } + + /** + * Extract category from definition ID (fallback grouping). + */ + private function extractCategoryFromDefinitionId(string $definitionId): string + { + $parts = explode('_', $definitionId); + + // Use first 2-3 segments as category + return implode('_', array_slice($parts, 0, min(3, count($parts)))); + } + + /** + * Format category ID into readable title. + */ + private function formatCategoryTitle(string $categoryId): string + { + // Try to prettify known patterns + if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-/i', $categoryId)) { + // It's a UUID - likely a category ID from Graph + return 'Additional Settings'; + } + + // Clean up common prefixes + $title = str_replace('device_vendor_msft_', '', $categoryId); + $title = Str::title(str_replace('_', ' ', $title)); + + // Known mappings + $mappings = [ + 'Passportforwork' => 'Windows Hello for Business', + ]; + + foreach ($mappings as $search => $replace) { + $title = str_replace($search, $replace, $title); + } + + return $title; + } + + /** + * Convert technical instance type to user-friendly data type. + */ + private function getUserFriendlyDataType(?string $instanceType, mixed $value): string + { + if (! $instanceType) { + return $this->guessDataTypeFromValue($value); + } + + $type = strtolower($instanceType); + + if (str_contains($type, 'choice')) { + return 'Choice'; + } + + if (str_contains($type, 'simplesetting')) { + return $this->guessDataTypeFromValue($value); + } + + if (str_contains($type, 'groupsetting')) { + return 'Group'; + } + + return 'Text'; + } + + /** + * Guess data type from value. + */ + private function guessDataTypeFromValue(mixed $value): string + { + if (is_bool($value)) { + return 'Boolean'; + } + + if (is_int($value)) { + return 'Number'; + } + + if (is_string($value)) { + // Check if it's a boolean-like string + if (in_array(strtolower($value), ['true', 'false', 'enabled', 'disabled'])) { + return 'Boolean'; + } + + // Check if numeric string + if (is_numeric($value)) { + return 'Number'; + } + + return 'Text'; + } + + if (is_array($value)) { + return 'List'; + } + + return 'Text'; + } +} diff --git a/app/Services/Intune/DeviceConfigurationPolicyNormalizer.php b/app/Services/Intune/DeviceConfigurationPolicyNormalizer.php new file mode 100644 index 0000000..5c88051 --- /dev/null +++ b/app/Services/Intune/DeviceConfigurationPolicyNormalizer.php @@ -0,0 +1,126 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform); + + if ($snapshot === []) { + return $normalized; + } + + $normalized['settings'] = array_values(array_filter( + $normalized['settings'], + fn (array $block) => strtolower((string) ($block['title'] ?? '')) !== 'general' + )); + + $configurationBlock = $this->buildConfigurationBlock($snapshot); + + if ($configurationBlock !== null) { + $normalized['settings'][] = $configurationBlock; + } + + return $normalized; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + return $this->defaultNormalizer->flattenForDiff($snapshot, $policyType, $platform); + } + + /** + * @return array{type: string, title: string, entries: array>}|null + */ + private function buildConfigurationBlock(array $snapshot): ?array + { + $entries = []; + $ignoredKeys = $this->ignoredKeys(); + + foreach ($snapshot as $key => $value) { + if (! is_string($key)) { + continue; + } + + if (in_array($key, $ignoredKeys, true)) { + continue; + } + + $entries[] = [ + 'key' => Str::headline($key), + 'value' => $this->formatValue($value), + ]; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Configuration', + 'entries' => $entries, + ]; + } + + /** + * @return array + */ + private function ignoredKeys(): array + { + return [ + '@odata.context', + '@odata.type', + 'id', + 'version', + 'createdDateTime', + 'lastModifiedDateTime', + 'supportsScopeTags', + 'roleScopeTagIds', + 'assignments', + 'createdBy', + 'lastModifiedBy', + 'omaSettings', + 'settings', + 'settingsDelta', + 'displayName', + 'description', + 'name', + 'platform', + 'platforms', + 'technologies', + 'settingCount', + 'settingsCount', + 'templateReference', + ]; + } + + private function formatValue(mixed $value): mixed + { + if (is_array($value)) { + return json_encode($value, JSON_PRETTY_PRINT); + } + + return $value; + } +} diff --git a/app/Services/Intune/PolicyCaptureOrchestrator.php b/app/Services/Intune/PolicyCaptureOrchestrator.php index 0ac4573..b2f0971 100644 --- a/app/Services/Intune/PolicyCaptureOrchestrator.php +++ b/app/Services/Intune/PolicyCaptureOrchestrator.php @@ -58,7 +58,7 @@ public function capture( // 2. Fetch assignments if requested if ($includeAssignments) { try { - $rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions); + $rawAssignments = $this->assignmentFetcher->fetch($policy->policy_type, $tenantIdentifier, $policy->external_id, $graphOptions); if (! empty($rawAssignments)) { $resolvedGroups = []; @@ -242,7 +242,7 @@ public function ensureVersionHasAssignments( if ($includeAssignments && $version->assignments === null) { try { - $rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions); + $rawAssignments = $this->assignmentFetcher->fetch($policy->policy_type, $tenantIdentifier, $policy->external_id, $graphOptions); if (! empty($rawAssignments)) { $resolvedGroups = []; diff --git a/app/Services/Intune/PolicyNormalizer.php b/app/Services/Intune/PolicyNormalizer.php index 316b47d..e46544e 100644 --- a/app/Services/Intune/PolicyNormalizer.php +++ b/app/Services/Intune/PolicyNormalizer.php @@ -2,912 +2,65 @@ namespace App\Services\Intune; -use App\Models\Policy; -use Illuminate\Support\Arr; -use Illuminate\Support\Str; +use Illuminate\Container\Attributes\Tag; class PolicyNormalizer { - private const SETTINGS_CATALOG_MAX_ROWS = 1000; - - private const SETTINGS_CATALOG_MAX_DEPTH = 8; - /** - * Normalize raw Intune snapshots into display-friendly blocks and warnings. + * @var array */ + private array $typeNormalizers; + public function __construct( - private readonly SnapshotValidator $validator, - private readonly SettingsCatalogDefinitionResolver $definitionResolver, - private readonly SettingsCatalogCategoryResolver $categoryResolver, - ) {} + private readonly DefaultPolicyNormalizer $defaultNormalizer, + #[Tag('policy-type-normalizers')] + iterable $typeNormalizers = [], + ) { + $normalizers = is_array($typeNormalizers) + ? $typeNormalizers + : iterator_to_array($typeNormalizers); - /** - * @return array{status: string, settings: array>, settings_table?: array, warnings: array, context?: string, record_id?: string} - */ - public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array - { - $snapshot = $snapshot ?? []; - $resultWarnings = []; - $status = 'success'; - $settingsTable = null; - - $validation = $this->validator->validate($snapshot); - $resultWarnings = array_merge($resultWarnings, $validation['warnings']); - - $odataWarning = Policy::odataTypeWarning($snapshot, $policyType, $platform); - - if ($odataWarning) { - $resultWarnings[] = $odataWarning; - } - - if ($snapshot === []) { - return [ - 'status' => 'warning', - 'settings' => [], - 'warnings' => array_values(array_unique(array_merge($resultWarnings, ['No snapshot available']))), - ]; - } - - $settings = []; - - if (isset($snapshot['omaSettings']) && is_array($snapshot['omaSettings'])) { - $settings[] = $this->normalizeOmaSettings($snapshot['omaSettings']); - } - - if (isset($snapshot['settings']) && is_array($snapshot['settings'])) { - if ($policyType === 'settingsCatalogPolicy') { - $normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settings']); - $settingsTable = $normalized['table']; - $resultWarnings = array_merge($resultWarnings, $normalized['warnings']); - } else { - $settings[] = $this->normalizeSettingsCatalog($snapshot['settings']); - } - } elseif (isset($snapshot['settingsDelta']) && is_array($snapshot['settingsDelta'])) { - if ($policyType === 'settingsCatalogPolicy') { - $normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settingsDelta'], 'Settings delta'); - $settingsTable = $normalized['table']; - $resultWarnings = array_merge($resultWarnings, $normalized['warnings']); - } else { - $settings[] = $this->normalizeSettingsCatalog($snapshot['settingsDelta'], 'Settings delta'); - } - } elseif ($policyType === 'settingsCatalogPolicy') { - $resultWarnings[] = 'Settings not hydrated for this Settings Catalog policy.'; - } - - $settings[] = $this->normalizeStandard($snapshot); - - if (! empty($resultWarnings)) { - $status = 'warning'; - } - - $result = [ - 'status' => $status, - 'settings' => array_values(array_filter($settings)), - 'warnings' => array_values(array_unique($resultWarnings)), - ]; - - if (is_array($settingsTable) && ! empty($settingsTable['rows'] ?? [])) { - $result['settings_table'] = $settingsTable; - } - - return $result; + $this->typeNormalizers = array_values(array_filter( + $normalizers, + fn (mixed $normalizer) => $normalizer instanceof PolicyTypeNormalizer + )); + } + + /** + * @return array{status: string, settings: array>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + return $this->resolveNormalizer($policyType) + ->normalize($snapshot, $policyType, $platform); } /** - * Flatten normalized settings into key/value pairs for diffing. - * * @return array */ public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array { - $normalized = $this->normalize($snapshot ?? [], $policyType, $platform); - $map = []; - - if (isset($normalized['settings_table']['rows']) && is_array($normalized['settings_table']['rows'])) { - foreach ($normalized['settings_table']['rows'] as $row) { - if (! is_array($row)) { - continue; - } - - $key = $row['path'] ?? $row['definition'] ?? 'entry'; - $map[$key] = $row['value'] ?? null; - } - } - - foreach ($normalized['settings'] as $block) { - if (($block['type'] ?? null) === 'table') { - foreach ($block['rows'] ?? [] as $row) { - $key = $row['path'] ?? $row['label'] ?? 'entry'; - $map[$key] = $row['value'] ?? null; - } - - continue; - } - - foreach ($block['entries'] ?? [] as $entry) { - $key = $entry['key'] ?? 'entry'; - $map[$key] = $entry['value'] ?? null; - } - } - - return $map; + return $this->resolveNormalizer($policyType) + ->flattenForDiff($snapshot, $policyType, $platform); } /** - * @param array> $omaSettings - */ - private function normalizeOmaSettings(array $omaSettings): array - { - $rows = []; - - foreach ($omaSettings as $setting) { - if (! is_array($setting)) { - continue; - } - - $rows[] = [ - 'path' => $setting['omaUri'] ?? $setting['path'] ?? 'n/a', - 'value' => $setting['value'] ?? $setting['displayValue'] ?? $setting['secretReferenceValueId'] ?? null, - 'label' => $setting['displayName'] ?? null, - 'description' => $setting['description'] ?? null, - ]; - } - - return [ - 'type' => 'table', - 'title' => 'OMA-URI settings', - 'rows' => $rows, - ]; - } - - /** - * @param array> $settings - */ - private function normalizeSettingsCatalog(array $settings, string $title = 'Settings'): array - { - $entries = []; - - foreach ($settings as $setting) { - if (! is_array($setting)) { - continue; - } - - $key = $setting['displayName'] ?? $setting['name'] ?? $setting['definitionId'] ?? 'setting'; - $value = $setting['value'] ?? $setting['settingInstance']['simpleSettingValue'] ?? $setting['simpleSettingValue'] ?? null; - - if ($value === null && isset($setting['value']['value'])) { - $value = $setting['value']['value']; - } - - if (is_array($value)) { - $value = json_encode($value, JSON_PRETTY_PRINT); - } - - $entries[] = [ - 'key' => $key, - 'value' => $value, - ]; - } - - return [ - 'type' => 'keyValue', - 'title' => $title, - 'entries' => $entries, - ]; - } - - /** - * @param array $settings - * @return array{table: array, warnings: array} - */ - private function buildSettingsCatalogSettingsTable(array $settings, string $title = 'Settings'): array - { - $flattened = $this->flattenSettingsCatalogSettingInstances($settings); - - return [ - 'table' => [ - 'title' => $title, - 'rows' => $flattened['rows'], - ], - 'warnings' => $flattened['warnings'], - ]; - } - - /** - * @param array $settings - * @return array{rows: array>, warnings: array} - */ - private function flattenSettingsCatalogSettingInstances(array $settings): array - { - $rows = []; - $warnings = []; - $rowCount = 0; - $warnedDepthLimit = false; - $warnedRowLimit = false; - - // Extract all definition IDs first to resolve display names in batch - $definitionIds = $this->extractAllDefinitionIds($settings); - $definitions = $this->definitionResolver->resolve($definitionIds); - - // Extract all category IDs and resolve them - $categoryIds = array_filter(array_unique(array_map( - fn ($def) => $def['categoryId'] ?? null, - $definitions - ))); - $categories = $this->categoryResolver->resolve($categoryIds); - $categoryNames = []; - - foreach ($categoryIds as $categoryId) { - $categoryName = $categories[$categoryId]['displayName'] ?? null; - - if (is_string($categoryName) && $categoryName !== '') { - $categoryNames[] = $categoryName; - } - } - - $categoryNames = array_values(array_unique($categoryNames)); - $defaultCategoryName = count($categoryNames) === 1 ? $categoryNames[0] : null; - - $walk = function (array $nodes, array $pathParts, int $depth) use ( - &$walk, - &$rows, - &$warnings, - &$rowCount, - &$warnedDepthLimit, - &$warnedRowLimit, - $definitions, - $categories, - $defaultCategoryName - ): void { - if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) { - if (! $warnedRowLimit) { - $warnings[] = sprintf('Settings truncated after %d rows.', self::SETTINGS_CATALOG_MAX_ROWS); - $warnedRowLimit = true; - } - - return; - } - - if ($depth > self::SETTINGS_CATALOG_MAX_DEPTH) { - if (! $warnedDepthLimit) { - $warnings[] = sprintf('Settings nesting truncated after depth %d.', self::SETTINGS_CATALOG_MAX_DEPTH); - $warnedDepthLimit = true; - } - - return; - } - - foreach ($nodes as $node) { - if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) { - break; - } - - if (! is_array($node)) { - continue; - } - - $instance = $this->extractSettingsCatalogSettingInstance($node); - $definitionId = $this->extractSettingsCatalogDefinitionId($node, $instance); - $instanceType = is_array($instance) ? ($instance['@odata.type'] ?? $node['@odata.type'] ?? null) : ($node['@odata.type'] ?? null); - $rawInstanceType = is_string($instanceType) ? ltrim($instanceType, '#') : null; - - $currentPathParts = array_merge($pathParts, [$definitionId]); - $path = implode(' > ', $currentPathParts); - - $value = $this->extractSettingsCatalogValue($node, $instance); - - // Get metadata from resolved definitions - $definition = $definitions[$definitionId] ?? null; - $displayName = $definition['displayName'] ?? - $this->definitionResolver->prettifyDefinitionId($definitionId); - $categoryId = $definition['categoryId'] ?? null; - $categoryName = $categoryId ? ($categories[$categoryId]['displayName'] ?? '-') : '-'; - $description = $definition['description'] ?? null; - - if (! $categoryId && ! empty($pathParts)) { - foreach (array_reverse($pathParts) as $ancestorDefinitionId) { - if (! is_string($ancestorDefinitionId) || $ancestorDefinitionId === '') { - continue; - } - - $ancestorDefinition = $definitions[$ancestorDefinitionId] ?? null; - $ancestorCategoryId = $ancestorDefinition['categoryId'] ?? null; - - if ($ancestorCategoryId) { - $categoryId = $ancestorCategoryId; - $categoryName = $categories[$categoryId]['displayName'] ?? '-'; - break; - } - } - } - - if ( - ! $categoryId - && $defaultCategoryName - && (str_contains($definitionId, '{') || str_contains($definitionId, '}')) - ) { - $categoryName = $defaultCategoryName; - } - - // Convert technical type to user-friendly data type - $dataType = $this->getUserFriendlyDataType($rawInstanceType, $value); - - $rows[] = [ - 'definition' => $displayName, - 'definition_id' => $definitionId, - 'category' => $categoryName, - 'data_type' => $dataType, - 'value' => $this->stringifySettingsCatalogValue($value), - 'description' => $description ? Str::limit($description, 100) : '-', - 'path' => $path, - 'raw' => $this->pruneSettingsCatalogRaw($instance ?? $node), - ]; - - $rowCount++; - - if (! is_array($instance)) { - continue; - } - - $nested = $this->extractSettingsCatalogChildren($instance); - - if (! empty($nested)) { - $walk($nested, $currentPathParts, $depth + 1); - } - - if ($this->isSettingsCatalogGroupSettingCollectionInstance($instance)) { - $collections = $instance['groupSettingCollectionValue'] ?? []; - - if (! is_array($collections)) { - continue; - } - - foreach (array_values($collections) as $index => $collection) { - if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) { - break; - } - - if (! is_array($collection)) { - continue; - } - - $children = $collection['children'] ?? []; - - if (! is_array($children) || empty($children)) { - continue; - } - - $walk($children, array_merge($currentPathParts, ['['.($index + 1).']']), $depth + 1); - } - } - } - }; - - $walk($settings, [], 1); - - return [ - 'rows' => array_slice($rows, 0, self::SETTINGS_CATALOG_MAX_ROWS), - 'warnings' => $warnings, - ]; - } - - private function extractSettingsCatalogSettingInstance(array $setting): ?array - { - $instance = $setting['settingInstance'] ?? null; - - if (is_array($instance)) { - return $instance; - } - - if (isset($setting['@odata.type']) && (isset($setting['settingDefinitionId']) || isset($setting['definitionId']))) { - return $setting; - } - - return null; - } - - private function extractSettingsCatalogDefinitionId(array $setting, ?array $instance): string - { - $candidates = [ - $setting['definitionId'] ?? null, - $setting['settingDefinitionId'] ?? null, - $setting['name'] ?? null, - $setting['displayName'] ?? null, - $instance['settingDefinitionId'] ?? null, - $instance['definitionId'] ?? null, - ]; - - foreach ($candidates as $candidate) { - if (is_string($candidate) && $candidate !== '') { - return $candidate; - } - } - - return 'setting'; - } - - private function formatSettingsCatalogInstanceType(?string $type): ?string - { - if (! $type) { - return null; - } - - $type = Str::afterLast($type, '.'); - - foreach (['deviceManagementConfiguration', 'deviceManagement'] as $prefix) { - if (Str::startsWith($type, $prefix)) { - $type = substr($type, strlen($prefix)); - - break; - } - } - - return $type !== '' ? $type : null; - } - - private function isSettingsCatalogGroupSettingCollectionInstance(array $instance): bool - { - $type = $instance['@odata.type'] ?? null; - - if (! is_string($type)) { - return false; - } - - return Str::contains($type, 'GroupSettingCollectionInstance', ignoreCase: true); - } - - /** - * @return array - */ - private function extractSettingsCatalogChildren(array $instance): array - { - foreach (['children', 'choiceSettingValue.children', 'groupSettingValue.children'] as $path) { - $children = Arr::get($instance, $path); - - if (is_array($children) && ! empty($children)) { - return $children; - } - } - - return []; - } - - private function extractSettingsCatalogValue(array $setting, ?array $instance): mixed - { - if ($instance === null) { - return $setting['value'] ?? null; - } - - $type = $instance['@odata.type'] ?? null; - $type = is_string($type) ? $type : ''; - - if (Str::contains($type, 'SimpleSettingInstance', ignoreCase: true)) { - $simple = $instance['simpleSettingValue'] ?? null; - - if (is_array($simple)) { - return $simple['value'] ?? $simple; - } - - return $simple; - } - - if (Str::contains($type, 'ChoiceSettingInstance', ignoreCase: true)) { - $choice = $instance['choiceSettingValue'] ?? null; - - if (is_array($choice)) { - return $choice['value'] ?? $choice; - } - - return $choice; - } - - if ($this->isSettingsCatalogGroupSettingCollectionInstance($instance) || Str::contains($type, 'GroupSettingInstance', ignoreCase: true)) { - return '(group)'; - } - - $fallback = $instance; - unset($fallback['children']); - - return $fallback; - } - - private function stringifySettingsCatalogValue(mixed $value): string - { - if ($value === null) { - return '-'; - } - - return $this->formatSettingsCatalogValue($value); - } - - private function pruneSettingsCatalogRaw(mixed $raw): mixed - { - if (! is_array($raw)) { - return $raw; - } - - $pruned = $raw; - unset($pruned['children'], $pruned['groupSettingCollectionValue']); - - return $pruned; - } - - private function normalizeStandard(array $snapshot): array - { - $metadataKeys = [ - '@odata.context', - '@odata.type', - 'id', - 'version', - 'createdDateTime', - 'lastModifiedDateTime', - 'supportsScopeTags', - 'roleScopeTagIds', - 'assignments', - 'createdBy', - 'lastModifiedBy', - 'omaSettings', - 'settings', - 'settingsDelta', - ]; - - $filtered = Arr::except($snapshot, $metadataKeys); - $entries = []; - - foreach ($filtered as $key => $value) { - if (is_array($value)) { - $value = json_encode($value, JSON_PRETTY_PRINT); - } - - $entries[] = [ - 'key' => Str::headline((string) $key), - 'value' => $value, - ]; - } - - return [ - 'type' => 'keyValue', - 'title' => 'General', - 'entries' => $entries, - ]; - } - - /** - * Normalize Settings Catalog policy with grouped, readable settings (T011-T014). - * * @param array $settings * @return array{type: string, groups: array>} */ public function normalizeSettingsCatalogGrouped(array $settings): array { - // Extract all definition IDs - $definitionIds = $this->extractAllDefinitionIds($settings); - - // Resolve definitions - $definitions = $this->definitionResolver->resolve($definitionIds); - - // Flatten settings - $flattened = $this->flattenSettingsCatalogForGrouping($settings); - - // Group by category - $groups = $this->groupSettingsByCategory($flattened, $definitions); - - return [ - 'type' => 'settings_catalog_grouped', - 'groups' => $groups, - ]; + return $this->defaultNormalizer->normalizeSettingsCatalogGrouped($settings); } - /** - * Extract all definition IDs from settings array recursively. - */ - private function extractAllDefinitionIds(array $settings): array + private function resolveNormalizer(string $policyType): PolicyTypeNormalizer { - $ids = []; - - foreach ($settings as $setting) { - // Top-level settings have settingInstance wrapper - if (isset($setting['settingInstance']['settingDefinitionId'])) { - $ids[] = $setting['settingInstance']['settingDefinitionId']; - $instance = $setting['settingInstance']; - } - // Nested children have settingDefinitionId directly (they ARE the instance) - elseif (isset($setting['settingDefinitionId'])) { - $ids[] = $setting['settingDefinitionId']; - $instance = $setting; - } else { - continue; - } - - // Handle nested children using the comprehensive children extraction method - $children = $this->extractSettingsCatalogChildren($instance); - if (! empty($children)) { - $childIds = $this->extractAllDefinitionIds($children); - $ids = array_merge($ids, $childIds); - } - - // Also handle nested children in group collections (fallback for legacy code) - if (isset($instance['groupSettingCollectionValue'])) { - foreach ($instance['groupSettingCollectionValue'] as $group) { - if (isset($group['children']) && is_array($group['children'])) { - $childIds = $this->extractAllDefinitionIds($group['children']); - $ids = array_merge($ids, $childIds); - } - } + foreach ($this->typeNormalizers as $normalizer) { + if ($normalizer->supports($policyType)) { + return $normalizer; } } - return array_unique($ids); - } - - /** - * Flatten settings for grouping with value formatting. - */ - private function flattenSettingsCatalogForGrouping(array $settings): array - { - $rows = []; - - $walk = function (array $nodes, array $pathParts) use (&$walk, &$rows): void { - foreach ($nodes as $node) { - if (! is_array($node)) { - continue; - } - - $instance = $this->extractSettingsCatalogSettingInstance($node); - $definitionId = $this->extractSettingsCatalogDefinitionId($node, $instance); - $value = $this->extractSettingsCatalogValue($node, $instance); - $isGroupCollection = $this->isSettingsCatalogGroupSettingCollectionInstance($instance); - - // Only add to rows if NOT a group collection (those are containers) - if (! $isGroupCollection) { - $rows[] = [ - 'definition_id' => $definitionId, - 'value_raw' => $value, - 'value_display' => $this->formatSettingsCatalogValue($value), - 'instance_type' => is_array($instance) ? ($instance['@odata.type'] ?? null) : null, - ]; - } - - // Handle nested children - if (is_array($instance)) { - $nested = $this->extractSettingsCatalogChildren($instance); - if (! empty($nested)) { - $walk($nested, array_merge($pathParts, [$definitionId])); - } - - // Handle group collections - if ($this->isSettingsCatalogGroupSettingCollectionInstance($instance)) { - $collections = $instance['groupSettingCollectionValue'] ?? []; - if (is_array($collections)) { - foreach ($collections as $collection) { - if (isset($collection['children']) && is_array($collection['children'])) { - $walk($collection['children'], array_merge($pathParts, [$definitionId])); - } - } - } - } - } - } - }; - - $walk($settings, []); - - return $rows; - } - - /** - * Format setting value for display (T012). - */ - private function formatSettingsCatalogValue(mixed $value): string - { - if (is_bool($value)) { - return $value ? 'Enabled' : 'Disabled'; - } - - if (is_int($value)) { - return number_format($value); - } - - if (is_string($value)) { - // Remove {tenantid} placeholder - $value = str_replace(['{tenantid}', '_tenantid_'], ['', '_'], $value); - $value = preg_replace('/_+/', '_', $value); - - // Extract choice label from choice values (last meaningful part) - // Example: "device_vendor_msft_...lowercaseletters_0" -> "Lowercase Letters: 0" - if (str_contains($value, 'device_vendor_msft') || str_contains($value, 'user_vendor_msft') || str_contains($value, '#microsoft.graph')) { - $parts = explode('_', $value); - $lastPart = end($parts); - - // Check for boolean-like values - if (in_array(strtolower($lastPart), ['true', 'false'])) { - return strtolower($lastPart) === 'true' ? 'Enabled' : 'Disabled'; - } - - // If last part is just a number, take second-to-last too - if (is_numeric($lastPart) && count($parts) > 1) { - $secondLast = $parts[count($parts) - 2]; - - // Map common values - $mapping = [ - 'lowercaseletters' => 'Lowercase Letters', - 'uppercaseletters' => 'Uppercase Letters', - 'specialcharacters' => 'Special Characters', - 'digits' => 'Digits', - ]; - - if (isset($mapping[strtolower($secondLast)])) { - return $mapping[strtolower($secondLast)].': '.$lastPart; - } - - if (in_array((string) $lastPart, ['0', '1'], true)) { - return (string) $lastPart === '1' ? 'Enabled' : 'Disabled'; - } - - return Str::title($secondLast).': '.$lastPart; - } - - return Str::title($lastPart); - } - - // Truncate long strings - return Str::limit($value, 100); - } - - if (is_array($value)) { - return json_encode($value); - } - - return (string) $value; - } - - /** - * Group settings by category (T013). - */ - private function groupSettingsByCategory(array $rows, array $definitions): array - { - $grouped = []; - - foreach ($rows as $row) { - $definitionId = $row['definition_id']; - $definition = $definitions[$definitionId] ?? null; - - // Determine category - $categoryId = $definition['categoryId'] ?? $this->extractCategoryFromDefinitionId($definitionId); - $categoryTitle = $this->formatCategoryTitle($categoryId); - - if (! isset($grouped[$categoryId])) { - $grouped[$categoryId] = [ - 'title' => $categoryTitle, - 'description' => null, - 'settings' => [], - ]; - } - - $grouped[$categoryId]['settings'][] = [ - 'label' => $definition['displayName'] ?? $row['definition_id'], - 'value' => $row['value_display'], // Primary value for display - 'value_display' => $row['value_display'], - 'value_raw' => $row['value_raw'], - 'help_text' => $definition['helpText'] ?? $definition['description'] ?? null, - 'definition_id' => $definitionId, - 'instance_type' => $row['instance_type'], - 'is_fallback' => $definition['isFallback'] ?? false, - ]; - } - - // Sort groups by title - uasort($grouped, fn ($a, $b) => strcmp($a['title'], $b['title'])); - - // Sort settings within each group by label for stable ordering - foreach ($grouped as $cid => $g) { - if (isset($g['settings']) && is_array($g['settings'])) { - usort($g['settings'], function ($a, $b) { - return strcmp(strtolower($a['label'] ?? ''), strtolower($b['label'] ?? '')); - }); - - $grouped[$cid]['settings'] = $g['settings']; - } - } - - return array_values($grouped); - } - - /** - * Extract category from definition ID (fallback grouping). - */ - private function extractCategoryFromDefinitionId(string $definitionId): string - { - $parts = explode('_', $definitionId); - - // Use first 2-3 segments as category - return implode('_', array_slice($parts, 0, min(3, count($parts)))); - } - - /** - * Format category ID into readable title. - */ - private function formatCategoryTitle(string $categoryId): string - { - // Try to prettify known patterns - if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-/i', $categoryId)) { - // It's a UUID - likely a category ID from Graph - return 'Additional Settings'; - } - - // Clean up common prefixes - $title = str_replace('device_vendor_msft_', '', $categoryId); - $title = Str::title(str_replace('_', ' ', $title)); - - // Known mappings - $mappings = [ - 'Passportforwork' => 'Windows Hello for Business', - ]; - - foreach ($mappings as $search => $replace) { - $title = str_replace($search, $replace, $title); - } - - return $title; - } - - /** - * Convert technical instance type to user-friendly data type. - */ - private function getUserFriendlyDataType(?string $instanceType, mixed $value): string - { - if (! $instanceType) { - return $this->guessDataTypeFromValue($value); - } - - $type = strtolower($instanceType); - - if (str_contains($type, 'choice')) { - return 'Choice'; - } - - if (str_contains($type, 'simplesetting')) { - return $this->guessDataTypeFromValue($value); - } - - if (str_contains($type, 'groupsetting')) { - return 'Group'; - } - - return 'Text'; - } - - /** - * Guess data type from value. - */ - private function guessDataTypeFromValue(mixed $value): string - { - if (is_bool($value)) { - return 'Boolean'; - } - - if (is_int($value)) { - return 'Number'; - } - - if (is_string($value)) { - // Check if it's a boolean-like string - if (in_array(strtolower($value), ['true', 'false', 'enabled', 'disabled'])) { - return 'Boolean'; - } - - // Check if numeric string - if (is_numeric($value)) { - return 'Number'; - } - - return 'Text'; - } - - if (is_array($value)) { - return 'List'; - } - - return 'Text'; + return $this->defaultNormalizer; } } diff --git a/app/Services/Intune/PolicySyncService.php b/app/Services/Intune/PolicySyncService.php index 6ed859c..69cc514 100644 --- a/app/Services/Intune/PolicySyncService.php +++ b/app/Services/Intune/PolicySyncService.php @@ -36,11 +36,13 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr foreach ($types as $typeConfig) { $policyType = $typeConfig['type']; $platform = $typeConfig['platform'] ?? null; + $filter = $typeConfig['filter'] ?? null; $this->graphLogger->logRequest('list_policies', [ 'tenant' => $tenantIdentifier, 'policy_type' => $policyType, 'platform' => $platform, + 'filter' => $filter, ]); try { @@ -49,6 +51,7 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr 'client_id' => $tenant->app_client_id, 'client_secret' => $tenant->app_client_secret, 'platform' => $platform, + 'filter' => $filter, ]); } catch (Throwable $throwable) { throw GraphErrorMapper::fromThrowable($throwable, [ diff --git a/app/Services/Intune/PolicyTypeNormalizer.php b/app/Services/Intune/PolicyTypeNormalizer.php new file mode 100644 index 0000000..c111dd4 --- /dev/null +++ b/app/Services/Intune/PolicyTypeNormalizer.php @@ -0,0 +1,18 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array; + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array; +} diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index 06622fc..8fc3aab 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -43,9 +43,16 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt [$foundationItems, $policyItems] = $this->splitItems($items); + $notificationTemplateIds = $foundationItems + ->where('policy_type', 'notificationMessageTemplate') + ->pluck('policy_identifier') + ->filter() + ->values() + ->all(); + $foundationPreview = $this->foundationMappingService->map($tenant, $foundationItems, false)['entries'] ?? []; - $policyPreview = $policyItems->map(function (BackupItem $item) use ($tenant) { + $policyPreview = $policyItems->map(function (BackupItem $item) use ($tenant, $notificationTemplateIds) { $existing = Policy::query() ->where('tenant_id', $tenant->id) ->where('external_id', $item->policy_identifier) @@ -54,7 +61,7 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt $restoreMode = $this->resolveRestoreMode($item->policy_type); - return [ + $preview = [ 'backup_item_id' => $item->id, 'policy_identifier' => $item->policy_identifier, 'policy_type' => $item->policy_type, @@ -68,6 +75,18 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt $item->platform ) ?? ($this->snapshotValidator->validate(is_array($item->payload) ? $item->payload : [])['warnings'][0] ?? null), ]; + + if ($item->policy_type === 'deviceCompliancePolicy') { + $preview = array_merge( + $preview, + $this->previewComplianceNotificationTemplates( + payload: is_array($item->payload) ? $item->payload : [], + availableTemplateIds: $notificationTemplateIds + ) + ); + } + + return $preview; })->all(); return array_merge($foundationPreview, $policyPreview); @@ -201,6 +220,16 @@ public function execute( try { $originalPayload = is_array($item->payload) ? $item->payload : []; $originalPayload = $this->applyScopeTagMapping($originalPayload, $scopeTagMapping); + $complianceActionSummary = null; + $complianceActionOutcomes = null; + + if ($item->policy_type === 'deviceCompliancePolicy') { + [$originalPayload, $complianceActionSummary, $complianceActionOutcomes] = $this->applyComplianceNotificationTemplateMapping( + payload: $originalPayload, + templateMapping: $foundationMappingByType['notificationMessageTemplate'] ?? [] + ); + } + $mappedScopeTagIds = $this->resolvePayloadArray($originalPayload, ['roleScopeTagIds', 'RoleScopeTagIds']); // sanitize high-level fields according to contract @@ -358,6 +387,25 @@ public function execute( $itemStatus = 'partial'; $resultReason = 'Assignments restored with failures'; } + + } + + if (is_array($complianceActionSummary) && ($complianceActionSummary['skipped'] ?? 0) > 0 && $itemStatus === 'applied') { + $itemStatus = 'partial'; + $resultReason = 'Compliance notification actions skipped'; + } + + if ($complianceActionSummary !== null) { + $this->auditComplianceActionMapping( + tenant: $tenant, + restoreRun: $restoreRun, + policyId: $item->policy_identifier, + policyType: $item->policy_type, + summary: $complianceActionSummary, + outcomes: $complianceActionOutcomes ?? [], + actorEmail: $actorEmail, + actorName: $actorName + ); } $result = $context + [ @@ -391,6 +439,14 @@ public function execute( $result['assignment_summary'] = $assignmentSummary; } + if ($complianceActionSummary !== null) { + $result['compliance_action_summary'] = $complianceActionSummary; + } + + if ($complianceActionOutcomes !== null) { + $result['compliance_action_outcomes'] = $complianceActionOutcomes; + } + $results[] = $result; $appliedPolicyId = $item->policy_identifier; @@ -599,6 +655,230 @@ private function applyScopeTagIdsToPayload(array $payload, ?array $scopeTagIds, return $payload; } + /** + * @param array $payload + * @param array $availableTemplateIds + * @return array + */ + private function previewComplianceNotificationTemplates(array $payload, array $availableTemplateIds): array + { + $templateIds = $this->collectComplianceNotificationTemplateIds($payload); + + if ($templateIds === []) { + return []; + } + + $available = array_values(array_unique($availableTemplateIds)); + $missing = array_values(array_diff($templateIds, $available)); + $summary = [ + 'total' => count($templateIds), + 'missing' => count($missing), + ]; + + $warning = null; + + if ($missing !== []) { + $warning = sprintf('Missing %d notification template(s); notification actions may be skipped.', count($missing)); + } + + return array_filter([ + 'compliance_action_summary' => $summary, + 'compliance_action_warning' => $warning, + 'compliance_action_missing_templates' => $missing !== [] ? $missing : null, + ], static fn ($value) => $value !== null); + } + + /** + * @param array $payload + * @param array $templateMapping + * @return array{0: array, 1: ?array{total:int,mapped:int,skipped:int}, 2: ?array>} + */ + private function applyComplianceNotificationTemplateMapping(array $payload, array $templateMapping): array + { + $scheduled = $payload['scheduledActionsForRule'] ?? null; + + if (! is_array($scheduled)) { + return [$payload, null, null]; + } + + $rules = []; + $total = 0; + $mapped = 0; + $skipped = 0; + $outcomes = []; + + foreach ($scheduled as $rule) { + if (! is_array($rule)) { + continue; + } + + $configs = $rule['scheduledActionConfigurations'] ?? null; + + if (! is_array($configs)) { + $rules[] = $rule; + + continue; + } + + $ruleName = $rule['ruleName'] ?? null; + $updatedConfigs = []; + + foreach ($configs as $config) { + if (! is_array($config)) { + $updatedConfigs[] = $config; + + continue; + } + + $actionType = $config['actionType'] ?? null; + $templateKey = $this->resolveNotificationTemplateKey($config); + + if ($actionType !== 'notification' || $templateKey === null) { + $updatedConfigs[] = $config; + + continue; + } + + $templateId = $config[$templateKey] ?? null; + + if (! is_string($templateId) || $templateId === '' || $this->isEmptyGuid($templateId)) { + $updatedConfigs[] = $config; + + continue; + } + + $total++; + + if ($templateMapping === []) { + $outcomes[] = [ + 'status' => 'skipped', + 'template_id' => $templateId, + 'rule_name' => $ruleName, + 'reason' => 'Notification template mapping unavailable.', + ]; + $skipped++; + + continue; + } + + $mappedTemplateId = $templateMapping[$templateId] ?? null; + + if (! is_string($mappedTemplateId) || $mappedTemplateId === '') { + $outcomes[] = [ + 'status' => 'skipped', + 'template_id' => $templateId, + 'rule_name' => $ruleName, + 'reason' => 'Notification template mapping missing for template ID.', + ]; + $skipped++; + + continue; + } + + $config[$templateKey] = $mappedTemplateId; + $updatedConfigs[] = $config; + $mapped++; + + $outcomes[] = [ + 'status' => 'mapped', + 'template_id' => $templateId, + 'mapped_template_id' => $mappedTemplateId, + 'rule_name' => $ruleName, + ]; + } + + if ($updatedConfigs === []) { + continue; + } + + $rule['scheduledActionConfigurations'] = array_values($updatedConfigs); + $rules[] = $rule; + } + + if ($rules !== []) { + $payload['scheduledActionsForRule'] = array_values($rules); + } else { + unset($payload['scheduledActionsForRule']); + } + + if ($total === 0) { + return [$payload, null, null]; + } + + return [$payload, ['total' => $total, 'mapped' => $mapped, 'skipped' => $skipped], $outcomes]; + } + + /** + * @param array $payload + * @return array + */ + private function collectComplianceNotificationTemplateIds(array $payload): array + { + $scheduled = $payload['scheduledActionsForRule'] ?? null; + + if (! is_array($scheduled)) { + return []; + } + + $ids = []; + + foreach ($scheduled as $rule) { + if (! is_array($rule)) { + continue; + } + + $configs = $rule['scheduledActionConfigurations'] ?? null; + + if (! is_array($configs)) { + continue; + } + + foreach ($configs as $config) { + if (! is_array($config)) { + continue; + } + + if (($config['actionType'] ?? null) !== 'notification') { + continue; + } + + $templateKey = $this->resolveNotificationTemplateKey($config); + + if ($templateKey === null) { + continue; + } + + $templateId = $config[$templateKey] ?? null; + + if (! is_string($templateId) || $templateId === '' || $this->isEmptyGuid($templateId)) { + continue; + } + + $ids[] = $templateId; + } + } + + return array_values(array_unique($ids)); + } + + private function resolveNotificationTemplateKey(array $config): ?string + { + if (array_key_exists('notificationTemplateId', $config)) { + return 'notificationTemplateId'; + } + + if (array_key_exists('notificationMessageTemplateId', $config)) { + return 'notificationMessageTemplateId'; + } + + return null; + } + + private function isEmptyGuid(string $value): bool + { + return strtolower($value) === '00000000-0000-0000-0000-000000000000'; + } + /** * @param array> $entries */ @@ -653,6 +933,51 @@ private function auditFoundationMapping( } } + /** + * @param array{total:int,mapped:int,skipped:int} $summary + * @param array> $outcomes + */ + private function auditComplianceActionMapping( + Tenant $tenant, + RestoreRun $restoreRun, + string $policyId, + string $policyType, + array $summary, + array $outcomes, + ?string $actorEmail, + ?string $actorName + ): void { + $skipped = (int) ($summary['skipped'] ?? 0); + $status = $skipped > 0 ? 'warning' : 'success'; + $skippedTemplates = collect($outcomes) + ->filter(fn (array $outcome) => ($outcome['status'] ?? null) === 'skipped') + ->pluck('template_id') + ->filter() + ->values() + ->all(); + + $this->auditLogger->log( + tenant: $tenant, + action: 'restore.compliance.actions.mapped', + context: [ + 'metadata' => [ + 'restore_run_id' => $restoreRun->id, + 'policy_id' => $policyId, + 'policy_type' => $policyType, + 'total' => (int) ($summary['total'] ?? 0), + 'mapped' => (int) ($summary['mapped'] ?? 0), + 'skipped' => $skipped, + 'skipped_template_ids' => $skippedTemplates, + ], + ], + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'restore_run', + resourceId: (string) $restoreRun->id, + status: $status + ); + } + /** * @param array|null $selectedItemIds */ diff --git a/app/Services/Intune/SettingsCatalogPolicyNormalizer.php b/app/Services/Intune/SettingsCatalogPolicyNormalizer.php new file mode 100644 index 0000000..6fc64c1 --- /dev/null +++ b/app/Services/Intune/SettingsCatalogPolicyNormalizer.php @@ -0,0 +1,31 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + return $this->defaultNormalizer->normalize($snapshot, $policyType, $platform); + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + return $this->defaultNormalizer->flattenForDiff($snapshot, $policyType, $platform); + } +} diff --git a/app/Services/Intune/VersionService.php b/app/Services/Intune/VersionService.php index 3d4f4da..214fcb6 100644 --- a/app/Services/Intune/VersionService.php +++ b/app/Services/Intune/VersionService.php @@ -91,7 +91,7 @@ public function captureFromGraph( if ($includeAssignments) { try { - $rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions); + $rawAssignments = $this->assignmentFetcher->fetch($policy->policy_type, $tenantIdentifier, $policy->external_id, $graphOptions); if (! empty($rawAssignments)) { $resolvedGroups = []; diff --git a/app/Support/Concerns/InteractsWithODataTypes.php b/app/Support/Concerns/InteractsWithODataTypes.php index 843ee0e..eb5274a 100644 --- a/app/Support/Concerns/InteractsWithODataTypes.php +++ b/app/Support/Concerns/InteractsWithODataTypes.php @@ -17,10 +17,18 @@ protected static function odataTypeMap(): array 'macOS' => '#microsoft.graph.macOSGeneralDeviceConfiguration', 'all' => '#microsoft.graph.deviceConfiguration', ], + 'groupPolicyConfiguration' => [ + 'windows' => '#microsoft.graph.groupPolicyConfiguration', + 'all' => '#microsoft.graph.groupPolicyConfiguration', + ], 'settingsCatalogPolicy' => [ 'windows' => '#microsoft.graph.deviceManagementConfigurationPolicy', 'all' => '#microsoft.graph.deviceManagementConfigurationPolicy', ], + 'windowsUpdateRing' => [ + 'windows' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', + 'all' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', + ], 'deviceCompliancePolicy' => [ 'windows' => '#microsoft.graph.windows10CompliancePolicy', 'ios' => '#microsoft.graph.iosCompliancePolicy', @@ -38,6 +46,14 @@ protected static function odataTypeMap(): array 'deviceManagementScript' => [ 'windows' => '#microsoft.graph.deviceManagementScript', ], + 'deviceShellScript' => [ + 'macOS' => '#microsoft.graph.deviceShellScript', + 'all' => '#microsoft.graph.deviceShellScript', + ], + 'deviceHealthScript' => [ + 'windows' => '#microsoft.graph.deviceHealthScript', + 'all' => '#microsoft.graph.deviceHealthScript', + ], 'enrollmentRestriction' => [ 'all' => '#microsoft.graph.deviceEnrollmentConfiguration', ], diff --git a/config/graph_contracts.php b/config/graph_contracts.php index bdf58d4..e760db8 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -27,6 +27,34 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/deviceConfigurations/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/deviceConfigurations/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', + ], + 'groupPolicyConfiguration' => [ + 'resource' => 'deviceManagement/groupPolicyConfigurations', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.groupPolicyConfiguration', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', ], 'settingsCatalogPolicy' => [ 'resource' => 'deviceManagement/configurationPolicies', @@ -84,6 +112,27 @@ 'supports_scope_tags' => true, 'scope_tag_field' => 'roleScopeTagIds', ], + 'windowsUpdateRing' => [ + 'resource' => 'deviceManagement/deviceConfigurations', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.windowsUpdateForBusinessConfiguration', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/deviceConfigurations/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/deviceConfigurations/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', + ], 'deviceCompliancePolicy' => [ 'resource' => 'deviceManagement/deviceCompliancePolicies', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], @@ -99,6 +148,15 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceCompliancePolicies/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceCompliancePolicies/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/deviceCompliancePolicies/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/deviceCompliancePolicies/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', ], 'appProtectionPolicy' => [ 'resource' => 'deviceAppManagement/managedAppPolicies', @@ -135,6 +193,51 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceManagementScripts/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + ], + 'deviceShellScript' => [ + 'resource' => 'deviceManagement/deviceShellScripts', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.deviceShellScript', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceShellScripts/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceShellScripts/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/deviceShellScripts/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/deviceShellScripts/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + ], + 'deviceHealthScript' => [ + 'resource' => 'deviceManagement/deviceHealthScripts', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.deviceHealthScript', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceHealthScripts/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', ], 'enrollmentRestriction' => [ 'resource' => 'deviceManagement/deviceEnrollmentConfigurations', diff --git a/config/intune_permissions.php b/config/intune_permissions.php index 87523f4..21684c1 100644 --- a/config/intune_permissions.php +++ b/config/intune_permissions.php @@ -77,8 +77,14 @@ [ 'key' => 'DeviceManagementScripts.ReadWrite.All', 'type' => 'application', - 'description' => 'Read directory data needed for tenant health checks.', - 'features' => ['script-management'], + 'description' => 'Manage Intune device management scripts and remediations.', + 'features' => ['policy-sync', 'backup', 'restore', 'scripts', 'remediations'], + ], + [ + 'key' => 'DeviceManagementScripts.Read.All', + 'type' => 'application', + 'description' => 'Read Intune device management scripts and remediations.', + 'features' => ['policy-sync', 'backup', 'scripts', 'remediations'], ], ], // Stub list of permissions already granted to the service principal (used for display in Tenant verification UI). diff --git a/config/tenantpilot.php b/config/tenantpilot.php index fe389b8..1e214df 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -8,6 +8,17 @@ 'category' => 'Configuration', 'platform' => 'all', 'endpoint' => 'deviceManagement/deviceConfigurations', + 'filter' => "@odata.type ne '#microsoft.graph.windowsUpdateForBusinessConfiguration'", + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium', + ], + [ + 'type' => 'groupPolicyConfiguration', + 'label' => 'Administrative Templates', + 'category' => 'Configuration', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/groupPolicyConfigurations', 'backup' => 'full', 'restore' => 'enabled', 'risk' => 'medium', @@ -22,6 +33,17 @@ 'restore' => 'enabled', 'risk' => 'medium', ], + [ + 'type' => 'windowsUpdateRing', + 'label' => 'Software Update Ring', + 'category' => 'Update Management', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/deviceConfigurations', + 'filter' => "@odata.type eq '#microsoft.graph.windowsUpdateForBusinessConfiguration'", + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium-high', + ], [ 'type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance', @@ -62,6 +84,26 @@ 'restore' => 'enabled', 'risk' => 'medium', ], + [ + 'type' => 'deviceShellScript', + 'label' => 'macOS Shell Scripts', + 'category' => 'Scripts', + 'platform' => 'macOS', + 'endpoint' => 'deviceManagement/deviceShellScripts', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium', + ], + [ + 'type' => 'deviceHealthScript', + 'label' => 'Proactive Remediations', + 'category' => 'Scripts', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/deviceHealthScripts', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium', + ], [ 'type' => 'enrollmentRestriction', 'label' => 'Enrollment Restrictions', @@ -88,7 +130,7 @@ 'category' => 'Enrollment', 'platform' => 'all', 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', - 'filter' => "odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'", + 'filter' => "@odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'", 'backup' => 'full', 'restore' => 'enabled', 'risk' => 'medium', diff --git a/resources/views/filament/infolists/entries/restore-preview.blade.php b/resources/views/filament/infolists/entries/restore-preview.blade.php index 00c852e..4fb8987 100644 --- a/resources/views/filament/infolists/entries/restore-preview.blade.php +++ b/resources/views/filament/infolists/entries/restore-preview.blade.php @@ -82,6 +82,12 @@ {{ $item['validation_warning'] }} @endif + + @if (! empty($item['compliance_action_warning'])) +
+ {{ $item['compliance_action_warning'] }} +
+ @endif @endforeach diff --git a/resources/views/filament/infolists/entries/restore-results.blade.php b/resources/views/filament/infolists/entries/restore-results.blade.php index 7a41afb..7adccee 100644 --- a/resources/views/filament/infolists/entries/restore-results.blade.php +++ b/resources/views/filament/infolists/entries/restore-results.blade.php @@ -189,6 +189,51 @@ @endif @endif + @if (! empty($item['compliance_action_summary']) && is_array($item['compliance_action_summary'])) + @php + $summary = $item['compliance_action_summary']; + $complianceOutcomes = $item['compliance_action_outcomes'] ?? []; + $complianceIssues = collect($complianceOutcomes) + ->filter(fn ($outcome) => ($outcome['status'] ?? null) === 'skipped') + ->values(); + @endphp + +
+ Compliance notifications: {{ (int) ($summary['mapped'] ?? 0) }} mapped • + {{ (int) ($summary['skipped'] ?? 0) }} skipped +
+ + @if ($complianceIssues->isNotEmpty()) +
+ Compliance notification details +
+ @foreach ($complianceIssues as $outcome) +
+
+
+ Template {{ $outcome['template_id'] ?? 'unknown' }} +
+ + skipped + +
+ @if (! empty($outcome['rule_name'])) +
+ Rule: {{ $outcome['rule_name'] }} +
+ @endif + @if (! empty($outcome['reason'])) +
+ {{ $outcome['reason'] }} +
+ @endif +
+ @endforeach +
+
+ @endif + @endif + @if (! empty($item['created_policy_id'])) @php $createdMode = $item['created_policy_mode'] ?? null; diff --git a/specs/007-device-config-compliance/spec.md b/specs/007-device-config-compliance/spec.md index 59f7cb9..765f1c3 100644 --- a/specs/007-device-config-compliance/spec.md +++ b/specs/007-device-config-compliance/spec.md @@ -23,6 +23,7 @@ ### Full Scope (Phase 2+) - Endpoint security: security baselines (Windows security baseline, Microsoft Defender, Microsoft Edge); endpoint privilege management policies. - Tenant administration: device cleanup rules; RBAC roles and role assignments. - Connectors and tokens (metadata-only): APNs; VPP tokens; managed Google Play; certificate connectors; remote help settings. +- Inventory / Properties catalog policies (deviceManagement/inventoryPolicies) deferred until required permissions are confirmed. ## Overview Expand backup and restore coverage for device configuration and compliance workloads, including scripts and remediations. This feature focuses on policy types that are already core to DR and rollback, and builds on existing foundations and assignment mapping capabilities. @@ -75,4 +76,3 @@ ## Success Criteria (mandatory) - **SC-007.1**: For a backup containing at least 10 mixed configuration/compliance items, restore completes with 100% of items in Applied, Partial, or Skipped with reason (no silent failures). - **SC-007.2**: At least 95% of assignments in a mixed restore are either applied successfully or explicitly skipped with a recorded reason. - **SC-007.3**: Restore preview for 100 selected items completes in under 2 minutes in a typical admin environment. - diff --git a/specs/007-device-config-compliance/tasks.md b/specs/007-device-config-compliance/tasks.md index e5c583a..1843d9c 100644 --- a/specs/007-device-config-compliance/tasks.md +++ b/specs/007-device-config-compliance/tasks.md @@ -72,3 +72,8 @@ ## Phase 5: Tests and Verification **Checkpoint**: Tests pass and formatting is clean. +--- + +## Deferred / Backlog + +- [ ] T019 [Deferred] Add inventory/properties catalog policies (`deviceManagement/inventoryPolicies`) once required permissions are confirmed; include contracts, sync, snapshot hydration via `/settings`, and normalized UI display. diff --git a/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php b/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php index 587329c..45ec5a2 100644 --- a/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php +++ b/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php @@ -193,7 +193,7 @@ // "Device Vendor Msft Policy Config Uncached Test Setting" })->skip('Manual UI verification required'); -it('does not show Settings tab for non-Settings Catalog policies', function () { +it('shows tabbed layout for non-Settings Catalog policies', function () { $tenant = Tenant::create([ 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'name' => 'Test Tenant', @@ -234,7 +234,9 @@ ->get(PolicyResource::getUrl('view', ['record' => $policy])); $response->assertOk(); - // Verify page renders successfully for non-Settings Catalog policies + $response->assertSee('General'); + $response->assertSee('Settings'); + $response->assertSee('JSON'); }); // T034: Test display names shown (not definition IDs) diff --git a/tests/Feature/Filament/RestoreExecutionTest.php b/tests/Feature/Filament/RestoreExecutionTest.php index 252aafe..e13c564 100644 --- a/tests/Feature/Filament/RestoreExecutionTest.php +++ b/tests/Feature/Filament/RestoreExecutionTest.php @@ -182,3 +182,103 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon 'resource_id' => (string) $run->id, ]); }); + +test('restore execution records compliance notification mapping outcomes', function () { + app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface + { + 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 + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-3', + 'name' => 'Tenant Three', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-3', + 'policy_type' => 'deviceCompliancePolicy', + 'display_name' => 'Compliance Policy', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => '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' => [ + 'scheduledActionsForRule' => [ + [ + 'ruleName' => 'Password', + 'scheduledActionConfigurations' => [ + [ + 'actionType' => 'notification', + 'notificationTemplateId' => 'template-1', + ], + ], + ], + ], + ], + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + ); + + expect($run->status)->toBe('partial'); + expect($run->results[0]['status'])->toBe('partial'); + expect($run->results[0]['compliance_action_summary']['skipped'] ?? null)->toBe(1); + + $this->assertDatabaseHas('audit_logs', [ + 'action' => 'restore.compliance.actions.mapped', + 'resource_id' => (string) $run->id, + ]); +}); diff --git a/tests/Feature/Filament/RestorePreviewTest.php b/tests/Feature/Filament/RestorePreviewTest.php index 75196aa..fc441dc 100644 --- a/tests/Feature/Filament/RestorePreviewTest.php +++ b/tests/Feature/Filament/RestorePreviewTest.php @@ -103,3 +103,89 @@ public function request(string $method, string $path, array $options = []): Grap $policyPreview = collect($preview)->first(fn (array $item) => isset($item['action'])); expect($policyPreview['action'])->toBe('update'); }); + +test('restore preview warns about missing compliance notification templates', function () { + app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface + { + 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 + { + 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, []); + } + }); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-2', + 'name' => 'Tenant Two', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-2', + 'policy_type' => 'deviceCompliancePolicy', + 'display_name' => 'Compliance Policy', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + 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' => [ + 'scheduledActionsForRule' => [ + [ + 'ruleName' => 'Password', + 'scheduledActionConfigurations' => [ + [ + 'actionType' => 'notification', + 'notificationTemplateId' => 'template-1', + ], + ], + ], + ], + ], + ]); + + $service = app(RestoreService::class); + $preview = $service->preview($tenant, $backupSet); + + $policyPreview = collect($preview)->first(fn (array $item) => ($item['policy_type'] ?? null) === 'deviceCompliancePolicy'); + + expect($policyPreview['compliance_action_warning'] ?? null)->not->toBeNull(); + expect(($policyPreview['compliance_action_summary']['missing'] ?? 0))->toBe(1); +}); diff --git a/tests/Unit/AssignmentFetcherTest.php b/tests/Unit/AssignmentFetcherTest.php index f685067..bab434a 100644 --- a/tests/Unit/AssignmentFetcherTest.php +++ b/tests/Unit/AssignmentFetcherTest.php @@ -1,6 +1,7 @@ graphClient = Mockery::mock(MicrosoftGraphClient::class); - $this->fetcher = new AssignmentFetcher($this->graphClient); + $this->fetcher = new AssignmentFetcher($this->graphClient, app(GraphContractRegistry::class)); }); test('primary endpoint success', function () { $tenantId = 'tenant-123'; $policyId = 'policy-456'; + $policyType = 'settingsCatalogPolicy'; $assignments = [ ['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']], ['id' => 'assign-2', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-2']], @@ -35,7 +37,7 @@ ]) ->andReturn($response); - $result = $this->fetcher->fetch($tenantId, $policyId); + $result = $this->fetcher->fetch($policyType, $tenantId, $policyId); expect($result)->toBe($assignments); }); @@ -43,6 +45,7 @@ test('fallback on empty response', function () { $tenantId = 'tenant-123'; $policyId = 'policy-456'; + $policyType = 'settingsCatalogPolicy'; $assignments = [ ['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']], ]; @@ -70,7 +73,7 @@ $this->graphClient ->shouldReceive('request') ->once() - ->with('GET', '/deviceManagement/configurationPolicies', [ + ->with('GET', 'deviceManagement/configurationPolicies', [ 'tenant' => $tenantId, 'query' => [ '$expand' => 'assignments', @@ -79,7 +82,7 @@ ]) ->andReturn($fallbackResponse); - $result = $this->fetcher->fetch($tenantId, $policyId); + $result = $this->fetcher->fetch($policyType, $tenantId, $policyId); expect($result)->toBe($assignments); }); @@ -87,13 +90,14 @@ test('fail soft on error', function () { $tenantId = 'tenant-123'; $policyId = 'policy-456'; + $policyType = 'settingsCatalogPolicy'; $this->graphClient ->shouldReceive('request') ->once() ->andThrow(new GraphException('Graph API error', 500, ['request_id' => 'request-id-123'])); - $result = $this->fetcher->fetch($tenantId, $policyId); + $result = $this->fetcher->fetch($policyType, $tenantId, $policyId); expect($result)->toBe([]); }); @@ -101,6 +105,7 @@ test('returns empty array when both endpoints return empty', function () { $tenantId = 'tenant-123'; $policyId = 'policy-456'; + $policyType = 'settingsCatalogPolicy'; // Primary returns empty $primaryResponse = new GraphResponse( @@ -123,10 +128,10 @@ $this->graphClient ->shouldReceive('request') ->once() - ->with('GET', '/deviceManagement/configurationPolicies', Mockery::any()) + ->with('GET', 'deviceManagement/configurationPolicies', Mockery::any()) ->andReturn($fallbackResponse); - $result = $this->fetcher->fetch($tenantId, $policyId); + $result = $this->fetcher->fetch($policyType, $tenantId, $policyId); expect($result)->toBe([]); }); @@ -134,6 +139,7 @@ test('fallback handles missing assignments key', function () { $tenantId = 'tenant-123'; $policyId = 'policy-456'; + $policyType = 'settingsCatalogPolicy'; // Primary returns empty $primaryResponse = new GraphResponse( @@ -157,7 +163,7 @@ ->once() ->andReturn($fallbackResponse); - $result = $this->fetcher->fetch($tenantId, $policyId); + $result = $this->fetcher->fetch($policyType, $tenantId, $policyId); expect($result)->toBe([]); }); diff --git a/tests/Unit/CompliancePolicyNormalizerTest.php b/tests/Unit/CompliancePolicyNormalizerTest.php new file mode 100644 index 0000000..a2c5bca --- /dev/null +++ b/tests/Unit/CompliancePolicyNormalizerTest.php @@ -0,0 +1,36 @@ + '#microsoft.graph.windows10CompliancePolicy', + 'passwordRequired' => true, + 'passwordMinimumLength' => 8, + 'defenderEnabled' => true, + 'bitLockerEnabled' => false, + 'osMinimumVersion' => '10.0.19045', + 'activeFirewallRequired' => true, + 'customSetting' => 'Custom value', + ]; + + $normalized = $normalizer->normalize($snapshot, 'deviceCompliancePolicy', 'windows'); + + $settings = collect($normalized['settings']); + + $passwordBlock = $settings->firstWhere('title', 'Password & Access'); + expect($passwordBlock)->not->toBeNull(); + expect(collect($passwordBlock['rows'])->pluck('label')->all()) + ->toContain('Password required', 'Password minimum length'); + + $additionalBlock = $settings->firstWhere('title', 'Additional Settings'); + expect($additionalBlock)->not->toBeNull(); + expect(collect($additionalBlock['rows'])->pluck('label')->all()) + ->toContain('Custom Setting'); + + expect($settings->pluck('title')->all())->not->toContain('General'); +}); diff --git a/tests/Unit/DeviceConfigurationPolicyNormalizerTest.php b/tests/Unit/DeviceConfigurationPolicyNormalizerTest.php new file mode 100644 index 0000000..913dd2d --- /dev/null +++ b/tests/Unit/DeviceConfigurationPolicyNormalizerTest.php @@ -0,0 +1,39 @@ + '#microsoft.graph.windows10CustomConfiguration', + 'displayName' => 'Device Config Policy', + 'description' => 'Test policy', + 'omaSettings' => [ + [ + 'displayName' => 'Setting A', + 'omaUri' => './Vendor/MSFT/SettingA', + 'value' => 'Enabled', + ], + ], + 'customSetting' => 'Custom value', + 'nestedSetting' => ['value' => 'Nested'], + ]; + + $normalized = $normalizer->normalize($snapshot, 'deviceConfiguration', 'windows'); + $settings = collect($normalized['settings']); + + $omaBlock = $settings->firstWhere('title', 'OMA-URI settings'); + expect($omaBlock)->not->toBeNull(); + + $configurationBlock = $settings->firstWhere('title', 'Configuration'); + expect($configurationBlock)->not->toBeNull(); + + $labels = collect($configurationBlock['entries'])->pluck('key')->all(); + expect($labels)->toContain('Custom Setting', 'Nested Setting'); + expect($labels)->not->toContain('Display Name'); + + expect($settings->pluck('title')->all())->not->toContain('General'); +}); diff --git a/tests/Unit/PolicyNormalizerRoutingTest.php b/tests/Unit/PolicyNormalizerRoutingTest.php new file mode 100644 index 0000000..50be439 --- /dev/null +++ b/tests/Unit/PolicyNormalizerRoutingTest.php @@ -0,0 +1,44 @@ + 'custom', + 'settings' => [], + 'warnings' => [], + ]; + } + + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + return ['custom' => true]; + } + }; + + $normalizer = new PolicyNormalizer($defaultNormalizer, [$customNormalizer]); + + $custom = $normalizer->normalize(['id' => 'policy-1'], 'deviceCompliancePolicy', 'windows'); + expect($custom['status'])->toBe('custom'); + + $customDiff = $normalizer->flattenForDiff(['id' => 'policy-1'], 'deviceCompliancePolicy', 'windows'); + expect($customDiff)->toBe(['custom' => true]); + + $fallback = $normalizer->normalize(['id' => 'policy-1'], 'unknownPolicy', 'windows'); + expect($fallback['status'])->not->toBe('custom'); +}); diff --git a/tests/Unit/SettingsCatalogPolicyNormalizerTest.php b/tests/Unit/SettingsCatalogPolicyNormalizerTest.php new file mode 100644 index 0000000..fcc9a14 --- /dev/null +++ b/tests/Unit/SettingsCatalogPolicyNormalizerTest.php @@ -0,0 +1,33 @@ + '#microsoft.graph.deviceManagementConfigurationPolicy', + 'settings' => [ + [ + 'id' => 's1', + 'settingInstance' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance', + 'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring', + 'simpleSettingValue' => [ + 'value' => 1, + ], + ], + ], + ], + ]; + + $normalized = $normalizer->normalize($snapshot, 'settingsCatalogPolicy', 'windows'); + + $rows = $normalized['settings_table']['rows'] ?? []; + + expect($rows)->toHaveCount(1); + expect($rows[0]['definition_id'] ?? null)->toBe('device_vendor_msft_policy_config_defender_allowrealtimemonitoring'); +});