merge: agent session work
This commit is contained in:
commit
783d8581b9
@ -62,7 +62,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
// For Settings Catalog policies: Tabs with Settings table + JSON viewer
|
||||
// Tabbed content (General / Settings / JSON)
|
||||
Tabs::make('policy_content')
|
||||
->activeTab(1)
|
||||
->persistTabInQueryString()
|
||||
@ -74,10 +74,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('')
|
||||
->view('filament.infolists.entries.policy-general')
|
||||
->state(function (Policy $record) {
|
||||
$normalized = static::normalizedPolicyState($record);
|
||||
$split = static::splitGeneralBlock($normalized);
|
||||
|
||||
return $split['general'];
|
||||
return static::generalOverviewState($record);
|
||||
}),
|
||||
])
|
||||
->visible(fn (Policy $record) => $record->versions()->exists()),
|
||||
@ -88,12 +85,9 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('')
|
||||
->view('filament.infolists.entries.normalized-settings')
|
||||
->state(function (Policy $record) {
|
||||
$normalized = static::normalizedPolicyState($record);
|
||||
$split = static::splitGeneralBlock($normalized);
|
||||
|
||||
return $split['normalized'];
|
||||
return static::settingsTabState($record);
|
||||
})
|
||||
->visible(fn (Policy $record) => $record->policy_type === 'settingsCatalogPolicy' &&
|
||||
->visible(fn (Policy $record) => static::hasSettingsTable($record) &&
|
||||
$record->versions()->exists()
|
||||
),
|
||||
|
||||
@ -101,12 +95,9 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('')
|
||||
->view('filament.infolists.entries.policy-settings-standard')
|
||||
->state(function (Policy $record) {
|
||||
$normalized = static::normalizedPolicyState($record);
|
||||
$split = static::splitGeneralBlock($normalized);
|
||||
|
||||
return $split['normalized'];
|
||||
return static::settingsTabState($record);
|
||||
})
|
||||
->visible(fn (Policy $record) => $record->policy_type !== 'settingsCatalogPolicy' &&
|
||||
->visible(fn (Policy $record) => ! static::hasSettingsTable($record) &&
|
||||
$record->versions()->exists()
|
||||
),
|
||||
|
||||
@ -144,12 +135,9 @@ public static function infolist(Schema $schema): Schema
|
||||
->visible(fn (Policy $record) => $record->versions()->exists()),
|
||||
])
|
||||
->columnSpanFull()
|
||||
->visible(function (Policy $record) {
|
||||
return str_contains(strtolower($record->policy_type ?? ''), 'settings') ||
|
||||
str_contains(strtolower($record->policy_type ?? ''), 'catalog');
|
||||
}),
|
||||
->visible(fn (Policy $record) => static::usesTabbedLayout($record)),
|
||||
|
||||
// For non-Settings Catalog policies: Simple sections without tabs
|
||||
// Legacy layout (kept for fallback if tabs are disabled)
|
||||
Section::make('Settings')
|
||||
->schema([
|
||||
ViewEntry::make('settings')
|
||||
@ -170,9 +158,7 @@ public static function infolist(Schema $schema): Schema
|
||||
])
|
||||
->columnSpanFull()
|
||||
->visible(function (Policy $record) {
|
||||
// Show simple settings section for non-Settings Catalog policies
|
||||
return ! str_contains(strtolower($record->policy_type ?? ''), 'settings') &&
|
||||
! str_contains(strtolower($record->policy_type ?? ''), 'catalog');
|
||||
return ! static::usesTabbedLayout($record);
|
||||
}),
|
||||
|
||||
Section::make('Policy Snapshot (JSON)')
|
||||
@ -205,9 +191,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->description('Raw JSON configuration from Microsoft Graph API')
|
||||
->columnSpanFull()
|
||||
->visible(function (Policy $record) {
|
||||
// Show standalone JSON section only for non-Settings Catalog policies
|
||||
return ! str_contains(strtolower($record->policy_type ?? ''), 'settings') &&
|
||||
! str_contains(strtolower($record->policy_type ?? ''), 'catalog');
|
||||
return ! static::usesTabbedLayout($record);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
@ -690,4 +674,101 @@ private static function typeMeta(?string $type): array
|
||||
return collect(config('tenantpilot.supported_policy_types', []))
|
||||
->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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,6 +51,16 @@ public function handle(AssignmentBackupService $assignmentBackupService): void
|
||||
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
|
||||
if ($backupItem->policy_type !== 'settingsCatalogPolicy') {
|
||||
Log::info('FetchAssignmentsJob: Skipping non-Settings Catalog policy', [
|
||||
@ -63,8 +73,9 @@ public function handle(AssignmentBackupService $assignmentBackupService): void
|
||||
|
||||
$assignmentBackupService->enrichWithAssignments(
|
||||
backupItem: $backupItem,
|
||||
tenantId: $this->tenantExternalId,
|
||||
policyId: $this->policyExternalId,
|
||||
tenant: $tenant,
|
||||
policyType: $backupItem->policy_type,
|
||||
policyId: $backupItem->policy_identifier,
|
||||
policyPayload: $this->policyPayload,
|
||||
includeAssignments: true
|
||||
);
|
||||
|
||||
@ -5,6 +5,9 @@
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\MicrosoftGraphClient;
|
||||
use App\Services\Graph\NullGraphClient;
|
||||
use App\Services\Intune\CompliancePolicyNormalizer;
|
||||
use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
|
||||
use App\Services\Intune\SettingsCatalogPolicyNormalizer;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@ -27,6 +30,15 @@ public function register(): void
|
||||
|
||||
return $app->make(NullGraphClient::class);
|
||||
});
|
||||
|
||||
$this->app->tag(
|
||||
[
|
||||
CompliancePolicyNormalizer::class,
|
||||
DeviceConfigurationPolicyNormalizer::class,
|
||||
SettingsCatalogPolicyNormalizer::class,
|
||||
],
|
||||
'policy-type-normalizers'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -24,6 +24,7 @@ public function __construct(
|
||||
*
|
||||
* @param BackupItem $backupItem The backup item to enrich
|
||||
* @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 array $policyPayload Full policy payload from Graph
|
||||
* @param bool $includeAssignments Whether to fetch and include assignments
|
||||
@ -32,6 +33,7 @@ public function __construct(
|
||||
public function enrichWithAssignments(
|
||||
BackupItem $backupItem,
|
||||
Tenant $tenant,
|
||||
string $policyType,
|
||||
string $policyId,
|
||||
array $policyPayload,
|
||||
bool $includeAssignments = false
|
||||
@ -58,7 +60,7 @@ public function enrichWithAssignments(
|
||||
// Fetch assignments from Graph API
|
||||
$graphOptions = $tenant->graphOptions();
|
||||
$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)) {
|
||||
// No assignments or fetch failed
|
||||
|
||||
@ -8,27 +8,32 @@ class AssignmentFetcher
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MicrosoftGraphClient $graphClient,
|
||||
private readonly GraphContractRegistry $contracts,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fetch policy assignments with fallback strategy.
|
||||
*
|
||||
* Primary: GET /deviceManagement/configurationPolicies/{id}/assignments
|
||||
* Fallback: GET /deviceManagement/configurationPolicies?$expand=assignments&$filter=id eq '{id}'
|
||||
* Primary: GET {assignments_list_path}
|
||||
* Fallback: GET {resource}?$expand=assignments&$filter=id eq '{id}'
|
||||
*
|
||||
* @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 {
|
||||
$contract = $this->contracts->get($policyType);
|
||||
$listPathTemplate = $contract['assignments_list_path'] ?? null;
|
||||
$resource = $contract['resource'] ?? null;
|
||||
$requestOptions = array_merge($options, ['tenant' => $tenantId]);
|
||||
|
||||
// Try primary endpoint
|
||||
$assignments = $this->fetchPrimary($policyId, $requestOptions);
|
||||
$assignments = $this->fetchPrimary($listPathTemplate, $policyId, $requestOptions);
|
||||
|
||||
if (! empty($assignments)) {
|
||||
Log::debug('Fetched assignments via primary endpoint', [
|
||||
'tenant_id' => $tenantId,
|
||||
'policy_type' => $policyType,
|
||||
'policy_id' => $policyId,
|
||||
'count' => count($assignments),
|
||||
]);
|
||||
@ -39,14 +44,26 @@ public function fetch(string $tenantId, string $policyId, array $options = []):
|
||||
// Try fallback with $expand
|
||||
Log::debug('Primary endpoint returned empty, trying fallback', [
|
||||
'tenant_id' => $tenantId,
|
||||
'policy_type' => $policyType,
|
||||
'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)) {
|
||||
Log::debug('Fetched assignments via fallback endpoint', [
|
||||
'tenant_id' => $tenantId,
|
||||
'policy_type' => $policyType,
|
||||
'policy_id' => $policyId,
|
||||
'count' => count($assignments),
|
||||
]);
|
||||
@ -57,6 +74,7 @@ public function fetch(string $tenantId, string $policyId, array $options = []):
|
||||
// Both methods returned empty
|
||||
Log::debug('No assignments found for policy', [
|
||||
'tenant_id' => $tenantId,
|
||||
'policy_type' => $policyType,
|
||||
'policy_id' => $policyId,
|
||||
]);
|
||||
|
||||
@ -64,6 +82,7 @@ public function fetch(string $tenantId, string $policyId, array $options = []):
|
||||
} catch (GraphException $e) {
|
||||
Log::warning('Failed to fetch assignments', [
|
||||
'tenant_id' => $tenantId,
|
||||
'policy_type' => $policyType,
|
||||
'policy_id' => $policyId,
|
||||
'error' => $e->getMessage(),
|
||||
'context' => $e->context,
|
||||
@ -76,9 +95,17 @@ public function fetch(string $tenantId, string $policyId, array $options = []):
|
||||
/**
|
||||
* 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);
|
||||
|
||||
@ -88,9 +115,9 @@ private function fetchPrimary(string $policyId, array $options): array
|
||||
/**
|
||||
* 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 = [
|
||||
'$expand' => 'assignments',
|
||||
'$filter' => "id eq '{$policyId}'",
|
||||
@ -108,4 +135,13 @@ private function fetchWithExpand(string $policyId, array $options): array
|
||||
|
||||
return $policies[0]['assignments'] ?? [];
|
||||
}
|
||||
|
||||
private function resolvePath(string $template, string $policyId): ?string
|
||||
{
|
||||
if ($template === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return str_replace('{id}', urlencode($policyId), $template);
|
||||
}
|
||||
}
|
||||
|
||||
296
app/Services/Intune/CompliancePolicyNormalizer.php
Normal file
296
app/Services/Intune/CompliancePolicyNormalizer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
918
app/Services/Intune/DefaultPolicyNormalizer.php
Normal file
918
app/Services/Intune/DefaultPolicyNormalizer.php
Normal 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';
|
||||
}
|
||||
}
|
||||
126
app/Services/Intune/DeviceConfigurationPolicyNormalizer.php
Normal file
126
app/Services/Intune/DeviceConfigurationPolicyNormalizer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -58,7 +58,7 @@ public function capture(
|
||||
// 2. Fetch assignments if requested
|
||||
if ($includeAssignments) {
|
||||
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)) {
|
||||
$resolvedGroups = [];
|
||||
@ -242,7 +242,7 @@ public function ensureVersionHasAssignments(
|
||||
|
||||
if ($includeAssignments && $version->assignments === null) {
|
||||
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)) {
|
||||
$resolvedGroups = [];
|
||||
|
||||
@ -2,912 +2,65 @@
|
||||
|
||||
namespace App\Services\Intune;
|
||||
|
||||
use App\Models\Policy;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Container\Attributes\Tag;
|
||||
|
||||
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(
|
||||
private readonly SnapshotValidator $validator,
|
||||
private readonly SettingsCatalogDefinitionResolver $definitionResolver,
|
||||
private readonly SettingsCatalogCategoryResolver $categoryResolver,
|
||||
) {}
|
||||
private readonly DefaultPolicyNormalizer $defaultNormalizer,
|
||||
#[Tag('policy-type-normalizers')]
|
||||
iterable $typeNormalizers = [],
|
||||
) {
|
||||
$normalizers = is_array($typeNormalizers)
|
||||
? $typeNormalizers
|
||||
: iterator_to_array($typeNormalizers);
|
||||
|
||||
/**
|
||||
* @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;
|
||||
$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>}
|
||||
*/
|
||||
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
|
||||
{
|
||||
return $this->resolveNormalizer($policyType)
|
||||
->normalize($snapshot, $policyType, $platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
return $this->resolveNormalizer($policyType)
|
||||
->flattenForDiff($snapshot, $policyType, $platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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,
|
||||
];
|
||||
return $this->defaultNormalizer->normalizeSettingsCatalogGrouped($settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all definition IDs from settings array recursively.
|
||||
*/
|
||||
private function extractAllDefinitionIds(array $settings): array
|
||||
private function resolveNormalizer(string $policyType): PolicyTypeNormalizer
|
||||
{
|
||||
$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);
|
||||
}
|
||||
}
|
||||
foreach ($this->typeNormalizers as $normalizer) {
|
||||
if ($normalizer->supports($policyType)) {
|
||||
return $normalizer;
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
return $this->defaultNormalizer;
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,11 +36,13 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
|
||||
foreach ($types as $typeConfig) {
|
||||
$policyType = $typeConfig['type'];
|
||||
$platform = $typeConfig['platform'] ?? null;
|
||||
$filter = $typeConfig['filter'] ?? null;
|
||||
|
||||
$this->graphLogger->logRequest('list_policies', [
|
||||
'tenant' => $tenantIdentifier,
|
||||
'policy_type' => $policyType,
|
||||
'platform' => $platform,
|
||||
'filter' => $filter,
|
||||
]);
|
||||
|
||||
try {
|
||||
@ -49,6 +51,7 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
|
||||
'client_id' => $tenant->app_client_id,
|
||||
'client_secret' => $tenant->app_client_secret,
|
||||
'platform' => $platform,
|
||||
'filter' => $filter,
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
throw GraphErrorMapper::fromThrowable($throwable, [
|
||||
|
||||
18
app/Services/Intune/PolicyTypeNormalizer.php
Normal file
18
app/Services/Intune/PolicyTypeNormalizer.php
Normal 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;
|
||||
}
|
||||
@ -43,9 +43,16 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
|
||||
|
||||
[$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'] ?? [];
|
||||
|
||||
$policyPreview = $policyItems->map(function (BackupItem $item) use ($tenant) {
|
||||
$policyPreview = $policyItems->map(function (BackupItem $item) use ($tenant, $notificationTemplateIds) {
|
||||
$existing = Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->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);
|
||||
|
||||
return [
|
||||
$preview = [
|
||||
'backup_item_id' => $item->id,
|
||||
'policy_identifier' => $item->policy_identifier,
|
||||
'policy_type' => $item->policy_type,
|
||||
@ -68,6 +75,18 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
|
||||
$item->platform
|
||||
) ?? ($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();
|
||||
|
||||
return array_merge($foundationPreview, $policyPreview);
|
||||
@ -201,6 +220,16 @@ public function execute(
|
||||
try {
|
||||
$originalPayload = is_array($item->payload) ? $item->payload : [];
|
||||
$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']);
|
||||
|
||||
// sanitize high-level fields according to contract
|
||||
@ -358,6 +387,25 @@ public function execute(
|
||||
$itemStatus = 'partial';
|
||||
$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 + [
|
||||
@ -391,6 +439,14 @@ public function execute(
|
||||
$result['assignment_summary'] = $assignmentSummary;
|
||||
}
|
||||
|
||||
if ($complianceActionSummary !== null) {
|
||||
$result['compliance_action_summary'] = $complianceActionSummary;
|
||||
}
|
||||
|
||||
if ($complianceActionOutcomes !== null) {
|
||||
$result['compliance_action_outcomes'] = $complianceActionOutcomes;
|
||||
}
|
||||
|
||||
$results[] = $result;
|
||||
|
||||
$appliedPolicyId = $item->policy_identifier;
|
||||
@ -599,6 +655,230 @@ private function applyScopeTagIdsToPayload(array $payload, ?array $scopeTagIds,
|
||||
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
|
||||
*/
|
||||
@ -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
|
||||
*/
|
||||
|
||||
31
app/Services/Intune/SettingsCatalogPolicyNormalizer.php
Normal file
31
app/Services/Intune/SettingsCatalogPolicyNormalizer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -91,7 +91,7 @@ public function captureFromGraph(
|
||||
|
||||
if ($includeAssignments) {
|
||||
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)) {
|
||||
$resolvedGroups = [];
|
||||
|
||||
@ -17,10 +17,18 @@ protected static function odataTypeMap(): array
|
||||
'macOS' => '#microsoft.graph.macOSGeneralDeviceConfiguration',
|
||||
'all' => '#microsoft.graph.deviceConfiguration',
|
||||
],
|
||||
'groupPolicyConfiguration' => [
|
||||
'windows' => '#microsoft.graph.groupPolicyConfiguration',
|
||||
'all' => '#microsoft.graph.groupPolicyConfiguration',
|
||||
],
|
||||
'settingsCatalogPolicy' => [
|
||||
'windows' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
'all' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
],
|
||||
'windowsUpdateRing' => [
|
||||
'windows' => '#microsoft.graph.windowsUpdateForBusinessConfiguration',
|
||||
'all' => '#microsoft.graph.windowsUpdateForBusinessConfiguration',
|
||||
],
|
||||
'deviceCompliancePolicy' => [
|
||||
'windows' => '#microsoft.graph.windows10CompliancePolicy',
|
||||
'ios' => '#microsoft.graph.iosCompliancePolicy',
|
||||
@ -38,6 +46,14 @@ protected static function odataTypeMap(): array
|
||||
'deviceManagementScript' => [
|
||||
'windows' => '#microsoft.graph.deviceManagementScript',
|
||||
],
|
||||
'deviceShellScript' => [
|
||||
'macOS' => '#microsoft.graph.deviceShellScript',
|
||||
'all' => '#microsoft.graph.deviceShellScript',
|
||||
],
|
||||
'deviceHealthScript' => [
|
||||
'windows' => '#microsoft.graph.deviceHealthScript',
|
||||
'all' => '#microsoft.graph.deviceHealthScript',
|
||||
],
|
||||
'enrollmentRestriction' => [
|
||||
'all' => '#microsoft.graph.deviceEnrollmentConfiguration',
|
||||
],
|
||||
|
||||
@ -27,6 +27,34 @@
|
||||
'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',
|
||||
],
|
||||
'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' => [
|
||||
'resource' => 'deviceManagement/configurationPolicies',
|
||||
@ -84,6 +112,27 @@
|
||||
'supports_scope_tags' => true,
|
||||
'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' => [
|
||||
'resource' => 'deviceManagement/deviceCompliancePolicies',
|
||||
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'],
|
||||
@ -99,6 +148,15 @@
|
||||
'update_method' => 'PATCH',
|
||||
'id_field' => 'id',
|
||||
'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' => [
|
||||
'resource' => 'deviceAppManagement/managedAppPolicies',
|
||||
@ -135,6 +193,51 @@
|
||||
'update_method' => 'PATCH',
|
||||
'id_field' => 'id',
|
||||
'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' => [
|
||||
'resource' => 'deviceManagement/deviceEnrollmentConfigurations',
|
||||
|
||||
@ -77,8 +77,14 @@
|
||||
[
|
||||
'key' => 'DeviceManagementScripts.ReadWrite.All',
|
||||
'type' => 'application',
|
||||
'description' => 'Read directory data needed for tenant health checks.',
|
||||
'features' => ['script-management'],
|
||||
'description' => 'Manage Intune device management scripts and remediations.',
|
||||
'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).
|
||||
|
||||
@ -8,6 +8,17 @@
|
||||
'category' => 'Configuration',
|
||||
'platform' => 'all',
|
||||
'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',
|
||||
'restore' => 'enabled',
|
||||
'risk' => 'medium',
|
||||
@ -22,6 +33,17 @@
|
||||
'restore' => 'enabled',
|
||||
'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',
|
||||
'label' => 'Device Compliance',
|
||||
@ -62,6 +84,26 @@
|
||||
'restore' => 'enabled',
|
||||
'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',
|
||||
'label' => 'Enrollment Restrictions',
|
||||
@ -88,7 +130,7 @@
|
||||
'category' => 'Enrollment',
|
||||
'platform' => 'all',
|
||||
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations',
|
||||
'filter' => "odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'",
|
||||
'filter' => "@odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'",
|
||||
'backup' => 'full',
|
||||
'restore' => 'enabled',
|
||||
'risk' => 'medium',
|
||||
|
||||
@ -82,6 +82,12 @@
|
||||
{{ $item['validation_warning'] }}
|
||||
</div>
|
||||
@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>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@ -189,6 +189,51 @@
|
||||
@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']))
|
||||
@php
|
||||
$createdMode = $item['created_policy_mode'] ?? null;
|
||||
|
||||
@ -23,6 +23,7 @@ ### Full Scope (Phase 2+)
|
||||
- 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.
|
||||
- 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
|
||||
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.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.
|
||||
|
||||
|
||||
@ -72,3 +72,8 @@ ## Phase 5: Tests and Verification
|
||||
|
||||
**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.
|
||||
|
||||
@ -193,7 +193,7 @@
|
||||
// "Device Vendor Msft Policy Config Uncached Test Setting"
|
||||
})->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_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
|
||||
'name' => 'Test Tenant',
|
||||
@ -234,7 +234,9 @@
|
||||
->get(PolicyResource::getUrl('view', ['record' => $policy]));
|
||||
|
||||
$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)
|
||||
|
||||
@ -182,3 +182,103 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
||||
'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,
|
||||
]);
|
||||
});
|
||||
|
||||
@ -103,3 +103,89 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
$policyPreview = collect($preview)->first(fn (array $item) => isset($item['action']));
|
||||
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);
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Graph\AssignmentFetcher;
|
||||
use App\Services\Graph\GraphContractRegistry;
|
||||
use App\Services\Graph\GraphException;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Graph\MicrosoftGraphClient;
|
||||
@ -11,12 +12,13 @@
|
||||
|
||||
beforeEach(function () {
|
||||
$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 () {
|
||||
$tenantId = 'tenant-123';
|
||||
$policyId = 'policy-456';
|
||||
$policyType = 'settingsCatalogPolicy';
|
||||
$assignments = [
|
||||
['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']],
|
||||
['id' => 'assign-2', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-2']],
|
||||
@ -35,7 +37,7 @@
|
||||
])
|
||||
->andReturn($response);
|
||||
|
||||
$result = $this->fetcher->fetch($tenantId, $policyId);
|
||||
$result = $this->fetcher->fetch($policyType, $tenantId, $policyId);
|
||||
|
||||
expect($result)->toBe($assignments);
|
||||
});
|
||||
@ -43,6 +45,7 @@
|
||||
test('fallback on empty response', function () {
|
||||
$tenantId = 'tenant-123';
|
||||
$policyId = 'policy-456';
|
||||
$policyType = 'settingsCatalogPolicy';
|
||||
$assignments = [
|
||||
['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']],
|
||||
];
|
||||
@ -70,7 +73,7 @@
|
||||
$this->graphClient
|
||||
->shouldReceive('request')
|
||||
->once()
|
||||
->with('GET', '/deviceManagement/configurationPolicies', [
|
||||
->with('GET', 'deviceManagement/configurationPolicies', [
|
||||
'tenant' => $tenantId,
|
||||
'query' => [
|
||||
'$expand' => 'assignments',
|
||||
@ -79,7 +82,7 @@
|
||||
])
|
||||
->andReturn($fallbackResponse);
|
||||
|
||||
$result = $this->fetcher->fetch($tenantId, $policyId);
|
||||
$result = $this->fetcher->fetch($policyType, $tenantId, $policyId);
|
||||
|
||||
expect($result)->toBe($assignments);
|
||||
});
|
||||
@ -87,13 +90,14 @@
|
||||
test('fail soft on error', function () {
|
||||
$tenantId = 'tenant-123';
|
||||
$policyId = 'policy-456';
|
||||
$policyType = 'settingsCatalogPolicy';
|
||||
|
||||
$this->graphClient
|
||||
->shouldReceive('request')
|
||||
->once()
|
||||
->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([]);
|
||||
});
|
||||
@ -101,6 +105,7 @@
|
||||
test('returns empty array when both endpoints return empty', function () {
|
||||
$tenantId = 'tenant-123';
|
||||
$policyId = 'policy-456';
|
||||
$policyType = 'settingsCatalogPolicy';
|
||||
|
||||
// Primary returns empty
|
||||
$primaryResponse = new GraphResponse(
|
||||
@ -123,10 +128,10 @@
|
||||
$this->graphClient
|
||||
->shouldReceive('request')
|
||||
->once()
|
||||
->with('GET', '/deviceManagement/configurationPolicies', Mockery::any())
|
||||
->with('GET', 'deviceManagement/configurationPolicies', Mockery::any())
|
||||
->andReturn($fallbackResponse);
|
||||
|
||||
$result = $this->fetcher->fetch($tenantId, $policyId);
|
||||
$result = $this->fetcher->fetch($policyType, $tenantId, $policyId);
|
||||
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
@ -134,6 +139,7 @@
|
||||
test('fallback handles missing assignments key', function () {
|
||||
$tenantId = 'tenant-123';
|
||||
$policyId = 'policy-456';
|
||||
$policyType = 'settingsCatalogPolicy';
|
||||
|
||||
// Primary returns empty
|
||||
$primaryResponse = new GraphResponse(
|
||||
@ -157,7 +163,7 @@
|
||||
->once()
|
||||
->andReturn($fallbackResponse);
|
||||
|
||||
$result = $this->fetcher->fetch($tenantId, $policyId);
|
||||
$result = $this->fetcher->fetch($policyType, $tenantId, $policyId);
|
||||
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
|
||||
36
tests/Unit/CompliancePolicyNormalizerTest.php
Normal file
36
tests/Unit/CompliancePolicyNormalizerTest.php
Normal 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');
|
||||
});
|
||||
39
tests/Unit/DeviceConfigurationPolicyNormalizerTest.php
Normal file
39
tests/Unit/DeviceConfigurationPolicyNormalizerTest.php
Normal 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');
|
||||
});
|
||||
44
tests/Unit/PolicyNormalizerRoutingTest.php
Normal file
44
tests/Unit/PolicyNormalizerRoutingTest.php
Normal 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');
|
||||
});
|
||||
33
tests/Unit/SettingsCatalogPolicyNormalizerTest.php
Normal file
33
tests/Unit/SettingsCatalogPolicyNormalizerTest.php
Normal 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');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user