TenantAtlas/app/Services/Intune/AppProtectionPolicyNormalizer.php
ahmido fbb9748725 feat/009-app-protection-policy (#11)
Summary

add appProtectionPolicy coverage for assignments, normalize settings for UI, and skip targetedManagedAppConfiguration noise during inventory
wire up derived Graph endpoints/contracts so restores use the correct /assign paths per platform and assignments no longer rely on unsupported $expand
add normalization logic/tests plus Pact/Plan updates so capture+restore behave more like Intune’s app protection workflows and no longer expose unsupported fields

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #11
2025-12-29 16:11:50 +00:00

407 lines
13 KiB
PHP

<?php
namespace App\Services\Intune;
use DateInterval;
use Illuminate\Support\Str;
class AppProtectionPolicyNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'appProtectionPolicy';
}
/**
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>}
*/
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<string, mixed>
*/
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<int, array<string, mixed>>
*/
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<int, string>, labels?: array<string, string>}
*/
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<string, mixed> $labels
* @return array<int, array<string, mixed>>
*/
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<int, string> $usedKeys
* @return array<int, array<string, mixed>>
*/
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<int, string>
*/
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<int, mixed> $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,
};
}
}