TenantAtlas/app/Services/Intune/PolicyNormalizer.php
2025-12-14 20:23:18 +01:00

785 lines
26 KiB
PHP

<?php
namespace App\Services\Intune;
use App\Models\Policy;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
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.
*/
public function __construct(
private readonly SnapshotValidator $validator,
private readonly SettingsCatalogDefinitionResolver $definitionResolver,
) {}
/**
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>, 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<string, mixed>
*/
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<int, array<string, mixed>> $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<int, array<string, mixed>> $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<int, mixed> $settings
* @return array{table: array<string, mixed>, warnings: array<int, string>}
*/
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<int, mixed> $settings
* @return array{rows: array<int, array<string, mixed>>, warnings: array<int, string>}
*/
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<int, mixed>
*/
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<int, mixed> $settings
* @return array{type: string, groups: array<int, array<string, mixed>>}
*/
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;
}
}