Hydrate configurationPolicies/{id}/settings for endpoint security/baseline policies so snapshots include real rule data.
Treat those types like Settings Catalog policies in the normalizer so they show the searchable settings table, recognizable categories, and readable choice values (firewall-specific formatting + interface badge parsing).
Improve “General” tab cards: badge lists for platforms/technologies, template reference summary (name/family/version/ID), and ISO timestamps rendered as YYYY‑MM‑DD HH:MM:SS; added regression test for the view.
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #23
1091 lines
36 KiB
PHP
1091 lines
36 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Intune;
|
|
|
|
use App\Models\Policy;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Str;
|
|
|
|
class DefaultPolicyNormalizer implements PolicyTypeNormalizer
|
|
{
|
|
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,
|
|
private readonly SettingsCatalogCategoryResolver $categoryResolver,
|
|
) {}
|
|
|
|
public function supports(string $policyType): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
$usesSettingsCatalogTable = in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true);
|
|
$fallbackCategoryName = $this->extractConfigurationPolicyFallbackCategoryName($snapshot);
|
|
|
|
$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 ($usesSettingsCatalogTable) {
|
|
$normalized = $this->buildSettingsCatalogSettingsTable(
|
|
$snapshot['settings'],
|
|
fallbackCategoryName: $fallbackCategoryName
|
|
);
|
|
$settingsTable = $normalized['table'];
|
|
$resultWarnings = array_merge($resultWarnings, $normalized['warnings']);
|
|
} else {
|
|
$settings[] = $this->normalizeSettingsCatalog($snapshot['settings']);
|
|
}
|
|
} elseif (isset($snapshot['settingsDelta']) && is_array($snapshot['settingsDelta'])) {
|
|
if ($usesSettingsCatalogTable) {
|
|
$normalized = $this->buildSettingsCatalogSettingsTable(
|
|
$snapshot['settingsDelta'],
|
|
'Settings delta',
|
|
$fallbackCategoryName
|
|
);
|
|
$settingsTable = $normalized['table'];
|
|
$resultWarnings = array_merge($resultWarnings, $normalized['warnings']);
|
|
} else {
|
|
$settings[] = $this->normalizeSettingsCatalog($snapshot['settingsDelta'], 'Settings delta');
|
|
}
|
|
} elseif ($usesSettingsCatalogTable) {
|
|
$resultWarnings[] = 'Settings not hydrated for this Configuration 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);
|
|
|
|
return $this->flattenNormalizedForDiff($normalized);
|
|
}
|
|
|
|
/**
|
|
* Flatten an already normalized payload into key/value pairs for diffing.
|
|
*
|
|
* @param array{settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>} $normalized
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function flattenNormalizedForDiff(array $normalized): array
|
|
{
|
|
$map = [];
|
|
|
|
if (isset($normalized['settings_table']['rows']) && is_array($normalized['settings_table']['rows'])) {
|
|
$title = $normalized['settings_table']['title'] ?? 'Settings';
|
|
$prefix = is_string($title) && $title !== '' ? $title.' > ' : '';
|
|
|
|
foreach ($normalized['settings_table']['rows'] as $row) {
|
|
if (! is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$key = $prefix.($row['path'] ?? $row['definition'] ?? 'entry');
|
|
$map[$key] = $row['value'] ?? null;
|
|
}
|
|
}
|
|
|
|
foreach ($normalized['settings'] ?? [] as $block) {
|
|
if (! is_array($block)) {
|
|
continue;
|
|
}
|
|
|
|
$title = $block['title'] ?? null;
|
|
$prefix = is_string($title) && $title !== '' ? $title.' > ' : '';
|
|
|
|
if (($block['type'] ?? null) === 'table') {
|
|
foreach ($block['rows'] ?? [] as $row) {
|
|
if (! is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$key = $prefix.($row['path'] ?? $row['label'] ?? 'entry');
|
|
$map[$key] = $row['value'] ?? null;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
foreach ($block['entries'] ?? [] as $entry) {
|
|
if (! is_array($entry)) {
|
|
continue;
|
|
}
|
|
|
|
$key = $prefix.($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,
|
|
];
|
|
}
|
|
|
|
private function extractConfigurationPolicyFallbackCategoryName(array $snapshot): ?string
|
|
{
|
|
$templateReference = $snapshot['templateReference'] ?? null;
|
|
|
|
if (is_string($templateReference)) {
|
|
$decoded = json_decode($templateReference, true);
|
|
$templateReference = is_array($decoded) ? $decoded : null;
|
|
}
|
|
|
|
if (! is_array($templateReference)) {
|
|
return null;
|
|
}
|
|
|
|
$displayName = $templateReference['templateDisplayName'] ?? null;
|
|
|
|
if (is_string($displayName) && $displayName !== '') {
|
|
return $displayName;
|
|
}
|
|
|
|
$family = $templateReference['templateFamily'] ?? null;
|
|
|
|
if (is_string($family) && $family !== '') {
|
|
return Str::headline($family);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, mixed> $settings
|
|
* @return array{table: array<string, mixed>, warnings: array<int, string>}
|
|
*/
|
|
private function buildSettingsCatalogSettingsTable(array $settings, string $title = 'Settings', ?string $fallbackCategoryName = null): array
|
|
{
|
|
$flattened = $this->flattenSettingsCatalogSettingInstances($settings, $fallbackCategoryName);
|
|
|
|
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, ?string $fallbackCategoryName = null): 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,
|
|
$fallbackCategoryName,
|
|
): 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;
|
|
}
|
|
|
|
if (
|
|
$categoryName === '-'
|
|
&& is_string($fallbackCategoryName)
|
|
&& $fallbackCategoryName !== ''
|
|
&& is_array($definition)
|
|
&& ($definition['isFallback'] ?? false)
|
|
) {
|
|
$categoryName = $fallbackCategoryName;
|
|
}
|
|
|
|
// 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<int, mixed>
|
|
*/
|
|
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, 'ChoiceSettingCollectionInstance', ignoreCase: true)) {
|
|
$collection = $instance['choiceSettingCollectionValue'] ?? null;
|
|
|
|
if (! is_array($collection) || $collection === []) {
|
|
return [];
|
|
}
|
|
|
|
$values = [];
|
|
|
|
foreach ($collection as $item) {
|
|
if (! is_array($item)) {
|
|
continue;
|
|
}
|
|
|
|
$value = $item['value'] ?? null;
|
|
|
|
if (is_string($value) && $value !== '') {
|
|
$values[] = $value;
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($values));
|
|
}
|
|
|
|
if (Str::contains($type, 'SimpleSettingInstance', ignoreCase: true)) {
|
|
$simple = $instance['simpleSettingValue'] ?? null;
|
|
|
|
if (is_array($simple)) {
|
|
$simpleValue = $simple['value'] ?? $simple;
|
|
|
|
if (is_array($simpleValue) && array_key_exists('value', $simpleValue)) {
|
|
return $simpleValue['value'];
|
|
}
|
|
|
|
return $simpleValue;
|
|
}
|
|
|
|
return $simple;
|
|
}
|
|
|
|
if (Str::contains($type, 'ChoiceSettingInstance', ignoreCase: true)) {
|
|
$choice = $instance['choiceSettingValue'] ?? null;
|
|
|
|
if (is_array($choice)) {
|
|
$choiceValue = $choice['value'] ?? $choice;
|
|
|
|
if (is_array($choiceValue) && array_key_exists('value', $choiceValue)) {
|
|
return $choiceValue['value'];
|
|
}
|
|
|
|
return $choiceValue;
|
|
}
|
|
|
|
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',
|
|
'scheduledActionsForRule',
|
|
'scheduledActionsForRule@odata.context',
|
|
];
|
|
|
|
$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 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);
|
|
$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, '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';
|
|
}
|
|
|
|
$commonLastPartMapping = [
|
|
'in' => 'Inbound',
|
|
'out' => 'Outbound',
|
|
'allow' => 'Allow',
|
|
'block' => 'Block',
|
|
'tcp' => 'TCP',
|
|
'udp' => 'UDP',
|
|
'icmpv4' => 'ICMPv4',
|
|
'icmpv6' => 'ICMPv6',
|
|
'any' => 'Any',
|
|
'notconfigured' => 'Not configured',
|
|
'lan' => 'LAN',
|
|
'wireless' => 'Wireless',
|
|
'remoteaccess' => 'Remote access',
|
|
'domain' => 'Domain',
|
|
'private' => 'Private',
|
|
'public' => 'Public',
|
|
];
|
|
|
|
if (is_string($lastPart) && isset($commonLastPartMapping[strtolower($lastPart)])) {
|
|
return $commonLastPartMapping[strtolower($lastPart)];
|
|
}
|
|
|
|
// 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)) {
|
|
if ($value === []) {
|
|
return '-';
|
|
}
|
|
|
|
if (array_is_list($value)) {
|
|
$parts = [];
|
|
|
|
foreach ($value as $item) {
|
|
if ($item === null) {
|
|
continue;
|
|
}
|
|
|
|
if (! is_bool($item) && ! is_int($item) && ! is_float($item) && ! is_string($item)) {
|
|
$parts = [];
|
|
break;
|
|
}
|
|
|
|
$parts[] = $this->formatSettingsCatalogValue($item);
|
|
}
|
|
|
|
$parts = array_values(array_unique(array_filter($parts, static fn (string $part): bool => $part !== '' && $part !== '-')));
|
|
|
|
if ($parts !== []) {
|
|
return implode(', ', $parts);
|
|
}
|
|
}
|
|
|
|
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';
|
|
}
|
|
}
|