wip: policy normalizers and settings catalog

This commit is contained in:
Ahmed Darrazi 2025-12-27 22:32:51 +01:00
parent ba468de486
commit 3ff79a2baa
31 changed files with 2517 additions and 936 deletions

View File

@ -62,7 +62,7 @@ public static function infolist(Schema $schema): Schema
->columns(2) ->columns(2)
->columnSpanFull(), ->columnSpanFull(),
// For Settings Catalog policies: Tabs with Settings table + JSON viewer // Tabbed content (General / Settings / JSON)
Tabs::make('policy_content') Tabs::make('policy_content')
->activeTab(1) ->activeTab(1)
->persistTabInQueryString() ->persistTabInQueryString()
@ -74,10 +74,7 @@ public static function infolist(Schema $schema): Schema
->label('') ->label('')
->view('filament.infolists.entries.policy-general') ->view('filament.infolists.entries.policy-general')
->state(function (Policy $record) { ->state(function (Policy $record) {
$normalized = static::normalizedPolicyState($record); return static::generalOverviewState($record);
$split = static::splitGeneralBlock($normalized);
return $split['general'];
}), }),
]) ])
->visible(fn (Policy $record) => $record->versions()->exists()), ->visible(fn (Policy $record) => $record->versions()->exists()),
@ -88,12 +85,9 @@ public static function infolist(Schema $schema): Schema
->label('') ->label('')
->view('filament.infolists.entries.normalized-settings') ->view('filament.infolists.entries.normalized-settings')
->state(function (Policy $record) { ->state(function (Policy $record) {
$normalized = static::normalizedPolicyState($record); return static::settingsTabState($record);
$split = static::splitGeneralBlock($normalized);
return $split['normalized'];
}) })
->visible(fn (Policy $record) => $record->policy_type === 'settingsCatalogPolicy' && ->visible(fn (Policy $record) => static::hasSettingsTable($record) &&
$record->versions()->exists() $record->versions()->exists()
), ),
@ -101,12 +95,9 @@ public static function infolist(Schema $schema): Schema
->label('') ->label('')
->view('filament.infolists.entries.policy-settings-standard') ->view('filament.infolists.entries.policy-settings-standard')
->state(function (Policy $record) { ->state(function (Policy $record) {
$normalized = static::normalizedPolicyState($record); return static::settingsTabState($record);
$split = static::splitGeneralBlock($normalized);
return $split['normalized'];
}) })
->visible(fn (Policy $record) => $record->policy_type !== 'settingsCatalogPolicy' && ->visible(fn (Policy $record) => ! static::hasSettingsTable($record) &&
$record->versions()->exists() $record->versions()->exists()
), ),
@ -144,12 +135,9 @@ public static function infolist(Schema $schema): Schema
->visible(fn (Policy $record) => $record->versions()->exists()), ->visible(fn (Policy $record) => $record->versions()->exists()),
]) ])
->columnSpanFull() ->columnSpanFull()
->visible(function (Policy $record) { ->visible(fn (Policy $record) => static::usesTabbedLayout($record)),
return str_contains(strtolower($record->policy_type ?? ''), 'settings') ||
str_contains(strtolower($record->policy_type ?? ''), 'catalog');
}),
// For non-Settings Catalog policies: Simple sections without tabs // Legacy layout (kept for fallback if tabs are disabled)
Section::make('Settings') Section::make('Settings')
->schema([ ->schema([
ViewEntry::make('settings') ViewEntry::make('settings')
@ -170,9 +158,7 @@ public static function infolist(Schema $schema): Schema
]) ])
->columnSpanFull() ->columnSpanFull()
->visible(function (Policy $record) { ->visible(function (Policy $record) {
// Show simple settings section for non-Settings Catalog policies return ! static::usesTabbedLayout($record);
return ! str_contains(strtolower($record->policy_type ?? ''), 'settings') &&
! str_contains(strtolower($record->policy_type ?? ''), 'catalog');
}), }),
Section::make('Policy Snapshot (JSON)') Section::make('Policy Snapshot (JSON)')
@ -205,9 +191,7 @@ public static function infolist(Schema $schema): Schema
->description('Raw JSON configuration from Microsoft Graph API') ->description('Raw JSON configuration from Microsoft Graph API')
->columnSpanFull() ->columnSpanFull()
->visible(function (Policy $record) { ->visible(function (Policy $record) {
// Show standalone JSON section only for non-Settings Catalog policies return ! static::usesTabbedLayout($record);
return ! str_contains(strtolower($record->policy_type ?? ''), 'settings') &&
! str_contains(strtolower($record->policy_type ?? ''), 'catalog');
}), }),
]); ]);
} }
@ -690,4 +674,101 @@ private static function typeMeta(?string $type): array
return collect(config('tenantpilot.supported_policy_types', [])) return collect(config('tenantpilot.supported_policy_types', []))
->firstWhere('type', $type) ?? []; ->firstWhere('type', $type) ?? [];
} }
private static function usesTabbedLayout(Policy $record): bool
{
return true;
}
private static function hasSettingsTable(Policy $record): bool
{
$normalized = static::normalizedPolicyState($record);
$rows = $normalized['settings_table']['rows'] ?? [];
return is_array($rows) && $rows !== [];
}
/**
* @return array{entries: array<int, array{key: string, value: mixed}>}
*/
private static function generalOverviewState(Policy $record): array
{
$snapshot = static::latestSnapshot($record);
$entries = [];
$name = $snapshot['displayName'] ?? $snapshot['name'] ?? $record->display_name;
if (is_string($name) && $name !== '') {
$entries[] = ['key' => 'Name', 'value' => $name];
}
$platforms = $snapshot['platforms'] ?? $snapshot['platform'] ?? $record->platform;
if (is_string($platforms) && $platforms !== '') {
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
} elseif (is_array($platforms) && $platforms !== []) {
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
}
$technologies = $snapshot['technologies'] ?? null;
if (is_string($technologies) && $technologies !== '') {
$entries[] = ['key' => 'Technologies', 'value' => $technologies];
} elseif (is_array($technologies) && $technologies !== []) {
$entries[] = ['key' => 'Technologies', 'value' => $technologies];
}
if (array_key_exists('templateReference', $snapshot)) {
$entries[] = ['key' => 'Template Reference', 'value' => $snapshot['templateReference']];
}
$settingCount = $snapshot['settingCount']
?? $snapshot['settingsCount']
?? (isset($snapshot['settings']) && is_array($snapshot['settings']) ? count($snapshot['settings']) : null);
if (is_int($settingCount) || is_numeric($settingCount)) {
$entries[] = ['key' => 'Setting Count', 'value' => $settingCount];
}
$version = $snapshot['version'] ?? null;
if (is_string($version) && $version !== '') {
$entries[] = ['key' => 'Version', 'value' => $version];
} elseif (is_numeric($version)) {
$entries[] = ['key' => 'Version', 'value' => $version];
}
$lastModified = $snapshot['lastModifiedDateTime'] ?? null;
if (is_string($lastModified) && $lastModified !== '') {
$entries[] = ['key' => 'Last Modified', 'value' => $lastModified];
}
$createdAt = $snapshot['createdDateTime'] ?? null;
if (is_string($createdAt) && $createdAt !== '') {
$entries[] = ['key' => 'Created', 'value' => $createdAt];
}
$description = $snapshot['description'] ?? null;
if (is_string($description) && $description !== '') {
$entries[] = ['key' => 'Description', 'value' => $description];
}
return [
'entries' => $entries,
];
}
/**
* @return array<string, mixed>
*/
private static function settingsTabState(Policy $record): array
{
$normalized = static::normalizedPolicyState($record);
$rows = $normalized['settings_table']['rows'] ?? [];
$hasSettingsTable = is_array($rows) && $rows !== [];
if ($record->policy_type === 'settingsCatalogPolicy' && $hasSettingsTable) {
$split = static::splitGeneralBlock($normalized);
return $split['normalized'];
}
return $normalized;
}
} }

View File

@ -51,6 +51,16 @@ public function handle(AssignmentBackupService $assignmentBackupService): void
return; return;
} }
$tenant = $backupItem->tenant;
if ($tenant === null) {
Log::warning('FetchAssignmentsJob: Tenant not found for BackupItem', [
'backup_item_id' => $this->backupItemId,
]);
return;
}
// Only process Settings Catalog policies // Only process Settings Catalog policies
if ($backupItem->policy_type !== 'settingsCatalogPolicy') { if ($backupItem->policy_type !== 'settingsCatalogPolicy') {
Log::info('FetchAssignmentsJob: Skipping non-Settings Catalog policy', [ Log::info('FetchAssignmentsJob: Skipping non-Settings Catalog policy', [
@ -63,8 +73,9 @@ public function handle(AssignmentBackupService $assignmentBackupService): void
$assignmentBackupService->enrichWithAssignments( $assignmentBackupService->enrichWithAssignments(
backupItem: $backupItem, backupItem: $backupItem,
tenantId: $this->tenantExternalId, tenant: $tenant,
policyId: $this->policyExternalId, policyType: $backupItem->policy_type,
policyId: $backupItem->policy_identifier,
policyPayload: $this->policyPayload, policyPayload: $this->policyPayload,
includeAssignments: true includeAssignments: true
); );

View File

@ -5,6 +5,9 @@
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\MicrosoftGraphClient; use App\Services\Graph\MicrosoftGraphClient;
use App\Services\Graph\NullGraphClient; use App\Services\Graph\NullGraphClient;
use App\Services\Intune\CompliancePolicyNormalizer;
use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
use App\Services\Intune\SettingsCatalogPolicyNormalizer;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@ -27,6 +30,15 @@ public function register(): void
return $app->make(NullGraphClient::class); return $app->make(NullGraphClient::class);
}); });
$this->app->tag(
[
CompliancePolicyNormalizer::class,
DeviceConfigurationPolicyNormalizer::class,
SettingsCatalogPolicyNormalizer::class,
],
'policy-type-normalizers'
);
} }
/** /**

View File

@ -24,6 +24,7 @@ public function __construct(
* *
* @param BackupItem $backupItem The backup item to enrich * @param BackupItem $backupItem The backup item to enrich
* @param Tenant $tenant Tenant model with credentials * @param Tenant $tenant Tenant model with credentials
* @param string $policyType Policy type key (e.g. deviceConfiguration)
* @param string $policyId Policy ID (external_id from Graph) * @param string $policyId Policy ID (external_id from Graph)
* @param array $policyPayload Full policy payload from Graph * @param array $policyPayload Full policy payload from Graph
* @param bool $includeAssignments Whether to fetch and include assignments * @param bool $includeAssignments Whether to fetch and include assignments
@ -32,6 +33,7 @@ public function __construct(
public function enrichWithAssignments( public function enrichWithAssignments(
BackupItem $backupItem, BackupItem $backupItem,
Tenant $tenant, Tenant $tenant,
string $policyType,
string $policyId, string $policyId,
array $policyPayload, array $policyPayload,
bool $includeAssignments = false bool $includeAssignments = false
@ -58,7 +60,7 @@ public function enrichWithAssignments(
// Fetch assignments from Graph API // Fetch assignments from Graph API
$graphOptions = $tenant->graphOptions(); $graphOptions = $tenant->graphOptions();
$tenantId = $graphOptions['tenant'] ?? $tenant->external_id ?? $tenant->tenant_id; $tenantId = $graphOptions['tenant'] ?? $tenant->external_id ?? $tenant->tenant_id;
$assignments = $this->assignmentFetcher->fetch($tenantId, $policyId, $graphOptions); $assignments = $this->assignmentFetcher->fetch($policyType, $tenantId, $policyId, $graphOptions);
if (empty($assignments)) { if (empty($assignments)) {
// No assignments or fetch failed // No assignments or fetch failed

View File

@ -8,27 +8,32 @@ class AssignmentFetcher
{ {
public function __construct( public function __construct(
private readonly MicrosoftGraphClient $graphClient, private readonly MicrosoftGraphClient $graphClient,
private readonly GraphContractRegistry $contracts,
) {} ) {}
/** /**
* Fetch policy assignments with fallback strategy. * Fetch policy assignments with fallback strategy.
* *
* Primary: GET /deviceManagement/configurationPolicies/{id}/assignments * Primary: GET {assignments_list_path}
* Fallback: GET /deviceManagement/configurationPolicies?$expand=assignments&$filter=id eq '{id}' * Fallback: GET {resource}?$expand=assignments&$filter=id eq '{id}'
* *
* @return array Returns assignment array or empty array on failure * @return array Returns assignment array or empty array on failure
*/ */
public function fetch(string $tenantId, string $policyId, array $options = []): array public function fetch(string $policyType, string $tenantId, string $policyId, array $options = []): array
{ {
try { try {
$contract = $this->contracts->get($policyType);
$listPathTemplate = $contract['assignments_list_path'] ?? null;
$resource = $contract['resource'] ?? null;
$requestOptions = array_merge($options, ['tenant' => $tenantId]); $requestOptions = array_merge($options, ['tenant' => $tenantId]);
// Try primary endpoint // Try primary endpoint
$assignments = $this->fetchPrimary($policyId, $requestOptions); $assignments = $this->fetchPrimary($listPathTemplate, $policyId, $requestOptions);
if (! empty($assignments)) { if (! empty($assignments)) {
Log::debug('Fetched assignments via primary endpoint', [ Log::debug('Fetched assignments via primary endpoint', [
'tenant_id' => $tenantId, 'tenant_id' => $tenantId,
'policy_type' => $policyType,
'policy_id' => $policyId, 'policy_id' => $policyId,
'count' => count($assignments), 'count' => count($assignments),
]); ]);
@ -39,14 +44,26 @@ public function fetch(string $tenantId, string $policyId, array $options = []):
// Try fallback with $expand // Try fallback with $expand
Log::debug('Primary endpoint returned empty, trying fallback', [ Log::debug('Primary endpoint returned empty, trying fallback', [
'tenant_id' => $tenantId, 'tenant_id' => $tenantId,
'policy_type' => $policyType,
'policy_id' => $policyId, 'policy_id' => $policyId,
]); ]);
$assignments = $this->fetchWithExpand($policyId, $requestOptions); if (! is_string($resource) || $resource === '') {
Log::debug('Assignments resource not configured for policy type', [
'tenant_id' => $tenantId,
'policy_type' => $policyType,
'policy_id' => $policyId,
]);
return [];
}
$assignments = $this->fetchWithExpand($resource, $policyId, $requestOptions);
if (! empty($assignments)) { if (! empty($assignments)) {
Log::debug('Fetched assignments via fallback endpoint', [ Log::debug('Fetched assignments via fallback endpoint', [
'tenant_id' => $tenantId, 'tenant_id' => $tenantId,
'policy_type' => $policyType,
'policy_id' => $policyId, 'policy_id' => $policyId,
'count' => count($assignments), 'count' => count($assignments),
]); ]);
@ -57,6 +74,7 @@ public function fetch(string $tenantId, string $policyId, array $options = []):
// Both methods returned empty // Both methods returned empty
Log::debug('No assignments found for policy', [ Log::debug('No assignments found for policy', [
'tenant_id' => $tenantId, 'tenant_id' => $tenantId,
'policy_type' => $policyType,
'policy_id' => $policyId, 'policy_id' => $policyId,
]); ]);
@ -64,6 +82,7 @@ public function fetch(string $tenantId, string $policyId, array $options = []):
} catch (GraphException $e) { } catch (GraphException $e) {
Log::warning('Failed to fetch assignments', [ Log::warning('Failed to fetch assignments', [
'tenant_id' => $tenantId, 'tenant_id' => $tenantId,
'policy_type' => $policyType,
'policy_id' => $policyId, 'policy_id' => $policyId,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'context' => $e->context, 'context' => $e->context,
@ -76,9 +95,17 @@ public function fetch(string $tenantId, string $policyId, array $options = []):
/** /**
* Fetch assignments using primary endpoint. * Fetch assignments using primary endpoint.
*/ */
private function fetchPrimary(string $policyId, array $options): array private function fetchPrimary(?string $listPathTemplate, string $policyId, array $options): array
{ {
$path = "/deviceManagement/configurationPolicies/{$policyId}/assignments"; if (! is_string($listPathTemplate) || $listPathTemplate === '') {
return [];
}
$path = $this->resolvePath($listPathTemplate, $policyId);
if ($path === null) {
return [];
}
$response = $this->graphClient->request('GET', $path, $options); $response = $this->graphClient->request('GET', $path, $options);
@ -88,9 +115,9 @@ private function fetchPrimary(string $policyId, array $options): array
/** /**
* Fetch assignments using $expand fallback. * Fetch assignments using $expand fallback.
*/ */
private function fetchWithExpand(string $policyId, array $options): array private function fetchWithExpand(string $resource, string $policyId, array $options): array
{ {
$path = '/deviceManagement/configurationPolicies'; $path = $resource;
$params = [ $params = [
'$expand' => 'assignments', '$expand' => 'assignments',
'$filter' => "id eq '{$policyId}'", '$filter' => "id eq '{$policyId}'",
@ -108,4 +135,13 @@ private function fetchWithExpand(string $policyId, array $options): array
return $policies[0]['assignments'] ?? []; return $policies[0]['assignments'] ?? [];
} }
private function resolvePath(string $template, string $policyId): ?string
{
if ($template === '') {
return null;
}
return str_replace('{id}', urlencode($policyId), $template);
}
} }

View File

@ -0,0 +1,296 @@
<?php
namespace App\Services\Intune;
use Illuminate\Support\Str;
class CompliancePolicyNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'deviceCompliancePolicy';
}
/**
* @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->buildComplianceBlocks($snapshot) as $block) {
$normalized['settings'][] = $block;
}
return $normalized;
}
/**
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
return $this->defaultNormalizer->flattenForDiff($snapshot, $policyType, $platform);
}
/**
* @return array<int, array<string, mixed>>
*/
private function buildComplianceBlocks(array $snapshot): array
{
$blocks = [];
$groups = $this->groupedFields();
$usedKeys = [];
foreach ($groups as $title => $group) {
$rows = $this->buildRows($snapshot, $group['keys'], $group['labels'] ?? []);
if ($rows === []) {
continue;
}
$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 [
'Password & Access' => [
'keys' => [
'passwordRequired',
'passwordRequiredType',
'passwordBlockSimple',
'passwordMinimumLength',
'passwordMinimumCharacterSetCount',
'passwordExpirationDays',
'passwordMinutesOfInactivityBeforeLock',
'passwordPreviousPasswordBlockCount',
'passwordRequiredToUnlockFromIdle',
],
'labels' => [
'passwordRequired' => 'Password required',
'passwordRequiredType' => 'Password required type',
'passwordBlockSimple' => 'Block simple passwords',
'passwordMinimumLength' => 'Password minimum length',
'passwordMinimumCharacterSetCount' => 'Password minimum character set count',
'passwordExpirationDays' => 'Password expiration days',
'passwordMinutesOfInactivityBeforeLock' => 'Password idle lock (minutes)',
'passwordPreviousPasswordBlockCount' => 'Password history count',
'passwordRequiredToUnlockFromIdle' => 'Password required to unlock from idle',
],
],
'Defender & Threat Protection' => [
'keys' => [
'defenderEnabled',
'defenderVersion',
'antivirusRequired',
'antiSpywareRequired',
'rtpEnabled',
'signatureOutOfDate',
'deviceThreatProtectionEnabled',
'deviceThreatProtectionRequiredSecurityLevel',
'requireHealthyDeviceReport',
],
'labels' => [
'defenderEnabled' => 'Microsoft Defender enabled',
'defenderVersion' => 'Defender version',
'antivirusRequired' => 'Antivirus required',
'antiSpywareRequired' => 'Anti-spyware required',
'rtpEnabled' => 'Real-time protection enabled',
'signatureOutOfDate' => 'Signature out of date (days)',
'deviceThreatProtectionEnabled' => 'Device threat protection enabled',
'deviceThreatProtectionRequiredSecurityLevel' => 'Threat protection required level',
'requireHealthyDeviceReport' => 'Require healthy device report',
],
],
'Encryption & Integrity' => [
'keys' => [
'bitLockerEnabled',
'storageRequireEncryption',
'tpmRequired',
'secureBootEnabled',
'codeIntegrityEnabled',
'memoryIntegrityEnabled',
'kernelDmaProtectionEnabled',
'firmwareProtectionEnabled',
'virtualizationBasedSecurityEnabled',
'earlyLaunchAntiMalwareDriverEnabled',
],
'labels' => [
'bitLockerEnabled' => 'BitLocker required',
'storageRequireEncryption' => 'Storage encryption required',
'tpmRequired' => 'TPM required',
'secureBootEnabled' => 'Secure boot required',
'codeIntegrityEnabled' => 'Code integrity required',
'memoryIntegrityEnabled' => 'Memory integrity required',
'kernelDmaProtectionEnabled' => 'Kernel DMA protection required',
'firmwareProtectionEnabled' => 'Firmware protection required',
'virtualizationBasedSecurityEnabled' => 'Virtualization-based security required',
'earlyLaunchAntiMalwareDriverEnabled' => 'Early launch anti-malware required',
],
],
'Operating System' => [
'keys' => [
'osMinimumVersion',
'osMaximumVersion',
'mobileOsMinimumVersion',
'mobileOsMaximumVersion',
'validOperatingSystemBuildRanges',
'wslDistributions',
],
'labels' => [
'osMinimumVersion' => 'OS minimum version',
'osMaximumVersion' => 'OS maximum version',
'mobileOsMinimumVersion' => 'Mobile OS minimum version',
'mobileOsMaximumVersion' => 'Mobile OS maximum version',
'validOperatingSystemBuildRanges' => 'Valid OS build ranges',
'wslDistributions' => 'Allowed WSL distributions',
],
],
'Firewall' => [
'keys' => [
'activeFirewallRequired',
],
'labels' => [
'activeFirewallRequired' => 'Active firewall required',
],
],
'Compliance Signals' => [
'keys' => [
'configurationManagerComplianceRequired',
'deviceCompliancePolicyScript',
],
'labels' => [
'configurationManagerComplianceRequired' => 'ConfigMgr compliance required',
'deviceCompliancePolicyScript' => 'Compliance policy script',
],
],
];
}
/**
* @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[] = [
'label' => $labels[$key] ?? Str::headline($key),
'value' => $this->formatValue($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[] = [
'label' => Str::headline($key),
'value' => $this->formatValue($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',
'displayName',
'description',
'name',
'platform',
'platforms',
'technologies',
'settingCount',
'settingsCount',
'templateReference',
];
}
private function formatValue(mixed $value): mixed
{
if (is_array($value)) {
return json_encode($value, JSON_PRETTY_PRINT);
}
return $value;
}
}

View File

@ -0,0 +1,918 @@
<?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;
$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;
// 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
): 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;
}
// 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, '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 '-';
}
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',
];
$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);
// 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, '#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',
];
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)) {
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';
}
}

View File

@ -0,0 +1,126 @@
<?php
namespace App\Services\Intune;
use Illuminate\Support\Str;
class DeviceConfigurationPolicyNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'deviceConfiguration';
}
/**
* @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'
));
$configurationBlock = $this->buildConfigurationBlock($snapshot);
if ($configurationBlock !== null) {
$normalized['settings'][] = $configurationBlock;
}
return $normalized;
}
/**
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
return $this->defaultNormalizer->flattenForDiff($snapshot, $policyType, $platform);
}
/**
* @return array{type: string, title: string, entries: array<int, array<string, mixed>>}|null
*/
private function buildConfigurationBlock(array $snapshot): ?array
{
$entries = [];
$ignoredKeys = $this->ignoredKeys();
foreach ($snapshot as $key => $value) {
if (! is_string($key)) {
continue;
}
if (in_array($key, $ignoredKeys, true)) {
continue;
}
$entries[] = [
'key' => Str::headline($key),
'value' => $this->formatValue($value),
];
}
if ($entries === []) {
return null;
}
return [
'type' => 'keyValue',
'title' => 'Configuration',
'entries' => $entries,
];
}
/**
* @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',
'displayName',
'description',
'name',
'platform',
'platforms',
'technologies',
'settingCount',
'settingsCount',
'templateReference',
];
}
private function formatValue(mixed $value): mixed
{
if (is_array($value)) {
return json_encode($value, JSON_PRETTY_PRINT);
}
return $value;
}
}

View File

@ -58,7 +58,7 @@ public function capture(
// 2. Fetch assignments if requested // 2. Fetch assignments if requested
if ($includeAssignments) { if ($includeAssignments) {
try { try {
$rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions); $rawAssignments = $this->assignmentFetcher->fetch($policy->policy_type, $tenantIdentifier, $policy->external_id, $graphOptions);
if (! empty($rawAssignments)) { if (! empty($rawAssignments)) {
$resolvedGroups = []; $resolvedGroups = [];
@ -242,7 +242,7 @@ public function ensureVersionHasAssignments(
if ($includeAssignments && $version->assignments === null) { if ($includeAssignments && $version->assignments === null) {
try { try {
$rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions); $rawAssignments = $this->assignmentFetcher->fetch($policy->policy_type, $tenantIdentifier, $policy->external_id, $graphOptions);
if (! empty($rawAssignments)) { if (! empty($rawAssignments)) {
$resolvedGroups = []; $resolvedGroups = [];

View File

@ -2,912 +2,65 @@
namespace App\Services\Intune; namespace App\Services\Intune;
use App\Models\Policy; use Illuminate\Container\Attributes\Tag;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class PolicyNormalizer 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. * @var array<int, PolicyTypeNormalizer>
*/ */
private array $typeNormalizers;
public function __construct( public function __construct(
private readonly SnapshotValidator $validator, private readonly DefaultPolicyNormalizer $defaultNormalizer,
private readonly SettingsCatalogDefinitionResolver $definitionResolver, #[Tag('policy-type-normalizers')]
private readonly SettingsCatalogCategoryResolver $categoryResolver, iterable $typeNormalizers = [],
) {} ) {
$normalizers = is_array($typeNormalizers)
? $typeNormalizers
: iterator_to_array($typeNormalizers);
$this->typeNormalizers = array_values(array_filter(
$normalizers,
fn (mixed $normalizer) => $normalizer instanceof PolicyTypeNormalizer
));
}
/** /**
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>, context?: string, record_id?: string} * @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 public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
{ {
$snapshot = $snapshot ?? []; return $this->resolveNormalizer($policyType)
$resultWarnings = []; ->normalize($snapshot, $policyType, $platform);
$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> * @return array<string, mixed>
*/ */
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{ {
$normalized = $this->normalize($snapshot ?? [], $policyType, $platform); return $this->resolveNormalizer($policyType)
$map = []; ->flattenForDiff($snapshot, $policyType, $platform);
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;
// 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
): 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;
}
// 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, '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 '-';
}
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',
];
$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 * @param array<int, mixed> $settings
* @return array{type: string, groups: array<int, array<string, mixed>>} * @return array{type: string, groups: array<int, array<string, mixed>>}
*/ */
public function normalizeSettingsCatalogGrouped(array $settings): array public function normalizeSettingsCatalogGrouped(array $settings): array
{ {
// Extract all definition IDs return $this->defaultNormalizer->normalizeSettingsCatalogGrouped($settings);
$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,
];
} }
/** private function resolveNormalizer(string $policyType): PolicyTypeNormalizer
* Extract all definition IDs from settings array recursively.
*/
private function extractAllDefinitionIds(array $settings): array
{ {
$ids = []; foreach ($this->typeNormalizers as $normalizer) {
if ($normalizer->supports($policyType)) {
foreach ($settings as $setting) { return $normalizer;
// 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); return $this->defaultNormalizer;
}
/**
* 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" -> "Lowercase Letters: 0"
if (str_contains($value, 'device_vendor_msft') || str_contains($value, 'user_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',
];
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)) {
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';
} }
} }

View File

@ -36,11 +36,13 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
foreach ($types as $typeConfig) { foreach ($types as $typeConfig) {
$policyType = $typeConfig['type']; $policyType = $typeConfig['type'];
$platform = $typeConfig['platform'] ?? null; $platform = $typeConfig['platform'] ?? null;
$filter = $typeConfig['filter'] ?? null;
$this->graphLogger->logRequest('list_policies', [ $this->graphLogger->logRequest('list_policies', [
'tenant' => $tenantIdentifier, 'tenant' => $tenantIdentifier,
'policy_type' => $policyType, 'policy_type' => $policyType,
'platform' => $platform, 'platform' => $platform,
'filter' => $filter,
]); ]);
try { try {
@ -49,6 +51,7 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
'client_id' => $tenant->app_client_id, 'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret, 'client_secret' => $tenant->app_client_secret,
'platform' => $platform, 'platform' => $platform,
'filter' => $filter,
]); ]);
} catch (Throwable $throwable) { } catch (Throwable $throwable) {
throw GraphErrorMapper::fromThrowable($throwable, [ throw GraphErrorMapper::fromThrowable($throwable, [

View File

@ -0,0 +1,18 @@
<?php
namespace App\Services\Intune;
interface PolicyTypeNormalizer
{
public function supports(string $policyType): bool;
/**
* @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;
/**
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array;
}

View File

@ -43,9 +43,16 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
[$foundationItems, $policyItems] = $this->splitItems($items); [$foundationItems, $policyItems] = $this->splitItems($items);
$notificationTemplateIds = $foundationItems
->where('policy_type', 'notificationMessageTemplate')
->pluck('policy_identifier')
->filter()
->values()
->all();
$foundationPreview = $this->foundationMappingService->map($tenant, $foundationItems, false)['entries'] ?? []; $foundationPreview = $this->foundationMappingService->map($tenant, $foundationItems, false)['entries'] ?? [];
$policyPreview = $policyItems->map(function (BackupItem $item) use ($tenant) { $policyPreview = $policyItems->map(function (BackupItem $item) use ($tenant, $notificationTemplateIds) {
$existing = Policy::query() $existing = Policy::query()
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->where('external_id', $item->policy_identifier) ->where('external_id', $item->policy_identifier)
@ -54,7 +61,7 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
$restoreMode = $this->resolveRestoreMode($item->policy_type); $restoreMode = $this->resolveRestoreMode($item->policy_type);
return [ $preview = [
'backup_item_id' => $item->id, 'backup_item_id' => $item->id,
'policy_identifier' => $item->policy_identifier, 'policy_identifier' => $item->policy_identifier,
'policy_type' => $item->policy_type, 'policy_type' => $item->policy_type,
@ -68,6 +75,18 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
$item->platform $item->platform
) ?? ($this->snapshotValidator->validate(is_array($item->payload) ? $item->payload : [])['warnings'][0] ?? null), ) ?? ($this->snapshotValidator->validate(is_array($item->payload) ? $item->payload : [])['warnings'][0] ?? null),
]; ];
if ($item->policy_type === 'deviceCompliancePolicy') {
$preview = array_merge(
$preview,
$this->previewComplianceNotificationTemplates(
payload: is_array($item->payload) ? $item->payload : [],
availableTemplateIds: $notificationTemplateIds
)
);
}
return $preview;
})->all(); })->all();
return array_merge($foundationPreview, $policyPreview); return array_merge($foundationPreview, $policyPreview);
@ -201,6 +220,16 @@ public function execute(
try { try {
$originalPayload = is_array($item->payload) ? $item->payload : []; $originalPayload = is_array($item->payload) ? $item->payload : [];
$originalPayload = $this->applyScopeTagMapping($originalPayload, $scopeTagMapping); $originalPayload = $this->applyScopeTagMapping($originalPayload, $scopeTagMapping);
$complianceActionSummary = null;
$complianceActionOutcomes = null;
if ($item->policy_type === 'deviceCompliancePolicy') {
[$originalPayload, $complianceActionSummary, $complianceActionOutcomes] = $this->applyComplianceNotificationTemplateMapping(
payload: $originalPayload,
templateMapping: $foundationMappingByType['notificationMessageTemplate'] ?? []
);
}
$mappedScopeTagIds = $this->resolvePayloadArray($originalPayload, ['roleScopeTagIds', 'RoleScopeTagIds']); $mappedScopeTagIds = $this->resolvePayloadArray($originalPayload, ['roleScopeTagIds', 'RoleScopeTagIds']);
// sanitize high-level fields according to contract // sanitize high-level fields according to contract
@ -358,6 +387,25 @@ public function execute(
$itemStatus = 'partial'; $itemStatus = 'partial';
$resultReason = 'Assignments restored with failures'; $resultReason = 'Assignments restored with failures';
} }
}
if (is_array($complianceActionSummary) && ($complianceActionSummary['skipped'] ?? 0) > 0 && $itemStatus === 'applied') {
$itemStatus = 'partial';
$resultReason = 'Compliance notification actions skipped';
}
if ($complianceActionSummary !== null) {
$this->auditComplianceActionMapping(
tenant: $tenant,
restoreRun: $restoreRun,
policyId: $item->policy_identifier,
policyType: $item->policy_type,
summary: $complianceActionSummary,
outcomes: $complianceActionOutcomes ?? [],
actorEmail: $actorEmail,
actorName: $actorName
);
} }
$result = $context + [ $result = $context + [
@ -391,6 +439,14 @@ public function execute(
$result['assignment_summary'] = $assignmentSummary; $result['assignment_summary'] = $assignmentSummary;
} }
if ($complianceActionSummary !== null) {
$result['compliance_action_summary'] = $complianceActionSummary;
}
if ($complianceActionOutcomes !== null) {
$result['compliance_action_outcomes'] = $complianceActionOutcomes;
}
$results[] = $result; $results[] = $result;
$appliedPolicyId = $item->policy_identifier; $appliedPolicyId = $item->policy_identifier;
@ -599,6 +655,230 @@ private function applyScopeTagIdsToPayload(array $payload, ?array $scopeTagIds,
return $payload; return $payload;
} }
/**
* @param array<string, mixed> $payload
* @param array<int, string> $availableTemplateIds
* @return array<string, mixed>
*/
private function previewComplianceNotificationTemplates(array $payload, array $availableTemplateIds): array
{
$templateIds = $this->collectComplianceNotificationTemplateIds($payload);
if ($templateIds === []) {
return [];
}
$available = array_values(array_unique($availableTemplateIds));
$missing = array_values(array_diff($templateIds, $available));
$summary = [
'total' => count($templateIds),
'missing' => count($missing),
];
$warning = null;
if ($missing !== []) {
$warning = sprintf('Missing %d notification template(s); notification actions may be skipped.', count($missing));
}
return array_filter([
'compliance_action_summary' => $summary,
'compliance_action_warning' => $warning,
'compliance_action_missing_templates' => $missing !== [] ? $missing : null,
], static fn ($value) => $value !== null);
}
/**
* @param array<string, mixed> $payload
* @param array<string, string> $templateMapping
* @return array{0: array<string, mixed>, 1: ?array{total:int,mapped:int,skipped:int}, 2: ?array<int, array<string, mixed>>}
*/
private function applyComplianceNotificationTemplateMapping(array $payload, array $templateMapping): array
{
$scheduled = $payload['scheduledActionsForRule'] ?? null;
if (! is_array($scheduled)) {
return [$payload, null, null];
}
$rules = [];
$total = 0;
$mapped = 0;
$skipped = 0;
$outcomes = [];
foreach ($scheduled as $rule) {
if (! is_array($rule)) {
continue;
}
$configs = $rule['scheduledActionConfigurations'] ?? null;
if (! is_array($configs)) {
$rules[] = $rule;
continue;
}
$ruleName = $rule['ruleName'] ?? null;
$updatedConfigs = [];
foreach ($configs as $config) {
if (! is_array($config)) {
$updatedConfigs[] = $config;
continue;
}
$actionType = $config['actionType'] ?? null;
$templateKey = $this->resolveNotificationTemplateKey($config);
if ($actionType !== 'notification' || $templateKey === null) {
$updatedConfigs[] = $config;
continue;
}
$templateId = $config[$templateKey] ?? null;
if (! is_string($templateId) || $templateId === '' || $this->isEmptyGuid($templateId)) {
$updatedConfigs[] = $config;
continue;
}
$total++;
if ($templateMapping === []) {
$outcomes[] = [
'status' => 'skipped',
'template_id' => $templateId,
'rule_name' => $ruleName,
'reason' => 'Notification template mapping unavailable.',
];
$skipped++;
continue;
}
$mappedTemplateId = $templateMapping[$templateId] ?? null;
if (! is_string($mappedTemplateId) || $mappedTemplateId === '') {
$outcomes[] = [
'status' => 'skipped',
'template_id' => $templateId,
'rule_name' => $ruleName,
'reason' => 'Notification template mapping missing for template ID.',
];
$skipped++;
continue;
}
$config[$templateKey] = $mappedTemplateId;
$updatedConfigs[] = $config;
$mapped++;
$outcomes[] = [
'status' => 'mapped',
'template_id' => $templateId,
'mapped_template_id' => $mappedTemplateId,
'rule_name' => $ruleName,
];
}
if ($updatedConfigs === []) {
continue;
}
$rule['scheduledActionConfigurations'] = array_values($updatedConfigs);
$rules[] = $rule;
}
if ($rules !== []) {
$payload['scheduledActionsForRule'] = array_values($rules);
} else {
unset($payload['scheduledActionsForRule']);
}
if ($total === 0) {
return [$payload, null, null];
}
return [$payload, ['total' => $total, 'mapped' => $mapped, 'skipped' => $skipped], $outcomes];
}
/**
* @param array<string, mixed> $payload
* @return array<int, string>
*/
private function collectComplianceNotificationTemplateIds(array $payload): array
{
$scheduled = $payload['scheduledActionsForRule'] ?? null;
if (! is_array($scheduled)) {
return [];
}
$ids = [];
foreach ($scheduled as $rule) {
if (! is_array($rule)) {
continue;
}
$configs = $rule['scheduledActionConfigurations'] ?? null;
if (! is_array($configs)) {
continue;
}
foreach ($configs as $config) {
if (! is_array($config)) {
continue;
}
if (($config['actionType'] ?? null) !== 'notification') {
continue;
}
$templateKey = $this->resolveNotificationTemplateKey($config);
if ($templateKey === null) {
continue;
}
$templateId = $config[$templateKey] ?? null;
if (! is_string($templateId) || $templateId === '' || $this->isEmptyGuid($templateId)) {
continue;
}
$ids[] = $templateId;
}
}
return array_values(array_unique($ids));
}
private function resolveNotificationTemplateKey(array $config): ?string
{
if (array_key_exists('notificationTemplateId', $config)) {
return 'notificationTemplateId';
}
if (array_key_exists('notificationMessageTemplateId', $config)) {
return 'notificationMessageTemplateId';
}
return null;
}
private function isEmptyGuid(string $value): bool
{
return strtolower($value) === '00000000-0000-0000-0000-000000000000';
}
/** /**
* @param array<int, array<string, mixed>> $entries * @param array<int, array<string, mixed>> $entries
*/ */
@ -653,6 +933,51 @@ private function auditFoundationMapping(
} }
} }
/**
* @param array{total:int,mapped:int,skipped:int} $summary
* @param array<int, array<string, mixed>> $outcomes
*/
private function auditComplianceActionMapping(
Tenant $tenant,
RestoreRun $restoreRun,
string $policyId,
string $policyType,
array $summary,
array $outcomes,
?string $actorEmail,
?string $actorName
): void {
$skipped = (int) ($summary['skipped'] ?? 0);
$status = $skipped > 0 ? 'warning' : 'success';
$skippedTemplates = collect($outcomes)
->filter(fn (array $outcome) => ($outcome['status'] ?? null) === 'skipped')
->pluck('template_id')
->filter()
->values()
->all();
$this->auditLogger->log(
tenant: $tenant,
action: 'restore.compliance.actions.mapped',
context: [
'metadata' => [
'restore_run_id' => $restoreRun->id,
'policy_id' => $policyId,
'policy_type' => $policyType,
'total' => (int) ($summary['total'] ?? 0),
'mapped' => (int) ($summary['mapped'] ?? 0),
'skipped' => $skipped,
'skipped_template_ids' => $skippedTemplates,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $restoreRun->id,
status: $status
);
}
/** /**
* @param array<int>|null $selectedItemIds * @param array<int>|null $selectedItemIds
*/ */

View File

@ -0,0 +1,31 @@
<?php
namespace App\Services\Intune;
class SettingsCatalogPolicyNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'settingsCatalogPolicy';
}
/**
* @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
{
return $this->defaultNormalizer->normalize($snapshot, $policyType, $platform);
}
/**
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
return $this->defaultNormalizer->flattenForDiff($snapshot, $policyType, $platform);
}
}

View File

@ -91,7 +91,7 @@ public function captureFromGraph(
if ($includeAssignments) { if ($includeAssignments) {
try { try {
$rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions); $rawAssignments = $this->assignmentFetcher->fetch($policy->policy_type, $tenantIdentifier, $policy->external_id, $graphOptions);
if (! empty($rawAssignments)) { if (! empty($rawAssignments)) {
$resolvedGroups = []; $resolvedGroups = [];

View File

@ -17,10 +17,18 @@ protected static function odataTypeMap(): array
'macOS' => '#microsoft.graph.macOSGeneralDeviceConfiguration', 'macOS' => '#microsoft.graph.macOSGeneralDeviceConfiguration',
'all' => '#microsoft.graph.deviceConfiguration', 'all' => '#microsoft.graph.deviceConfiguration',
], ],
'groupPolicyConfiguration' => [
'windows' => '#microsoft.graph.groupPolicyConfiguration',
'all' => '#microsoft.graph.groupPolicyConfiguration',
],
'settingsCatalogPolicy' => [ 'settingsCatalogPolicy' => [
'windows' => '#microsoft.graph.deviceManagementConfigurationPolicy', 'windows' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'all' => '#microsoft.graph.deviceManagementConfigurationPolicy', 'all' => '#microsoft.graph.deviceManagementConfigurationPolicy',
], ],
'windowsUpdateRing' => [
'windows' => '#microsoft.graph.windowsUpdateForBusinessConfiguration',
'all' => '#microsoft.graph.windowsUpdateForBusinessConfiguration',
],
'deviceCompliancePolicy' => [ 'deviceCompliancePolicy' => [
'windows' => '#microsoft.graph.windows10CompliancePolicy', 'windows' => '#microsoft.graph.windows10CompliancePolicy',
'ios' => '#microsoft.graph.iosCompliancePolicy', 'ios' => '#microsoft.graph.iosCompliancePolicy',
@ -38,6 +46,14 @@ protected static function odataTypeMap(): array
'deviceManagementScript' => [ 'deviceManagementScript' => [
'windows' => '#microsoft.graph.deviceManagementScript', 'windows' => '#microsoft.graph.deviceManagementScript',
], ],
'deviceShellScript' => [
'macOS' => '#microsoft.graph.deviceShellScript',
'all' => '#microsoft.graph.deviceShellScript',
],
'deviceHealthScript' => [
'windows' => '#microsoft.graph.deviceHealthScript',
'all' => '#microsoft.graph.deviceHealthScript',
],
'enrollmentRestriction' => [ 'enrollmentRestriction' => [
'all' => '#microsoft.graph.deviceEnrollmentConfiguration', 'all' => '#microsoft.graph.deviceEnrollmentConfiguration',
], ],

View File

@ -27,6 +27,34 @@
'update_method' => 'PATCH', 'update_method' => 'PATCH',
'id_field' => 'id', 'id_field' => 'id',
'hydration' => 'properties', 'hydration' => 'properties',
'assignments_list_path' => '/deviceManagement/deviceConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceConfigurations/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/deviceConfigurations/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/deviceConfigurations/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds',
],
'groupPolicyConfiguration' => [
'resource' => 'deviceManagement/groupPolicyConfigurations',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.groupPolicyConfiguration',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'assignments_list_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
], ],
'settingsCatalogPolicy' => [ 'settingsCatalogPolicy' => [
'resource' => 'deviceManagement/configurationPolicies', 'resource' => 'deviceManagement/configurationPolicies',
@ -84,6 +112,27 @@
'supports_scope_tags' => true, 'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds', 'scope_tag_field' => 'roleScopeTagIds',
], ],
'windowsUpdateRing' => [
'resource' => 'deviceManagement/deviceConfigurations',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.windowsUpdateForBusinessConfiguration',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'assignments_list_path' => '/deviceManagement/deviceConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceConfigurations/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/deviceConfigurations/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/deviceConfigurations/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds',
],
'deviceCompliancePolicy' => [ 'deviceCompliancePolicy' => [
'resource' => 'deviceManagement/deviceCompliancePolicies', 'resource' => 'deviceManagement/deviceCompliancePolicies',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'],
@ -99,6 +148,15 @@
'update_method' => 'PATCH', 'update_method' => 'PATCH',
'id_field' => 'id', 'id_field' => 'id',
'hydration' => 'properties', 'hydration' => 'properties',
'assignments_list_path' => '/deviceManagement/deviceCompliancePolicies/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceCompliancePolicies/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/deviceCompliancePolicies/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/deviceCompliancePolicies/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds',
], ],
'appProtectionPolicy' => [ 'appProtectionPolicy' => [
'resource' => 'deviceAppManagement/managedAppPolicies', 'resource' => 'deviceAppManagement/managedAppPolicies',
@ -135,6 +193,51 @@
'update_method' => 'PATCH', 'update_method' => 'PATCH',
'id_field' => 'id', 'id_field' => 'id',
'hydration' => 'properties', 'hydration' => 'properties',
'assignments_list_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceManagementScripts/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
],
'deviceShellScript' => [
'resource' => 'deviceManagement/deviceShellScripts',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.deviceShellScript',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'assignments_list_path' => '/deviceManagement/deviceShellScripts/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceShellScripts/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/deviceShellScripts/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/deviceShellScripts/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
],
'deviceHealthScript' => [
'resource' => 'deviceManagement/deviceHealthScripts',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.deviceHealthScript',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'assignments_list_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceHealthScripts/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
], ],
'enrollmentRestriction' => [ 'enrollmentRestriction' => [
'resource' => 'deviceManagement/deviceEnrollmentConfigurations', 'resource' => 'deviceManagement/deviceEnrollmentConfigurations',

View File

@ -77,8 +77,14 @@
[ [
'key' => 'DeviceManagementScripts.ReadWrite.All', 'key' => 'DeviceManagementScripts.ReadWrite.All',
'type' => 'application', 'type' => 'application',
'description' => 'Read directory data needed for tenant health checks.', 'description' => 'Manage Intune device management scripts and remediations.',
'features' => ['script-management'], 'features' => ['policy-sync', 'backup', 'restore', 'scripts', 'remediations'],
],
[
'key' => 'DeviceManagementScripts.Read.All',
'type' => 'application',
'description' => 'Read Intune device management scripts and remediations.',
'features' => ['policy-sync', 'backup', 'scripts', 'remediations'],
], ],
], ],
// Stub list of permissions already granted to the service principal (used for display in Tenant verification UI). // Stub list of permissions already granted to the service principal (used for display in Tenant verification UI).

View File

@ -8,6 +8,17 @@
'category' => 'Configuration', 'category' => 'Configuration',
'platform' => 'all', 'platform' => 'all',
'endpoint' => 'deviceManagement/deviceConfigurations', 'endpoint' => 'deviceManagement/deviceConfigurations',
'filter' => "@odata.type ne '#microsoft.graph.windowsUpdateForBusinessConfiguration'",
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'medium',
],
[
'type' => 'groupPolicyConfiguration',
'label' => 'Administrative Templates',
'category' => 'Configuration',
'platform' => 'windows',
'endpoint' => 'deviceManagement/groupPolicyConfigurations',
'backup' => 'full', 'backup' => 'full',
'restore' => 'enabled', 'restore' => 'enabled',
'risk' => 'medium', 'risk' => 'medium',
@ -22,6 +33,17 @@
'restore' => 'enabled', 'restore' => 'enabled',
'risk' => 'medium', 'risk' => 'medium',
], ],
[
'type' => 'windowsUpdateRing',
'label' => 'Software Update Ring',
'category' => 'Update Management',
'platform' => 'windows',
'endpoint' => 'deviceManagement/deviceConfigurations',
'filter' => "@odata.type eq '#microsoft.graph.windowsUpdateForBusinessConfiguration'",
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'medium-high',
],
[ [
'type' => 'deviceCompliancePolicy', 'type' => 'deviceCompliancePolicy',
'label' => 'Device Compliance', 'label' => 'Device Compliance',
@ -62,6 +84,26 @@
'restore' => 'enabled', 'restore' => 'enabled',
'risk' => 'medium', 'risk' => 'medium',
], ],
[
'type' => 'deviceShellScript',
'label' => 'macOS Shell Scripts',
'category' => 'Scripts',
'platform' => 'macOS',
'endpoint' => 'deviceManagement/deviceShellScripts',
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'medium',
],
[
'type' => 'deviceHealthScript',
'label' => 'Proactive Remediations',
'category' => 'Scripts',
'platform' => 'windows',
'endpoint' => 'deviceManagement/deviceHealthScripts',
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'medium',
],
[ [
'type' => 'enrollmentRestriction', 'type' => 'enrollmentRestriction',
'label' => 'Enrollment Restrictions', 'label' => 'Enrollment Restrictions',
@ -88,7 +130,7 @@
'category' => 'Enrollment', 'category' => 'Enrollment',
'platform' => 'all', 'platform' => 'all',
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations',
'filter' => "odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'", 'filter' => "@odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'",
'backup' => 'full', 'backup' => 'full',
'restore' => 'enabled', 'restore' => 'enabled',
'risk' => 'medium', 'risk' => 'medium',

View File

@ -82,6 +82,12 @@
{{ $item['validation_warning'] }} {{ $item['validation_warning'] }}
</div> </div>
@endif @endif
@if (! empty($item['compliance_action_warning']))
<div class="mt-2 rounded border border-amber-300 bg-amber-50 px-2 py-1 text-xs text-amber-800">
{{ $item['compliance_action_warning'] }}
</div>
@endif
</div> </div>
@endforeach @endforeach
</div> </div>

View File

@ -189,6 +189,51 @@
@endif @endif
@endif @endif
@if (! empty($item['compliance_action_summary']) && is_array($item['compliance_action_summary']))
@php
$summary = $item['compliance_action_summary'];
$complianceOutcomes = $item['compliance_action_outcomes'] ?? [];
$complianceIssues = collect($complianceOutcomes)
->filter(fn ($outcome) => ($outcome['status'] ?? null) === 'skipped')
->values();
@endphp
<div class="mt-2 text-xs text-gray-700">
Compliance notifications: {{ (int) ($summary['mapped'] ?? 0) }} mapped
{{ (int) ($summary['skipped'] ?? 0) }} skipped
</div>
@if ($complianceIssues->isNotEmpty())
<details class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
<summary class="cursor-pointer font-semibold">Compliance notification details</summary>
<div class="mt-2 space-y-2">
@foreach ($complianceIssues as $outcome)
<div class="rounded border border-amber-200 bg-white p-2">
<div class="flex items-center justify-between">
<div class="font-semibold text-gray-900">
Template {{ $outcome['template_id'] ?? 'unknown' }}
</div>
<span class="rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-900 bg-amber-100 border-amber-200">
skipped
</span>
</div>
@if (! empty($outcome['rule_name']))
<div class="mt-1 text-[11px] text-gray-700">
Rule: {{ $outcome['rule_name'] }}
</div>
@endif
@if (! empty($outcome['reason']))
<div class="mt-1 text-[11px] text-gray-800">
{{ $outcome['reason'] }}
</div>
@endif
</div>
@endforeach
</div>
</details>
@endif
@endif
@if (! empty($item['created_policy_id'])) @if (! empty($item['created_policy_id']))
@php @php
$createdMode = $item['created_policy_mode'] ?? null; $createdMode = $item['created_policy_mode'] ?? null;

View File

@ -23,6 +23,7 @@ ### Full Scope (Phase 2+)
- Endpoint security: security baselines (Windows security baseline, Microsoft Defender, Microsoft Edge); endpoint privilege management policies. - Endpoint security: security baselines (Windows security baseline, Microsoft Defender, Microsoft Edge); endpoint privilege management policies.
- Tenant administration: device cleanup rules; RBAC roles and role assignments. - Tenant administration: device cleanup rules; RBAC roles and role assignments.
- Connectors and tokens (metadata-only): APNs; VPP tokens; managed Google Play; certificate connectors; remote help settings. - Connectors and tokens (metadata-only): APNs; VPP tokens; managed Google Play; certificate connectors; remote help settings.
- Inventory / Properties catalog policies (deviceManagement/inventoryPolicies) deferred until required permissions are confirmed.
## Overview ## Overview
Expand backup and restore coverage for device configuration and compliance workloads, including scripts and remediations. This feature focuses on policy types that are already core to DR and rollback, and builds on existing foundations and assignment mapping capabilities. Expand backup and restore coverage for device configuration and compliance workloads, including scripts and remediations. This feature focuses on policy types that are already core to DR and rollback, and builds on existing foundations and assignment mapping capabilities.
@ -75,4 +76,3 @@ ## Success Criteria (mandatory)
- **SC-007.1**: For a backup containing at least 10 mixed configuration/compliance items, restore completes with 100% of items in Applied, Partial, or Skipped with reason (no silent failures). - **SC-007.1**: For a backup containing at least 10 mixed configuration/compliance items, restore completes with 100% of items in Applied, Partial, or Skipped with reason (no silent failures).
- **SC-007.2**: At least 95% of assignments in a mixed restore are either applied successfully or explicitly skipped with a recorded reason. - **SC-007.2**: At least 95% of assignments in a mixed restore are either applied successfully or explicitly skipped with a recorded reason.
- **SC-007.3**: Restore preview for 100 selected items completes in under 2 minutes in a typical admin environment. - **SC-007.3**: Restore preview for 100 selected items completes in under 2 minutes in a typical admin environment.

View File

@ -72,3 +72,8 @@ ## Phase 5: Tests and Verification
**Checkpoint**: Tests pass and formatting is clean. **Checkpoint**: Tests pass and formatting is clean.
---
## Deferred / Backlog
- [ ] T019 [Deferred] Add inventory/properties catalog policies (`deviceManagement/inventoryPolicies`) once required permissions are confirmed; include contracts, sync, snapshot hydration via `/settings`, and normalized UI display.

View File

@ -193,7 +193,7 @@
// "Device Vendor Msft Policy Config Uncached Test Setting" // "Device Vendor Msft Policy Config Uncached Test Setting"
})->skip('Manual UI verification required'); })->skip('Manual UI verification required');
it('does not show Settings tab for non-Settings Catalog policies', function () { it('shows tabbed layout for non-Settings Catalog policies', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
'name' => 'Test Tenant', 'name' => 'Test Tenant',
@ -234,7 +234,9 @@
->get(PolicyResource::getUrl('view', ['record' => $policy])); ->get(PolicyResource::getUrl('view', ['record' => $policy]));
$response->assertOk(); $response->assertOk();
// Verify page renders successfully for non-Settings Catalog policies $response->assertSee('General');
$response->assertSee('Settings');
$response->assertSee('JSON');
}); });
// T034: Test display names shown (not definition IDs) // T034: Test display names shown (not definition IDs)

View File

@ -182,3 +182,103 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
'resource_id' => (string) $run->id, 'resource_id' => (string) $run->id,
]); ]);
}); });
test('restore execution records compliance notification mapping outcomes', function () {
app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface
{
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true, ['payload' => []]);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
});
$tenant = Tenant::create([
'tenant_id' => 'tenant-3',
'name' => 'Tenant Three',
'metadata' => [],
]);
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-3',
'policy_type' => 'deviceCompliancePolicy',
'display_name' => 'Compliance Policy',
'platform' => 'windows',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => [
'scheduledActionsForRule' => [
[
'ruleName' => 'Password',
'scheduledActionConfigurations' => [
[
'actionType' => 'notification',
'notificationTemplateId' => 'template-1',
],
],
],
],
],
]);
$user = User::factory()->create(['email' => 'tester@example.com']);
$this->actingAs($user);
$service = app(RestoreService::class);
$run = $service->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: [$backupItem->id],
dryRun: false,
actorEmail: $user->email,
actorName: $user->name,
);
expect($run->status)->toBe('partial');
expect($run->results[0]['status'])->toBe('partial');
expect($run->results[0]['compliance_action_summary']['skipped'] ?? null)->toBe(1);
$this->assertDatabaseHas('audit_logs', [
'action' => 'restore.compliance.actions.mapped',
'resource_id' => (string) $run->id,
]);
});

View File

@ -103,3 +103,89 @@ public function request(string $method, string $path, array $options = []): Grap
$policyPreview = collect($preview)->first(fn (array $item) => isset($item['action'])); $policyPreview = collect($preview)->first(fn (array $item) => isset($item['action']));
expect($policyPreview['action'])->toBe('update'); expect($policyPreview['action'])->toBe('update');
}); });
test('restore preview warns about missing compliance notification templates', function () {
app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface
{
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true, ['payload' => []]);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
});
$tenant = Tenant::create([
'tenant_id' => 'tenant-2',
'name' => 'Tenant Two',
'metadata' => [],
]);
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-2',
'policy_type' => 'deviceCompliancePolicy',
'display_name' => 'Compliance Policy',
'platform' => 'windows',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 1,
]);
BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => [
'scheduledActionsForRule' => [
[
'ruleName' => 'Password',
'scheduledActionConfigurations' => [
[
'actionType' => 'notification',
'notificationTemplateId' => 'template-1',
],
],
],
],
],
]);
$service = app(RestoreService::class);
$preview = $service->preview($tenant, $backupSet);
$policyPreview = collect($preview)->first(fn (array $item) => ($item['policy_type'] ?? null) === 'deviceCompliancePolicy');
expect($policyPreview['compliance_action_warning'] ?? null)->not->toBeNull();
expect(($policyPreview['compliance_action_summary']['missing'] ?? 0))->toBe(1);
});

View File

@ -1,6 +1,7 @@
<?php <?php
use App\Services\Graph\AssignmentFetcher; use App\Services\Graph\AssignmentFetcher;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphException; use App\Services\Graph\GraphException;
use App\Services\Graph\GraphResponse; use App\Services\Graph\GraphResponse;
use App\Services\Graph\MicrosoftGraphClient; use App\Services\Graph\MicrosoftGraphClient;
@ -11,12 +12,13 @@
beforeEach(function () { beforeEach(function () {
$this->graphClient = Mockery::mock(MicrosoftGraphClient::class); $this->graphClient = Mockery::mock(MicrosoftGraphClient::class);
$this->fetcher = new AssignmentFetcher($this->graphClient); $this->fetcher = new AssignmentFetcher($this->graphClient, app(GraphContractRegistry::class));
}); });
test('primary endpoint success', function () { test('primary endpoint success', function () {
$tenantId = 'tenant-123'; $tenantId = 'tenant-123';
$policyId = 'policy-456'; $policyId = 'policy-456';
$policyType = 'settingsCatalogPolicy';
$assignments = [ $assignments = [
['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']], ['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']],
['id' => 'assign-2', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-2']], ['id' => 'assign-2', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-2']],
@ -35,7 +37,7 @@
]) ])
->andReturn($response); ->andReturn($response);
$result = $this->fetcher->fetch($tenantId, $policyId); $result = $this->fetcher->fetch($policyType, $tenantId, $policyId);
expect($result)->toBe($assignments); expect($result)->toBe($assignments);
}); });
@ -43,6 +45,7 @@
test('fallback on empty response', function () { test('fallback on empty response', function () {
$tenantId = 'tenant-123'; $tenantId = 'tenant-123';
$policyId = 'policy-456'; $policyId = 'policy-456';
$policyType = 'settingsCatalogPolicy';
$assignments = [ $assignments = [
['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']], ['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']],
]; ];
@ -70,7 +73,7 @@
$this->graphClient $this->graphClient
->shouldReceive('request') ->shouldReceive('request')
->once() ->once()
->with('GET', '/deviceManagement/configurationPolicies', [ ->with('GET', 'deviceManagement/configurationPolicies', [
'tenant' => $tenantId, 'tenant' => $tenantId,
'query' => [ 'query' => [
'$expand' => 'assignments', '$expand' => 'assignments',
@ -79,7 +82,7 @@
]) ])
->andReturn($fallbackResponse); ->andReturn($fallbackResponse);
$result = $this->fetcher->fetch($tenantId, $policyId); $result = $this->fetcher->fetch($policyType, $tenantId, $policyId);
expect($result)->toBe($assignments); expect($result)->toBe($assignments);
}); });
@ -87,13 +90,14 @@
test('fail soft on error', function () { test('fail soft on error', function () {
$tenantId = 'tenant-123'; $tenantId = 'tenant-123';
$policyId = 'policy-456'; $policyId = 'policy-456';
$policyType = 'settingsCatalogPolicy';
$this->graphClient $this->graphClient
->shouldReceive('request') ->shouldReceive('request')
->once() ->once()
->andThrow(new GraphException('Graph API error', 500, ['request_id' => 'request-id-123'])); ->andThrow(new GraphException('Graph API error', 500, ['request_id' => 'request-id-123']));
$result = $this->fetcher->fetch($tenantId, $policyId); $result = $this->fetcher->fetch($policyType, $tenantId, $policyId);
expect($result)->toBe([]); expect($result)->toBe([]);
}); });
@ -101,6 +105,7 @@
test('returns empty array when both endpoints return empty', function () { test('returns empty array when both endpoints return empty', function () {
$tenantId = 'tenant-123'; $tenantId = 'tenant-123';
$policyId = 'policy-456'; $policyId = 'policy-456';
$policyType = 'settingsCatalogPolicy';
// Primary returns empty // Primary returns empty
$primaryResponse = new GraphResponse( $primaryResponse = new GraphResponse(
@ -123,10 +128,10 @@
$this->graphClient $this->graphClient
->shouldReceive('request') ->shouldReceive('request')
->once() ->once()
->with('GET', '/deviceManagement/configurationPolicies', Mockery::any()) ->with('GET', 'deviceManagement/configurationPolicies', Mockery::any())
->andReturn($fallbackResponse); ->andReturn($fallbackResponse);
$result = $this->fetcher->fetch($tenantId, $policyId); $result = $this->fetcher->fetch($policyType, $tenantId, $policyId);
expect($result)->toBe([]); expect($result)->toBe([]);
}); });
@ -134,6 +139,7 @@
test('fallback handles missing assignments key', function () { test('fallback handles missing assignments key', function () {
$tenantId = 'tenant-123'; $tenantId = 'tenant-123';
$policyId = 'policy-456'; $policyId = 'policy-456';
$policyType = 'settingsCatalogPolicy';
// Primary returns empty // Primary returns empty
$primaryResponse = new GraphResponse( $primaryResponse = new GraphResponse(
@ -157,7 +163,7 @@
->once() ->once()
->andReturn($fallbackResponse); ->andReturn($fallbackResponse);
$result = $this->fetcher->fetch($tenantId, $policyId); $result = $this->fetcher->fetch($policyType, $tenantId, $policyId);
expect($result)->toBe([]); expect($result)->toBe([]);
}); });

View File

@ -0,0 +1,36 @@
<?php
use App\Services\Intune\CompliancePolicyNormalizer;
uses(Tests\TestCase::class);
it('groups compliance policy fields into structured blocks', function () {
$normalizer = app(CompliancePolicyNormalizer::class);
$snapshot = [
'@odata.type' => '#microsoft.graph.windows10CompliancePolicy',
'passwordRequired' => true,
'passwordMinimumLength' => 8,
'defenderEnabled' => true,
'bitLockerEnabled' => false,
'osMinimumVersion' => '10.0.19045',
'activeFirewallRequired' => true,
'customSetting' => 'Custom value',
];
$normalized = $normalizer->normalize($snapshot, 'deviceCompliancePolicy', 'windows');
$settings = collect($normalized['settings']);
$passwordBlock = $settings->firstWhere('title', 'Password & Access');
expect($passwordBlock)->not->toBeNull();
expect(collect($passwordBlock['rows'])->pluck('label')->all())
->toContain('Password required', 'Password minimum length');
$additionalBlock = $settings->firstWhere('title', 'Additional Settings');
expect($additionalBlock)->not->toBeNull();
expect(collect($additionalBlock['rows'])->pluck('label')->all())
->toContain('Custom Setting');
expect($settings->pluck('title')->all())->not->toContain('General');
});

View File

@ -0,0 +1,39 @@
<?php
use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
uses(Tests\TestCase::class);
it('builds a configuration block for device configuration policies', function () {
$normalizer = app(DeviceConfigurationPolicyNormalizer::class);
$snapshot = [
'@odata.type' => '#microsoft.graph.windows10CustomConfiguration',
'displayName' => 'Device Config Policy',
'description' => 'Test policy',
'omaSettings' => [
[
'displayName' => 'Setting A',
'omaUri' => './Vendor/MSFT/SettingA',
'value' => 'Enabled',
],
],
'customSetting' => 'Custom value',
'nestedSetting' => ['value' => 'Nested'],
];
$normalized = $normalizer->normalize($snapshot, 'deviceConfiguration', 'windows');
$settings = collect($normalized['settings']);
$omaBlock = $settings->firstWhere('title', 'OMA-URI settings');
expect($omaBlock)->not->toBeNull();
$configurationBlock = $settings->firstWhere('title', 'Configuration');
expect($configurationBlock)->not->toBeNull();
$labels = collect($configurationBlock['entries'])->pluck('key')->all();
expect($labels)->toContain('Custom Setting', 'Nested Setting');
expect($labels)->not->toContain('Display Name');
expect($settings->pluck('title')->all())->not->toContain('General');
});

View File

@ -0,0 +1,44 @@
<?php
use App\Services\Intune\DefaultPolicyNormalizer;
use App\Services\Intune\PolicyNormalizer;
use App\Services\Intune\PolicyTypeNormalizer;
uses(Tests\TestCase::class);
it('routes to the first matching policy type normalizer', function () {
$defaultNormalizer = app(DefaultPolicyNormalizer::class);
$customNormalizer = new class implements PolicyTypeNormalizer
{
public function supports(string $policyType): bool
{
return $policyType === 'deviceCompliancePolicy';
}
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
{
return [
'status' => 'custom',
'settings' => [],
'warnings' => [],
];
}
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
return ['custom' => true];
}
};
$normalizer = new PolicyNormalizer($defaultNormalizer, [$customNormalizer]);
$custom = $normalizer->normalize(['id' => 'policy-1'], 'deviceCompliancePolicy', 'windows');
expect($custom['status'])->toBe('custom');
$customDiff = $normalizer->flattenForDiff(['id' => 'policy-1'], 'deviceCompliancePolicy', 'windows');
expect($customDiff)->toBe(['custom' => true]);
$fallback = $normalizer->normalize(['id' => 'policy-1'], 'unknownPolicy', 'windows');
expect($fallback['status'])->not->toBe('custom');
});

View File

@ -0,0 +1,33 @@
<?php
use App\Services\Intune\SettingsCatalogPolicyNormalizer;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(Tests\TestCase::class, RefreshDatabase::class);
it('builds a settings table for settings catalog policies', function () {
$normalizer = app(SettingsCatalogPolicyNormalizer::class);
$snapshot = [
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'settings' => [
[
'id' => 's1',
'settingInstance' => [
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance',
'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring',
'simpleSettingValue' => [
'value' => 1,
],
],
],
],
];
$normalized = $normalizer->normalize($snapshot, 'settingsCatalogPolicy', 'windows');
$rows = $normalized['settings_table']['rows'] ?? [];
expect($rows)->toHaveCount(1);
expect($rows[0]['definition_id'] ?? null)->toBe('device_vendor_msft_policy_config_defender_allowrealtimemonitoring');
});