>, 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; $walk = function (array $nodes, array $pathParts, int $depth) use ( &$walk, &$rows, &$warnings, &$rowCount, &$warnedDepthLimit, &$warnedRowLimit ): 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); $instanceType = $this->formatSettingsCatalogInstanceType(is_string($instanceType) ? ltrim($instanceType, '#') : null); $currentPathParts = array_merge($pathParts, [$definitionId]); $path = implode(' > ', $currentPathParts); $value = $this->extractSettingsCatalogValue($node, $instance); $rows[] = [ 'definition' => $definitionId, 'type' => $instanceType ?? '-', 'value' => $this->stringifySettingsCatalogValue($value), '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', '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 '-'; } if (is_bool($value)) { return $value ? 'true' : 'false'; } if (is_scalar($value)) { return (string) $value; } if (is_array($value)) { return (string) json_encode($value, JSON_PRETTY_PRINT); } return (string) $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 in group collections 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" -> "Not Required (0)" if (str_contains($value, 'device_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', ]; $label = $mapping[strtolower($secondLast)] ?? Str::title($secondLast); return $label.': '.$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; } }