>, 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->buildBlocks($snapshot) as $block) { $normalized['settings'][] = $block; } return $normalized; } /** * @return array */ public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array { $snapshot = $snapshot ?? []; $normalized = $this->normalize($snapshot, $policyType, $platform); return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); } /** * @return array> */ private function buildBlocks(array $snapshot): array { $blocks = []; $groups = $this->groupedFields(); $usedKeys = []; foreach ($groups as $title => $group) { $rows = $this->buildRows($snapshot, $group['keys'], $group['labels'] ?? []); if ($rows === []) { continue; } if ($title === 'Basics') { $platformLabel = $this->platformLabelFromOdataType($snapshot['@odata.type'] ?? null); if ($platformLabel !== null) { array_unshift($rows, [ 'path' => '@odata.type', 'label' => 'Platform', 'value' => $platformLabel, ]); } } $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 [ 'Basics' => [ 'keys' => [ 'displayName', 'description', 'appGroupType', 'isAssigned', 'deployedAppCount', ], 'labels' => [ 'displayName' => 'Name', 'description' => 'Description', 'appGroupType' => 'App group type', 'isAssigned' => 'Assigned', 'deployedAppCount' => 'Deployed app count', ], ], 'Data Protection' => [ 'keys' => [ 'dataBackupBlocked', 'printBlocked', 'saveAsBlocked', 'screenCaptureBlocked', 'allowedInboundDataTransferSources', 'allowedOutboundDataTransferDestinations', 'allowedDataIngestionLocations', 'allowedOutboundClipboardSharingLevel', ], 'labels' => [ 'dataBackupBlocked' => 'Prevent backups', 'printBlocked' => 'Printing org data', 'saveAsBlocked' => 'Save copies of org data', 'screenCaptureBlocked' => 'Screen capture', 'allowedInboundDataTransferSources' => 'Receive data from other apps', 'allowedOutboundDataTransferDestinations' => 'Send org data to other apps', 'allowedDataIngestionLocations' => 'Allow users to open data from selected services', 'allowedOutboundClipboardSharingLevel' => 'Restrict cut, copy, and paste', ], ], 'Access Requirements' => [ 'keys' => [ 'pinRequired', 'pinCharacterSet', 'minimumPinLength', 'simplePinBlocked', 'maximumPinRetries', 'fingerprintAndBiometricEnabled', 'pinRequiredInsteadOfBiometricTimeout', 'periodOnlineBeforeAccessCheck', 'periodOfflineBeforeAccessCheck', ], 'labels' => [ 'pinRequired' => 'PIN for access', 'pinCharacterSet' => 'PIN type', 'minimumPinLength' => 'Minimum PIN length', 'simplePinBlocked' => 'Block simple PIN', 'maximumPinRetries' => 'Max PIN attempts', 'fingerprintAndBiometricEnabled' => 'Biometrics instead of PIN', 'pinRequiredInsteadOfBiometricTimeout' => 'Override biometrics with PIN after timeout', 'periodOnlineBeforeAccessCheck' => 'Recheck access requirements after', 'periodOfflineBeforeAccessCheck' => 'Offline grace period (block access)', ], ], 'Conditional Launch' => [ 'keys' => [ 'periodOfflineBeforeWipeIsEnforced', 'appActionIfMaximumPinRetriesExceeded', 'appActionIfDeviceLockNotSet', 'appActionIfDeviceComplianceRequired', 'maximumAllowedDeviceThreatLevel', 'mobileThreatDefenseRemediationAction', ], 'labels' => [ 'periodOfflineBeforeWipeIsEnforced' => 'Offline grace period (wipe data)', 'appActionIfMaximumPinRetriesExceeded' => 'Action if max PIN retries exceeded', 'appActionIfDeviceLockNotSet' => 'Action if device lock not set', 'appActionIfDeviceComplianceRequired' => 'Action if device compliance required', 'maximumAllowedDeviceThreatLevel' => 'Maximum allowed device threat level', 'mobileThreatDefenseRemediationAction' => 'Threat defense remediation action', ], ], ]; } /** * @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[] = [ 'path' => $key, 'label' => $labels[$key] ?? Str::headline($key), 'value' => $this->formatValue($key, $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[] = [ 'path' => $key, 'label' => Str::headline($key), 'value' => $this->formatValue($key, $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', ]; } private function formatValue(string $key, mixed $value): mixed { if (is_bool($value)) { $normalized = strtolower($key); if (str_ends_with($normalized, 'blocked')) { return $value ? 'Blocked' : 'Allowed'; } if (str_ends_with($normalized, 'required')) { return $value ? 'Required' : 'Not required'; } if (str_contains($normalized, 'enabled')) { return $value ? 'Enabled' : 'Disabled'; } return $value ? 'Yes' : 'No'; } if (is_array($value)) { if ($value === []) { return 'None'; } if (array_is_list($value) && $this->isScalarList($value)) { return implode(', ', array_map(fn (mixed $item) => $this->formatScalarListItem($item), $value)); } return json_encode($value, JSON_PRETTY_PRINT); } if (is_string($value)) { $duration = $this->formatDuration($value); if ($duration !== null) { return $duration; } if ($value === '') { return null; } if (preg_match('/^[a-zA-Z][a-zA-Z0-9]*$/', $value) === 1) { return Str::headline($value); } return $value; } return $value; } /** * @param array $value */ private function isScalarList(array $value): bool { foreach ($value as $item) { if (! is_string($item) && ! is_int($item) && ! is_float($item)) { return false; } } return true; } private function formatScalarListItem(mixed $value): string { if (is_int($value) || is_float($value)) { return (string) $value; } if (! is_string($value)) { return ''; } if (preg_match('/^[a-zA-Z][a-zA-Z0-9]*$/', $value) === 1) { return Str::headline($value); } return $value; } private function formatDuration(string $value): ?string { if (! preg_match('/^P[T0-9]/i', $value)) { return null; } try { $interval = new DateInterval(strtoupper($value)); } catch (\Throwable) { return null; } $parts = []; if ($interval->y) { $parts[] = $interval->y.' '.Str::plural('year', $interval->y); } if ($interval->m) { $parts[] = $interval->m.' '.Str::plural('month', $interval->m); } if ($interval->d) { $parts[] = $interval->d.' '.Str::plural('day', $interval->d); } if ($interval->h) { $parts[] = $interval->h.' '.Str::plural('hour', $interval->h); } if ($interval->i) { $parts[] = $interval->i.' '.Str::plural('minute', $interval->i); } if ($interval->s && $parts === []) { $parts[] = $interval->s.' '.Str::plural('second', $interval->s); } if ($parts === []) { return null; } return implode(' ', $parts); } private function platformLabelFromOdataType(mixed $odataType): ?string { if (! is_string($odataType) || $odataType === '') { return null; } return match (strtolower($odataType)) { '#microsoft.graph.androidmanagedappprotection' => 'Android', '#microsoft.graph.iosmanagedappprotection' => 'iOS', '#microsoft.graph.windowsinformationprotectionpolicy', '#microsoft.graph.mdmwindowsinformationprotectionpolicy' => 'Windows', default => null, }; } }