diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index 6c53439..dd20cf2 100644 --- a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php +++ b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php @@ -52,16 +52,26 @@ public function table(Table $table): Table ->label('Assignments') ->badge() ->color('info') - ->getStateUsing(function (BackupItem $record): int { - $assignments = $record->policyVersion?->assignments ?? $record->assignments ?? []; + ->getStateUsing(function (BackupItem $record): string { + $assignments = $record->policyVersion?->assignments ?? $record->assignments; - return is_array($assignments) ? count($assignments) : 0; + if (is_array($assignments)) { + return (string) count($assignments); + } + + $assignmentsFetched = $record->policyVersion?->metadata['assignments_fetched'] + ?? $record->metadata['assignments_fetched'] + ?? false; + + return $assignmentsFetched ? '0' : '—'; }), Tables\Columns\TextColumn::make('scope_tags') ->label('Scope Tags') ->default('—') ->getStateUsing(function (BackupItem $record): array { - $tags = $record->policyVersion?->scope_tags['names'] ?? []; + $tags = $record->policyVersion?->scope_tags['names'] + ?? $record->metadata['scope_tag_names'] + ?? []; return is_array($tags) ? $tags : []; }) @@ -100,6 +110,7 @@ public function table(Table $table): Table return Policy::query() ->where('tenant_id', $tenantId) + ->whereNull('ignored_at') ->where('last_synced_at', '>', now()->subDays(7)) // Hide deleted policies (Feature 005 workaround) ->when($existing, fn (Builder $query) => $query->whereNotIn('id', $existing)) ->orderBy('display_name') diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index b2d2ed5..bea7788 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -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} + */ + 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 + */ + 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; + } } diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 50087c9..bbc4d25 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -543,6 +543,11 @@ private static function restoreItemOptionData(?int $backupSetId): array $items = BackupItem::query() ->where('backup_set_id', $backupSetId) ->whereHas('backupSet', fn ($query) => $query->where('tenant_id', $tenant->getKey())) + ->where(function ($query) { + $query->whereNull('policy_id') + ->orWhereDoesntHave('policy') + ->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at')); + }) ->with('policy:id,display_name') ->get() ->sortBy(function (BackupItem $item) { diff --git a/app/Jobs/FetchAssignmentsJob.php b/app/Jobs/FetchAssignmentsJob.php index 7bb8d89..a492a4a 100644 --- a/app/Jobs/FetchAssignmentsJob.php +++ b/app/Jobs/FetchAssignmentsJob.php @@ -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 ); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d691c7b..3226a0e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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' + ); } /** diff --git a/app/Services/AssignmentBackupService.php b/app/Services/AssignmentBackupService.php index f94c697..9c3e941 100644 --- a/app/Services/AssignmentBackupService.php +++ b/app/Services/AssignmentBackupService.php @@ -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 @@ -91,12 +93,7 @@ public function enrichWithAssignments( ->contains(fn (array $group) => $group['orphaned'] ?? false); } - $filterIds = collect($assignments) - ->pluck('target.deviceAndAppManagementAssignmentFilterId') - ->filter() - ->unique() - ->values() - ->all(); + $filterIds = $this->extractAssignmentFilterIds($assignments); $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); $filterNames = collect($filters) @@ -181,9 +178,21 @@ private function enrichAssignments(array $assignments, array $groups, array $fil $target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false; } - $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; - if ($filterId && isset($filterNames[$filterId])) { - $target['assignment_filter_name'] = $filterNames[$filterId]; + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null); + $filterType = $assignment['deviceAndAppManagementAssignmentFilterType'] + ?? ($target['deviceAndAppManagementAssignmentFilterType'] ?? null); + + if ($filterId) { + $target['deviceAndAppManagementAssignmentFilterId'] = $target['deviceAndAppManagementAssignmentFilterId'] ?? $filterId; + + if ($filterType) { + $target['deviceAndAppManagementAssignmentFilterType'] = $target['deviceAndAppManagementAssignmentFilterType'] ?? $filterType; + } + + if (isset($filterNames[$filterId])) { + $target['assignment_filter_name'] = $filterNames[$filterId]; + } } $assignment['target'] = $target; @@ -191,4 +200,28 @@ private function enrichAssignments(array $assignments, array $groups, array $fil return $assignment; }, $assignments); } + + /** + * @param array> $assignments + * @return array + */ + private function extractAssignmentFilterIds(array $assignments): array + { + $filterIds = []; + + foreach ($assignments as $assignment) { + if (! is_array($assignment)) { + continue; + } + + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null); + + if (is_string($filterId) && $filterId !== '') { + $filterIds[] = $filterId; + } + } + + return array_values(array_unique($filterIds)); + } } diff --git a/app/Services/AssignmentRestoreService.php b/app/Services/AssignmentRestoreService.php index 6d32690..980749c 100644 --- a/app/Services/AssignmentRestoreService.php +++ b/app/Services/AssignmentRestoreService.php @@ -4,6 +4,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; +use App\Services\Graph\AssignmentFilterResolver; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphContractRegistry; use App\Services\Graph\GraphLogger; @@ -19,6 +20,7 @@ public function __construct( private readonly GraphContractRegistry $contracts, private readonly GraphLogger $graphLogger, private readonly AuditLogger $auditLogger, + private readonly AssignmentFilterResolver $assignmentFilterResolver, ) {} /** @@ -56,6 +58,11 @@ public function restore( $createPath = $this->resolvePath($contract['assignments_create_path'] ?? null, $policyId); $createMethod = strtoupper((string) ($contract['assignments_create_method'] ?? 'POST')); $usesAssignAction = is_string($createPath) && str_ends_with($createPath, '/assign'); + $assignmentsPayloadKey = $contract['assignments_payload_key'] ?? 'assignments'; + + if (! is_string($assignmentsPayloadKey) || $assignmentsPayloadKey === '') { + $assignmentsPayloadKey = 'assignments'; + } $listPath = $this->resolvePath($contract['assignments_list_path'] ?? null, $policyId); $deletePathTemplate = $contract['assignments_delete_path'] ?? null; @@ -84,13 +91,39 @@ public function restore( $assignmentFilterMapping = $foundationMapping['assignmentFilter'] ?? []; + if ($assignmentFilterMapping === []) { + $filterIds = $this->extractAssignmentFilterIds($assignments); + + if ($filterIds !== []) { + $resolvedFilters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); + + foreach ($resolvedFilters as $filter) { + $filterId = $filter['id'] ?? null; + + if (is_string($filterId) && $filterId !== '') { + $assignmentFilterMapping[$filterId] = $filterId; + } + } + } + } + foreach ($assignments as $assignment) { if (! is_array($assignment)) { continue; } $target = $assignment['target'] ?? []; - $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null); + $filterLocation = array_key_exists('deviceAndAppManagementAssignmentFilterId', $assignment) ? 'root' : 'target'; + + if (! is_string($filterId) && ! is_int($filterId)) { + $filterId = null; + } + + if (is_string($filterId) && $filterId === '') { + $filterId = null; + } if ($filterId !== null) { if ($assignmentFilterMapping === []) { @@ -142,8 +175,12 @@ public function restore( continue; } - $target['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId; - $assignment['target'] = $target; + if ($filterLocation === 'root') { + $assignment['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId; + } else { + $target['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId; + $assignment['target'] = $target; + } } $groupId = $assignment['target']['groupId'] ?? null; @@ -196,7 +233,7 @@ public function restore( ]); $assignResponse = $this->graphClient->request($createMethod, $createPath, [ - 'json' => ['assignments' => $preparedAssignments], + 'json' => [$assignmentsPayloadKey => $preparedAssignments], ] + $graphOptions); $this->graphLogger->logResponse('restore_assignments_assign', $assignResponse, $context + [ @@ -413,6 +450,34 @@ private function resolvePath(?string $template, string $policyId, ?string $assig return $path; } + /** + * @param array> $assignments + * @return array + */ + private function extractAssignmentFilterIds(array $assignments): array + { + $filterIds = []; + + foreach ($assignments as $assignment) { + if (! is_array($assignment)) { + continue; + } + + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null); + + if (is_string($filterId) || is_int($filterId)) { + $filterId = (string) $filterId; + + if ($filterId !== '') { + $filterIds[] = $filterId; + } + } + } + + return array_values(array_unique($filterIds)); + } + private function applyGroupMapping(array $assignment, ?string $mappedGroupId): array { if (! $mappedGroupId) { diff --git a/app/Services/Graph/AssignmentFetcher.php b/app/Services/Graph/AssignmentFetcher.php index 53d3ec6..f7efa2d 100644 --- a/app/Services/Graph/AssignmentFetcher.php +++ b/app/Services/Graph/AssignmentFetcher.php @@ -8,89 +8,189 @@ 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 = [], + bool $throwOnFailure = false + ): array { + $contract = $this->contracts->get($policyType); + $listPathTemplate = $contract['assignments_list_path'] ?? null; + $resource = $contract['resource'] ?? null; + $requestOptions = array_merge($options, ['tenant' => $tenantId]); + $context = [ + 'tenant_id' => $tenantId, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + ]; + + $primaryException = null; + $assignments = []; + + // Try primary endpoint try { - $requestOptions = array_merge($options, ['tenant' => $tenantId]); - - // Try primary endpoint - $assignments = $this->fetchPrimary($policyId, $requestOptions); - - if (! empty($assignments)) { - Log::debug('Fetched assignments via primary endpoint', [ - 'tenant_id' => $tenantId, - 'policy_id' => $policyId, - 'count' => count($assignments), - ]); - - return $assignments; - } - - // Try fallback with $expand - Log::debug('Primary endpoint returned empty, trying fallback', [ - 'tenant_id' => $tenantId, - 'policy_id' => $policyId, - ]); - - $assignments = $this->fetchWithExpand($policyId, $requestOptions); - - if (! empty($assignments)) { - Log::debug('Fetched assignments via fallback endpoint', [ - 'tenant_id' => $tenantId, - 'policy_id' => $policyId, - 'count' => count($assignments), - ]); - - return $assignments; - } - - // Both methods returned empty - Log::debug('No assignments found for policy', [ - 'tenant_id' => $tenantId, - 'policy_id' => $policyId, - ]); - - return []; + $assignments = $this->fetchPrimary( + $listPathTemplate, + $policyId, + $requestOptions, + $context, + $throwOnFailure + ); } catch (GraphException $e) { - Log::warning('Failed to fetch assignments', [ + $primaryException = $e; + } + + if (! empty($assignments)) { + Log::debug('Fetched assignments via primary endpoint', [ 'tenant_id' => $tenantId, + 'policy_type' => $policyType, 'policy_id' => $policyId, - 'error' => $e->getMessage(), - 'context' => $e->context, + 'count' => count($assignments), ]); + return $assignments; + } + + // Try fallback with $expand + Log::debug('Primary endpoint returned empty, trying fallback', [ + 'tenant_id' => $tenantId, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + ]); + + if (! is_string($resource) || $resource === '') { + Log::debug('Assignments resource not configured for policy type', [ + 'tenant_id' => $tenantId, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + ]); + + if ($throwOnFailure && $primaryException) { + Log::warning('Failed to fetch assignments', [ + 'tenant_id' => $tenantId, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + 'error' => $primaryException->getMessage(), + 'context' => $primaryException->context, + ]); + + throw $primaryException; + } + return []; } + + $fallbackException = null; + + try { + $assignments = $this->fetchWithExpand( + $resource, + $policyId, + $requestOptions, + $context, + $throwOnFailure + ); + } catch (GraphException $e) { + $fallbackException = $e; + } + + if (! empty($assignments)) { + Log::debug('Fetched assignments via fallback endpoint', [ + 'tenant_id' => $tenantId, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + 'count' => count($assignments), + ]); + + return $assignments; + } + + // Both methods returned empty + Log::debug('No assignments found for policy', [ + 'tenant_id' => $tenantId, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + ]); + + if ($throwOnFailure && ($fallbackException || $primaryException)) { + $exception = $fallbackException ?? $primaryException; + + Log::warning('Failed to fetch assignments', [ + 'tenant_id' => $tenantId, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + 'error' => $exception->getMessage(), + 'context' => $exception->context, + ]); + + throw $exception; + } + + return []; } /** * Fetch assignments using primary endpoint. */ - private function fetchPrimary(string $policyId, array $options): array - { - $path = "/deviceManagement/configurationPolicies/{$policyId}/assignments"; + private function fetchPrimary( + ?string $listPathTemplate, + string $policyId, + array $options, + array $context, + bool $throwOnFailure + ): array { + if (! is_string($listPathTemplate) || $listPathTemplate === '') { + return []; + } + + $path = $this->resolvePath($listPathTemplate, $policyId); + + if ($path === null) { + return []; + } $response = $this->graphClient->request('GET', $path, $options); + if ($response->failed()) { + $this->logAssignmentFailure('primary', $response, $context + ['path' => $path]); + + if ($throwOnFailure) { + throw new GraphException( + $this->resolveErrorMessage($response), + $response->status, + $context + ['path' => $path] + ); + } + + return []; + } + return $response->data['value'] ?? []; } /** * Fetch assignments using $expand fallback. */ - private function fetchWithExpand(string $policyId, array $options): array - { - $path = '/deviceManagement/configurationPolicies'; + private function fetchWithExpand( + string $resource, + string $policyId, + array $options, + array $context, + bool $throwOnFailure + ): array { + $path = $resource; $params = [ '$expand' => 'assignments', '$filter' => "id eq '{$policyId}'", @@ -100,6 +200,20 @@ private function fetchWithExpand(string $policyId, array $options): array 'query' => $params, ])); + if ($response->failed()) { + $this->logAssignmentFailure('fallback', $response, $context + ['path' => $path]); + + if ($throwOnFailure) { + throw new GraphException( + $this->resolveErrorMessage($response), + $response->status, + $context + ['path' => $path] + ); + } + + return []; + } + $policies = $response->data['value'] ?? []; if (empty($policies)) { @@ -108,4 +222,41 @@ 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); + } + + private function resolveErrorMessage(GraphResponse $response): string + { + $error = $response->errors[0] ?? null; + + if (is_array($error)) { + if (isset($error['message']) && is_string($error['message'])) { + return $error['message']; + } + + return json_encode($error, JSON_UNESCAPED_SLASHES) ?: 'Graph request failed'; + } + + if (is_string($error) && $error !== '') { + return $error; + } + + return 'Graph request failed'; + } + + private function logAssignmentFailure(string $stage, GraphResponse $response, array $context): void + { + Log::warning('Assignment fetch failed', $context + [ + 'stage' => $stage, + 'status' => $response->status, + 'errors' => $response->errors, + ]); + } } diff --git a/app/Services/Graph/GraphContractRegistry.php b/app/Services/Graph/GraphContractRegistry.php index 40b6a56..37afda0 100644 --- a/app/Services/Graph/GraphContractRegistry.php +++ b/app/Services/Graph/GraphContractRegistry.php @@ -69,8 +69,7 @@ public function sanitizeUpdatePayload(string $policyType, array $snapshot): arra $whitelist = $contract['update_whitelist'] ?? null; $stripKeys = array_merge($this->readOnlyKeys(), $contract['update_strip_keys'] ?? []); $mapping = $contract['update_map'] ?? []; - - $stripOdata = $whitelist !== null || ! empty($contract['update_strip_keys']); + $stripOdata = $contract['strip_odata'] ?? ($whitelist !== null || ! empty($contract['update_strip_keys'])); $result = $this->sanitizeArray($snapshot, $whitelist, $stripKeys, $stripOdata, $mapping); diff --git a/app/Services/Intune/BackupService.php b/app/Services/Intune/BackupService.php index b41dd01..22abe4c 100644 --- a/app/Services/Intune/BackupService.php +++ b/app/Services/Intune/BackupService.php @@ -42,6 +42,7 @@ public function createBackupSet( $policies = Policy::query() ->where('tenant_id', $tenant->id) ->whereIn('id', $policyIds) + ->whereNull('ignored_at') ->get(); $backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name, $includeAssignments, $includeScopeTags, $includeFoundations) { @@ -182,6 +183,7 @@ public function addPoliciesToSet( $policies = Policy::query() ->where('tenant_id', $tenant->id) ->whereIn('id', $policyIds) + ->whereNull('ignored_at') ->get(); $metadata = $backupSet->metadata ?? []; @@ -303,6 +305,12 @@ private function snapshotPolicy( $metadata['warnings'] = array_values(array_unique($metadataWarnings)); } + $capturedScopeTags = $captured['scope_tags'] ?? null; + if (is_array($capturedScopeTags)) { + $metadata['scope_tag_ids'] = $capturedScopeTags['ids'] ?? null; + $metadata['scope_tag_names'] = $capturedScopeTags['names'] ?? null; + } + // Create BackupItem as a copy/reference of the PolicyVersion $backupItem = BackupItem::create([ 'tenant_id' => $tenant->id, diff --git a/app/Services/Intune/CompliancePolicyNormalizer.php b/app/Services/Intune/CompliancePolicyNormalizer.php new file mode 100644 index 0000000..be66042 --- /dev/null +++ b/app/Services/Intune/CompliancePolicyNormalizer.php @@ -0,0 +1,296 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform); + + if ($snapshot === []) { + return $normalized; + } + + $normalized['settings'] = array_values(array_filter( + $normalized['settings'], + fn (array $block) => strtolower((string) ($block['title'] ?? '')) !== 'general' + )); + + foreach ($this->buildComplianceBlocks($snapshot) as $block) { + $normalized['settings'][] = $block; + } + + return $normalized; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + return $this->defaultNormalizer->flattenForDiff($snapshot, $policyType, $platform); + } + + /** + * @return array> + */ + 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, labels?: array} + */ + 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 $labels + * @return array> + */ + private function buildRows(array $snapshot, array $keys, array $labels = []): array + { + $rows = []; + + foreach ($keys as $key) { + if (! array_key_exists($key, $snapshot)) { + continue; + } + + $rows[] = [ + 'label' => $labels[$key] ?? Str::headline($key), + 'value' => $this->formatValue($snapshot[$key]), + ]; + } + + return $rows; + } + + /** + * @param array $usedKeys + * @return array> + */ + private function buildAdditionalRows(array $snapshot, array $usedKeys): array + { + $ignoredKeys = array_merge($this->ignoredKeys(), $usedKeys); + $rows = []; + + foreach ($snapshot as $key => $value) { + if (! is_string($key)) { + continue; + } + + if (in_array($key, $ignoredKeys, true)) { + continue; + } + + $rows[] = [ + 'label' => Str::headline($key), + 'value' => $this->formatValue($value), + ]; + } + + return $rows; + } + + /** + * @return array + */ + private function ignoredKeys(): array + { + return [ + '@odata.context', + '@odata.type', + 'id', + 'version', + 'createdDateTime', + 'lastModifiedDateTime', + 'supportsScopeTags', + 'roleScopeTagIds', + 'assignments', + 'createdBy', + 'lastModifiedBy', + 'omaSettings', + 'settings', + 'settingsDelta', + '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; + } +} diff --git a/app/Services/Intune/DefaultPolicyNormalizer.php b/app/Services/Intune/DefaultPolicyNormalizer.php new file mode 100644 index 0000000..6cf7145 --- /dev/null +++ b/app/Services/Intune/DefaultPolicyNormalizer.php @@ -0,0 +1,918 @@ +>, settings_table?: array, warnings: array, 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 + */ + 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> $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> $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 $settings + * @return array{table: array, warnings: array} + */ + 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 $settings + * @return array{rows: array>, warnings: array} + */ + 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 + */ + 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 $settings + * @return array{type: string, groups: array>} + */ + 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'; + } +} diff --git a/app/Services/Intune/DeviceConfigurationPolicyNormalizer.php b/app/Services/Intune/DeviceConfigurationPolicyNormalizer.php new file mode 100644 index 0000000..5c88051 --- /dev/null +++ b/app/Services/Intune/DeviceConfigurationPolicyNormalizer.php @@ -0,0 +1,126 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform); + + if ($snapshot === []) { + return $normalized; + } + + $normalized['settings'] = array_values(array_filter( + $normalized['settings'], + fn (array $block) => strtolower((string) ($block['title'] ?? '')) !== 'general' + )); + + $configurationBlock = $this->buildConfigurationBlock($snapshot); + + if ($configurationBlock !== null) { + $normalized['settings'][] = $configurationBlock; + } + + return $normalized; + } + + /** + * @return array + */ + 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>}|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 + */ + 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; + } +} diff --git a/app/Services/Intune/PolicyCaptureOrchestrator.php b/app/Services/Intune/PolicyCaptureOrchestrator.php index 0ac4573..63e6f27 100644 --- a/app/Services/Intune/PolicyCaptureOrchestrator.php +++ b/app/Services/Intune/PolicyCaptureOrchestrator.php @@ -58,7 +58,15 @@ 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, + true + ); + $captureMetadata['assignments_fetched'] = true; + $captureMetadata['assignments_count'] = count($rawAssignments); if (! empty($rawAssignments)) { $resolvedGroups = []; @@ -77,12 +85,7 @@ public function capture( ->contains(fn (array $group) => $group['orphaned'] ?? false); } - $filterIds = collect($rawAssignments) - ->pluck('target.deviceAndAppManagementAssignmentFilterId') - ->filter() - ->unique() - ->values() - ->all(); + $filterIds = $this->extractAssignmentFilterIds($rawAssignments); $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); $filterNames = collect($filters) @@ -90,7 +93,6 @@ public function capture( ->all(); $assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames); - $captureMetadata['assignments_count'] = count($rawAssignments); } } catch (\Throwable $e) { $captureMetadata['assignments_fetch_failed'] = true; @@ -242,7 +244,15 @@ 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, + true + ); + $metadata['assignments_fetched'] = true; + $metadata['assignments_count'] = count($rawAssignments); if (! empty($rawAssignments)) { $resolvedGroups = []; @@ -261,12 +271,7 @@ public function ensureVersionHasAssignments( ->contains(fn (array $group) => $group['orphaned'] ?? false); } - $filterIds = collect($rawAssignments) - ->pluck('target.deviceAndAppManagementAssignmentFilterId') - ->filter() - ->unique() - ->values() - ->all(); + $filterIds = $this->extractAssignmentFilterIds($rawAssignments); $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); $filterNames = collect($filters) @@ -274,7 +279,6 @@ public function ensureVersionHasAssignments( ->all(); $assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames); - $metadata['assignments_count'] = count($rawAssignments); } } catch (\Throwable $e) { $metadata['assignments_fetch_failed'] = true; @@ -336,9 +340,21 @@ private function enrichAssignments(array $assignments, array $groups, array $fil $target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false; } - $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; - if ($filterId && isset($filterNames[$filterId])) { - $target['assignment_filter_name'] = $filterNames[$filterId]; + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null); + $filterType = $assignment['deviceAndAppManagementAssignmentFilterType'] + ?? ($target['deviceAndAppManagementAssignmentFilterType'] ?? null); + + if ($filterId) { + $target['deviceAndAppManagementAssignmentFilterId'] = $target['deviceAndAppManagementAssignmentFilterId'] ?? $filterId; + + if ($filterType) { + $target['deviceAndAppManagementAssignmentFilterType'] = $target['deviceAndAppManagementAssignmentFilterType'] ?? $filterType; + } + + if (isset($filterNames[$filterId])) { + $target['assignment_filter_name'] = $filterNames[$filterId]; + } } $assignment['target'] = $target; @@ -347,6 +363,30 @@ private function enrichAssignments(array $assignments, array $groups, array $fil }, $assignments); } + /** + * @param array> $assignments + * @return array + */ + private function extractAssignmentFilterIds(array $assignments): array + { + $filterIds = []; + + foreach ($assignments as $assignment) { + if (! is_array($assignment)) { + continue; + } + + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null); + + if (is_string($filterId) && $filterId !== '') { + $filterIds[] = $filterId; + } + } + + return array_values(array_unique($filterIds)); + } + /** * @param array $scopeTagIds * @return array{ids:array,names:array} diff --git a/app/Services/Intune/PolicyNormalizer.php b/app/Services/Intune/PolicyNormalizer.php index 316b47d..e46544e 100644 --- a/app/Services/Intune/PolicyNormalizer.php +++ b/app/Services/Intune/PolicyNormalizer.php @@ -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 */ + 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>, settings_table?: array, warnings: array, 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>, settings_table?: array, warnings: array} + */ + 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 */ 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> $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> $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 $settings - * @return array{table: array, warnings: array} - */ - 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 $settings - * @return array{rows: array>, warnings: array} - */ - 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 - */ - 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 $settings * @return array{type: string, groups: array>} */ 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; } } diff --git a/app/Services/Intune/PolicySyncService.php b/app/Services/Intune/PolicySyncService.php index 6ed859c..69cc514 100644 --- a/app/Services/Intune/PolicySyncService.php +++ b/app/Services/Intune/PolicySyncService.php @@ -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, [ diff --git a/app/Services/Intune/PolicyTypeNormalizer.php b/app/Services/Intune/PolicyTypeNormalizer.php new file mode 100644 index 0000000..c111dd4 --- /dev/null +++ b/app/Services/Intune/PolicyTypeNormalizer.php @@ -0,0 +1,18 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array; + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array; +} diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index 06622fc..c5a36a1 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -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 @@ -247,7 +276,7 @@ public function execute( settings: $settings, graphOptions: $graphOptions, context: $context, - fallbackName: $item->policy_identifier, + fallbackName: $item->resolvedDisplayName(), ); if ($createOutcome['success']) { @@ -301,6 +330,26 @@ public function execute( $payload, $graphOptions ); + + if ($item->policy_type === 'windowsAutopilotDeploymentProfile' && $response->failed()) { + $createOutcome = $this->createAutopilotDeploymentProfileIfMissing( + originalPayload: $originalPayload, + graphOptions: $graphOptions, + context: $context, + policyId: $item->policy_identifier, + ); + + if ($createOutcome['attempted']) { + $response = $createOutcome['response'] ?? $response; + + if ($createOutcome['success']) { + $createdPolicyId = $createOutcome['policy_id']; + $createdPolicyMode = 'created'; + $itemStatus = 'applied'; + $resultReason = 'Policy missing; created new Autopilot profile.'; + } + } + } } } catch (Throwable $throwable) { $mapped = GraphErrorMapper::fromThrowable($throwable, $context); @@ -336,6 +385,7 @@ public function execute( $assignmentOutcomes = null; $assignmentSummary = null; + $restoredAssignments = null; if (! $dryRun && is_array($item->assignments) && $item->assignments !== []) { $assignmentPolicyId = $createdPolicyId ?? $item->policy_identifier; @@ -358,6 +408,38 @@ public function execute( $itemStatus = 'partial'; $resultReason = 'Assignments restored with failures'; } + + } + + if (is_array($assignmentOutcomes)) { + $restoredAssignments = collect($assignmentOutcomes['outcomes'] ?? []) + ->filter(fn (array $outcome) => ($outcome['status'] ?? null) === 'success') + ->pluck('assignment') + ->filter() + ->values() + ->all(); + + if ($restoredAssignments === []) { + $restoredAssignments = null; + } + } + + 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 +473,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; @@ -410,7 +500,8 @@ public function execute( 'source' => 'restore', 'restore_run_id' => $restoreRun->id, 'backup_item_id' => $item->id, - ] + ], + assignments: $restoredAssignments, ); } } @@ -599,6 +690,230 @@ private function applyScopeTagIdsToPayload(array $payload, ?array $scopeTagIds, return $payload; } + /** + * @param array $payload + * @param array $availableTemplateIds + * @return array + */ + 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 $payload + * @param array $templateMapping + * @return array{0: array, 1: ?array{total:int,mapped:int,skipped:int}, 2: ?array>} + */ + 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 $payload + * @return array + */ + 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> $entries */ @@ -653,6 +968,51 @@ private function auditFoundationMapping( } } + /** + * @param array{total:int,mapped:int,skipped:int} $summary + * @param array> $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|null $selectedItemIds */ @@ -999,6 +1359,96 @@ private function createSettingsCatalogPolicy( ]; } + /** + * @return array{attempted:bool,success:bool,policy_id:?string,response:?object} + */ + private function createAutopilotDeploymentProfileIfMissing( + array $originalPayload, + array $graphOptions, + array $context, + string $policyId, + ): array { + if (! $this->shouldAttemptAutopilotCreate($policyId, $graphOptions)) { + return [ + 'attempted' => false, + 'success' => false, + 'policy_id' => null, + 'response' => null, + ]; + } + + $resource = $this->contracts->resourcePath('windowsAutopilotDeploymentProfile') + ?? 'deviceManagement/windowsAutopilotDeploymentProfiles'; + $payload = $this->contracts->sanitizeUpdatePayload('windowsAutopilotDeploymentProfile', $originalPayload); + $payload['displayName'] = $this->prefixRestoredName( + $this->resolvePayloadString($payload, ['displayName', 'name']), + $policyId + ); + unset($payload['name']); + + if ($payload === []) { + return [ + 'attempted' => true, + 'success' => false, + 'policy_id' => null, + 'response' => null, + ]; + } + + $this->graphLogger->logRequest('create_autopilot_profile', $context + [ + 'endpoint' => $resource, + 'method' => 'POST', + ]); + + $response = $this->graphClient->request( + 'POST', + $resource, + ['json' => $payload] + Arr::except($graphOptions, ['platform']) + ); + + $this->graphLogger->logResponse('create_autopilot_profile', $response, $context + [ + 'endpoint' => $resource, + 'method' => 'POST', + ]); + + $policyId = $this->extractCreatedPolicyId($response); + + return [ + 'attempted' => true, + 'success' => $response->successful(), + 'policy_id' => $policyId, + 'response' => $response, + ]; + } + + private function shouldAttemptAutopilotCreate(string $policyId, array $graphOptions): bool + { + $response = $this->graphClient->getPolicy( + 'windowsAutopilotDeploymentProfile', + $policyId, + $graphOptions + ); + + if ($response->successful()) { + return false; + } + + if ($response->status === 404) { + return true; + } + + $code = strtolower((string) ($response->meta['error_code'] ?? '')); + $message = strtolower((string) ($response->meta['error_message'] ?? '')); + + if (str_contains($code, 'notfound') || str_contains($code, 'resource')) { + return true; + } + + return str_contains($message, 'not found') + || str_contains($message, 'resource not found') + || str_contains($message, 'does not exist'); + } + private function shouldRetrySettingsCatalogCreateWithoutSettings(object $response): bool { $code = strtolower((string) ($response->meta['error_code'] ?? '')); @@ -1033,7 +1483,7 @@ private function buildSettingsCatalogCreatePayload( $payload = []; $name = $this->resolvePayloadString($originalPayload, ['name', 'displayName']); - $payload['name'] = $name ?? sprintf('Restored %s', $fallbackName); + $payload['name'] = $this->prefixRestoredName($name, $fallbackName); $description = $this->resolvePayloadString($originalPayload, ['description', 'Description']); if ($description !== null) { @@ -1075,6 +1525,24 @@ private function buildSettingsCatalogCreatePayload( return $payload; } + private function prefixRestoredName(?string $name, string $fallback): string + { + $prefix = 'Restored_'; + $base = trim((string) ($name ?? $fallback)); + + if ($base === '') { + $base = $fallback; + } + + $normalized = strtolower($base); + + if (str_starts_with($normalized, 'restored_') || str_starts_with($normalized, 'restored ')) { + return $base; + } + + return $prefix.$base; + } + /** * @param array $payload * @param array $keys diff --git a/app/Services/Intune/SettingsCatalogPolicyNormalizer.php b/app/Services/Intune/SettingsCatalogPolicyNormalizer.php new file mode 100644 index 0000000..6fc64c1 --- /dev/null +++ b/app/Services/Intune/SettingsCatalogPolicyNormalizer.php @@ -0,0 +1,31 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + return $this->defaultNormalizer->normalize($snapshot, $policyType, $platform); + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + return $this->defaultNormalizer->flattenForDiff($snapshot, $policyType, $platform); + } +} diff --git a/app/Services/Intune/VersionService.php b/app/Services/Intune/VersionService.php index 3d4f4da..8186f53 100644 --- a/app/Services/Intune/VersionService.php +++ b/app/Services/Intune/VersionService.php @@ -91,7 +91,15 @@ 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, + true + ); + $assignmentMetadata['assignments_fetched'] = true; + $assignmentMetadata['assignments_count'] = count($rawAssignments); if (! empty($rawAssignments)) { $resolvedGroups = []; @@ -110,14 +118,8 @@ public function captureFromGraph( $assignmentMetadata['has_orphaned_assignments'] = collect($resolvedGroups) ->contains(fn (array $group) => $group['orphaned'] ?? false); - $assignmentMetadata['assignments_count'] = count($rawAssignments); - $filterIds = collect($rawAssignments) - ->pluck('target.deviceAndAppManagementAssignmentFilterId') - ->filter() - ->unique() - ->values() - ->all(); + $filterIds = $this->extractAssignmentFilterIds($rawAssignments); $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); $filterNames = collect($filters) @@ -170,9 +172,21 @@ private function enrichAssignments(array $assignments, array $groups, array $fil $target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false; } - $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; - if ($filterId && isset($filterNames[$filterId])) { - $target['assignment_filter_name'] = $filterNames[$filterId]; + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null); + $filterType = $assignment['deviceAndAppManagementAssignmentFilterType'] + ?? ($target['deviceAndAppManagementAssignmentFilterType'] ?? null); + + if ($filterId) { + $target['deviceAndAppManagementAssignmentFilterId'] = $target['deviceAndAppManagementAssignmentFilterId'] ?? $filterId; + + if ($filterType) { + $target['deviceAndAppManagementAssignmentFilterType'] = $target['deviceAndAppManagementAssignmentFilterType'] ?? $filterType; + } + + if (isset($filterNames[$filterId])) { + $target['assignment_filter_name'] = $filterNames[$filterId]; + } } $assignment['target'] = $target; @@ -181,6 +195,30 @@ private function enrichAssignments(array $assignments, array $groups, array $fil }, $assignments); } + /** + * @param array> $assignments + * @return array + */ + private function extractAssignmentFilterIds(array $assignments): array + { + $filterIds = []; + + foreach ($assignments as $assignment) { + if (! is_array($assignment)) { + continue; + } + + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null); + + if (is_string($filterId) && $filterId !== '') { + $filterIds[] = $filterId; + } + } + + return array_values(array_unique($filterIds)); + } + /** * @param array $scopeTagIds * @return array{ids:array,names:array} diff --git a/app/Support/Concerns/InteractsWithODataTypes.php b/app/Support/Concerns/InteractsWithODataTypes.php index 843ee0e..eb5274a 100644 --- a/app/Support/Concerns/InteractsWithODataTypes.php +++ b/app/Support/Concerns/InteractsWithODataTypes.php @@ -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', ], diff --git a/config/graph_contracts.php b/config/graph_contracts.php index bdf58d4..b181301 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -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,54 @@ '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_payload_key' => 'deviceManagementScriptAssignments', + '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_payload_key' => 'deviceManagementScriptAssignments', + '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_payload_key' => 'deviceHealthScriptAssignments', + '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', @@ -148,6 +254,10 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'enrollmentConfigurationAssignments', ], 'windowsAutopilotDeploymentProfile' => [ 'resource' => 'deviceManagement/windowsAutopilotDeploymentProfiles', @@ -155,11 +265,26 @@ 'allowed_expand' => [], 'type_family' => [ '#microsoft.graph.windowsAutopilotDeploymentProfile', + '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile', + '#microsoft.graph.activeDirectoryWindowsAutopilotDeploymentProfile', ], 'create_method' => 'POST', 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'strip_odata' => false, + 'update_strip_keys' => [ + 'assignments', + 'managementServiceAppId', + 'outOfBoxExperienceSetting', + 'hardwareHashExtractionEnabled', + 'locale', + ], + 'assignments_list_path' => '/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments', + 'assignments_create_method' => 'POST', + 'assignments_delete_path' => '/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', ], 'windowsEnrollmentStatusPage' => [ 'resource' => 'deviceManagement/deviceEnrollmentConfigurations', @@ -172,6 +297,10 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'enrollmentConfigurationAssignments', ], 'endpointSecurityIntent' => [ 'resource' => 'deviceManagement/intents', diff --git a/config/intune_permissions.php b/config/intune_permissions.php index 87523f4..21684c1 100644 --- a/config/intune_permissions.php +++ b/config/intune_permissions.php @@ -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). diff --git a/config/tenantpilot.php b/config/tenantpilot.php index fe389b8..1e214df 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -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', diff --git a/resources/views/filament/infolists/entries/restore-preview.blade.php b/resources/views/filament/infolists/entries/restore-preview.blade.php index 00c852e..4fb8987 100644 --- a/resources/views/filament/infolists/entries/restore-preview.blade.php +++ b/resources/views/filament/infolists/entries/restore-preview.blade.php @@ -82,6 +82,12 @@ {{ $item['validation_warning'] }} @endif + + @if (! empty($item['compliance_action_warning'])) +
+ {{ $item['compliance_action_warning'] }} +
+ @endif @endforeach diff --git a/resources/views/filament/infolists/entries/restore-results.blade.php b/resources/views/filament/infolists/entries/restore-results.blade.php index 7a41afb..6d288b9 100644 --- a/resources/views/filament/infolists/entries/restore-results.blade.php +++ b/resources/views/filament/infolists/entries/restore-results.blade.php @@ -189,12 +189,59 @@ @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 + +
+ Compliance notifications: {{ (int) ($summary['mapped'] ?? 0) }} mapped • + {{ (int) ($summary['skipped'] ?? 0) }} skipped +
+ + @if ($complianceIssues->isNotEmpty()) +
+ Compliance notification details +
+ @foreach ($complianceIssues as $outcome) +
+
+
+ Template {{ $outcome['template_id'] ?? 'unknown' }} +
+ + skipped + +
+ @if (! empty($outcome['rule_name'])) +
+ Rule: {{ $outcome['rule_name'] }} +
+ @endif + @if (! empty($outcome['reason'])) +
+ {{ $outcome['reason'] }} +
+ @endif +
+ @endforeach +
+
+ @endif + @endif + @if (! empty($item['created_policy_id'])) @php $createdMode = $item['created_policy_mode'] ?? null; - $createdMessage = $createdMode === 'metadata_only' - ? 'New policy created (metadata only). Apply settings manually.' - : 'New policy created (manual cleanup required).'; + $createdMessage = match ($createdMode) { + 'metadata_only' => 'New policy created (metadata only). Apply settings manually.', + 'created' => 'New policy created.', + default => 'New policy created (manual cleanup required).', + }; @endphp
{{ $createdMessage }} ID: {{ $item['created_policy_id'] }} diff --git a/resources/views/livewire/policy-version-assignments-widget.blade.php b/resources/views/livewire/policy-version-assignments-widget.blade.php index fb6238b..f466235 100644 --- a/resources/views/livewire/policy-version-assignments-widget.blade.php +++ b/resources/views/livewire/policy-version-assignments-widget.blade.php @@ -118,9 +118,29 @@

Assignments

-

- Assignments were not captured for this version. -

+ @php + $assignmentsFetched = $version->metadata['assignments_fetched'] ?? false; + $assignmentsFetchFailed = $version->metadata['assignments_fetch_failed'] ?? false; + $assignmentsFetchError = $version->metadata['assignments_fetch_error'] ?? null; + @endphp + @if($assignmentsFetchFailed) +

+ Assignments could not be fetched from Microsoft Graph. +

+ @if($assignmentsFetchError) +

+ {{ $assignmentsFetchError }} +

+ @endif + @elseif($assignmentsFetched) +

+ No assignments found for this version. +

+ @else +

+ Assignments were not captured for this version. +

+ @endif @php $hasBackupItem = $version->policy->backupItems() ->whereNotNull('assignments') diff --git a/specs/007-device-config-compliance/plan.md b/specs/007-device-config-compliance/plan.md new file mode 100644 index 0000000..a8af39f --- /dev/null +++ b/specs/007-device-config-compliance/plan.md @@ -0,0 +1,79 @@ +# Implementation Plan: Device Configuration and Compliance Coverage + +**Branch**: `007-device-config-compliance` | **Date**: 2025-12-26 | **Spec**: ./spec.md +**Input**: Feature specification from `/specs/007-device-config-compliance/spec.md` + +## Summary + +Expand backup and restore coverage for device configuration, compliance, scripts, and update rings. This plan focuses on policy type coverage, assignment capture, and safe restore behavior using existing foundation mappings and assignment logic. + +Phase outputs: +- Phase 0 research: n/a (no new research artifact yet) +- Phase 1 design: n/a (no new data model artifact yet) + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Laravel 12, Filament v4, Livewire v3, Microsoft Graph (custom client abstraction) +**Storage**: PostgreSQL (JSONB payload storage for snapshots) +**Testing**: Pest v4 + PHPUnit 12 +**Target Platform**: Docker/Sail locally; container deploy via Dokploy +**Project Type**: Web application (Laravel backend + Filament admin UI) +**Performance Goals**: Restore preview for 100 selected items in under 2 minutes +**Constraints**: Restore must be defensive (no deletions); assignments only applied with valid mapping; audit logs required +**Scale/Scope**: Tenants with mixed configuration and compliance policies, including scripts and update rings + +## Constitution Check + +The constitution at `.specify/memory/constitution.md` is currently an unfilled template. For this feature, adopt the repo rules as gates: + +- Sail-first local dev/test commands. +- Spec gate: code changes must be accompanied by `specs/007-device-config-compliance/` updates. +- Tests required for behavior changes (Pest). +- Restore safety: never delete; skip unsafe assignments; record reasons. +- Auditability: backup and restore outcomes are logged per tenant. + +## Project Structure + +### Documentation (this feature) + +```text +specs/007-device-config-compliance/ +├── spec.md +├── plan.md +└── tasks.md +``` + +### Source Code (expected touch points) + +```text +app/ +├── Filament/ +│ └── Resources/ +├── Models/ +│ ├── BackupItem.php +│ ├── Policy.php +│ └── PolicyVersion.php +├── Services/ +│ ├── Graph/ +│ └── Intune/ +└── Jobs/ + +config/ +├── graph_contracts.php +├── intune_permissions.php +└── tenantpilot.php + +tests/ +├── Feature/ +└── Unit/ +``` + +**Structure Decision**: Extend existing services (PolicySnapshotService, PolicyCaptureOrchestrator, RestoreService) and Filament resources, adding only targeted helpers where needed. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| n/a | n/a | n/a | + diff --git a/specs/007-device-config-compliance/spec.md b/specs/007-device-config-compliance/spec.md new file mode 100644 index 0000000..765f1c3 --- /dev/null +++ b/specs/007-device-config-compliance/spec.md @@ -0,0 +1,78 @@ +# Feature Specification: Device Configuration and Compliance Coverage + +**Feature Branch**: `007-device-config-compliance` +**Created**: 2025-12-26 +**Status**: Draft +**Input**: Workload list for Intune backup and restore coverage (MVP vs full scope). + +## Program Scope Reference (MVP vs Full) + +### MVP Scope (Phase 1) +- Device configuration and compliance: administrative templates; settings catalog policies; device configurations (including custom OMA-URI); device compliance policies; assignments. +- Scripts and remediations: PowerShell scripts (Windows); macOS shell scripts (where supported); proactive remediations and assignments. +- Enrollment and Autopilot: Autopilot deployment profiles and assignments; Enrollment Status Page (ESP) if used. +- Update management (Windows): software update rings and assignments. +- Endpoint security: endpoint security configurations (antivirus, firewall, disk encryption, EDR, ASR, account protection) and assignments. +- Tenant administration foundations: assignment filters; scope tags; notification message templates. + +### Full Scope (Phase 2+) +- Compliance actions and notifications: actions for noncompliance; compliance notifications and templates. +- Apps and app management: client apps; app protection policies; app configuration policies; assignments; supersedence metadata. +- Enrollment: enrollment restrictions; enrollment notifications; terms and conditions; ADE tokens and profiles. +- Update management: feature update policies; quality update policies; driver update policies; expedite/hotpatch policies. +- Endpoint security: security baselines (Windows security baseline, Microsoft Defender, Microsoft Edge); endpoint privilege management policies. +- Tenant administration: device cleanup rules; RBAC roles and role assignments. +- 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. + +## User Scenarios and Testing (mandatory) + +### User Story 1 - Backup and Restore Core Configuration Policies (Priority: P1) +As an admin, I want to back up and restore device configuration and compliance policies with their assignments and scope tags, so that a restore reproduces targeting accurately. + +**Independent Test**: Select at least one settings catalog policy, one device configuration policy (including an OMA-URI policy), and one device compliance policy. Create a backup with assignments and scope tags enabled. Restore into a tenant with different group IDs and verify assignments are mapped or skipped with clear reasons. + +**Acceptance Scenarios**: +1. Given policies with assignments and scope tags, when a backup is captured, then assignments and scope tag metadata are stored alongside the snapshot. +2. Given a restore run with group mapping, when policies are restored, then assignments are applied using mapped group IDs and assignment filters. +3. Given missing mappings, when restore executes, then assignments are skipped and a human readable reason is recorded. + +### User Story 2 - Compliance Actions and Notifications (Priority: P2) +As an admin, I want actions for noncompliance and compliance notification templates to be captured and restored, so that compliance workflows remain intact after restore. + +**Independent Test**: Create a compliance policy with scheduled actions and a notification template. Capture a backup including foundations. Restore into a tenant without that template and verify the template is created and referenced correctly. + +**Acceptance Scenarios**: +1. Given a compliance policy referencing a notification template, when restore executes, then the template is restored first and the policy references the mapped template ID. +2. Given a missing template and no mapping, when restore executes, then the policy is restored without that action and a skip reason is recorded. + +### User Story 3 - Scripts and Remediations (Priority: P3) +As an admin, I want scripts and remediations to be captured and restored with assignments, so that endpoint automation is preserved. + +**Independent Test**: Capture a PowerShell script and a proactive remediation with assignments. Restore into a test tenant and verify assignments are applied safely. + +**Acceptance Scenarios**: +1. Given a script policy with assignments, when restore executes, then the script is recreated or updated and assignments are applied. +2. Given a remediation with missing assignment filter mapping, when restore executes, then the assignment is skipped and the remediation is still restored. + +## Requirements (mandatory) + +### Functional Requirements +- **FR-007.1**: System MUST support backup and restore for administrative templates, settings catalog policies, device configurations (including OMA-URI), and device compliance policies. +- **FR-007.2**: System MUST capture assignments and scope tags when the backup flags are enabled, using the existing capture orchestrator. +- **FR-007.3**: System MUST handle compliance actions and notification templates by restoring templates first and mapping references in policies. +- **FR-007.4**: System MUST restore scripts and remediations with assignments, applying foundation mappings and group mappings where available. +- **FR-007.5**: System MUST keep Conditional Access restore preview-only until identity dependency mapping is supported. +- **FR-007.6**: System MUST record audit logs for backup and restore actions, including skipped assignments and template mapping outcomes. + +### Non-Goals +- No support for app workloads in this feature (tracked separately). +- No connector or token restore (metadata-only handled in a later phase). + +## 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. diff --git a/specs/007-device-config-compliance/tasks.md b/specs/007-device-config-compliance/tasks.md new file mode 100644 index 0000000..e5a8981 --- /dev/null +++ b/specs/007-device-config-compliance/tasks.md @@ -0,0 +1,79 @@ +# Tasks: Device Configuration and Compliance Coverage (007) + +**Branch**: `feat/007-device-config-compliance` | **Date**: 2025-12-26 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Task Format + +- **Checkbox**: `- [ ]` for incomplete, `- [x]` for complete +- **Task ID**: Sequential T001, T002, T003... +- **[P] marker**: Task can run in parallel (different files, no blocking dependencies) +- **[Story] label**: User story tag (US1, US2, US3...) +- **File path**: Always include exact file path in description + +## Phase 1: Policy Types, Contracts, Permissions + +**Purpose**: Add missing device configuration, compliance, scripts, and update ring types with Graph contract coverage. + +- [x] T001 [P] Expand policy type registry for device configuration, compliance, scripts, and update rings in `config/tenantpilot.php` (labels, categories, restore mode, risk). +- [x] T002 [P] Add/update Graph contracts and assignment endpoints for new policy types in `config/graph_contracts.php`. +- [x] T003 [P] Verify and extend permissions for the new workloads in `config/intune_permissions.php`. +- [x] T004 Update type metadata helpers and filters in `app/Filament/Resources/PolicyResource.php` and `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`. + +**Checkpoint**: New policy types are recognized across UI metadata and Graph contract registry. + +--- + +## Phase 2: Snapshot Capture and Metadata + +**Purpose**: Ensure snapshots, assignments, and scope tags are captured for the new workloads. + +- [x] T005 Update `app/Services/Intune/PolicySnapshotService.php` to fetch and hydrate the new policy types correctly (filters, select fields). +- [x] T006 Extend `app/Services/Intune/PolicyCaptureOrchestrator.php` to capture assignments and scope tags for the new types with existing resolvers. +- [x] T007 Update `app/Services/Intune/BackupService.php` to capture snapshots for the new types and propagate warnings. +- [x] T008 Add or extend normalization support in `app/Services/Intune/PolicyNormalizer.php` for the new policy types. + +**Checkpoint**: Backups include snapshots and metadata for configuration/compliance policies. + +--- + +## Phase 3: Restore Logic and Mapping + +**Purpose**: Restore new policy types safely using assignment and foundation mappings. + +- [ ] T009 Update `app/Services/Intune/RestoreService.php` to restore the new policy types using Graph contracts. +- [x] T010 Extend `app/Services/AssignmentRestoreService.php` for assignment endpoints of the new types. +- [x] T011 Ensure compliance notification templates are restored and referenced via mapping in `app/Services/Intune/RestoreService.php`. +- [x] T012 Add audit coverage for compliance action mapping outcomes in `app/Services/Intune/AuditLogger.php`. + +**Checkpoint**: Restore applies policies and assignments or skips with clear reasons. + +--- + +## Phase 4: Admin UX + +**Purpose**: Surface restore and compliance details clearly in the UI. + +- [ ] T013 Update `resources/views/filament/infolists/entries/restore-preview.blade.php` to surface compliance action/template warnings. +- [ ] T014 Update `resources/views/filament/infolists/entries/restore-results.blade.php` to show compliance action mapping outcomes and skip reasons. + +**Checkpoint**: Admins can see compliance related mapping results in preview and results. + +--- + +## Phase 5: Tests and Verification + +**Purpose**: Cover new workloads with Pest tests and verify formatting. + +- [ ] T015 Add unit tests for snapshot and normalization coverage in `tests/Unit/PolicySnapshotServiceTest.php` and `tests/Unit/PolicyNormalizerTest.php`. +- [ ] T016 Add feature tests for backup and restore flows in `tests/Feature/Filament/RestorePreviewTest.php` and `tests/Feature/Filament/RestoreExecutionTest.php`. +- [ ] T017 Run tests: `./vendor/bin/sail artisan test tests/Unit/PolicySnapshotServiceTest.php tests/Unit/PolicyNormalizerTest.php tests/Feature/Filament/RestorePreviewTest.php tests/Feature/Filament/RestoreExecutionTest.php` +- [ ] T018 Run Pint: `./vendor/bin/pint --dirty` + +**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. diff --git a/tests/Feature/Filament/BackupCreationTest.php b/tests/Feature/Filament/BackupCreationTest.php index 896b6f3..e619c15 100644 --- a/tests/Feature/Filament/BackupCreationTest.php +++ b/tests/Feature/Filament/BackupCreationTest.php @@ -19,7 +19,7 @@ // Mock PolicySnapshotService $this->mock(PolicySnapshotService::class, function (MockInterface $mock) { $mock->shouldReceive('fetch') - ->twice() // Called once for each policy + ->once() // Called once for the active policy ->andReturnUsing(function ($tenant, $policy) { return [ 'payload' => [ @@ -96,6 +96,7 @@ public function request(string $method, string $path, array $options = []): Grap 'display_name' => 'Policy B', 'platform' => 'windows', 'last_synced_at' => now(), + 'ignored_at' => now(), ]); $user = User::factory()->create(); @@ -109,15 +110,15 @@ public function request(string $method, string $path, array $options = []): Grap 'ownerRecord' => $backupSet, 'pageClass' => \App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class, ])->callTableAction('addPolicies', data: [ - 'policy_ids' => [$policyA->id, $policyB->id], + 'policy_ids' => [$policyA->id], 'include_assignments' => false, 'include_scope_tags' => true, ]); $backupSet->refresh(); - expect($backupSet->item_count)->toBe(2); - expect($backupSet->items)->toHaveCount(2); + expect($backupSet->item_count)->toBe(1); + expect($backupSet->items)->toHaveCount(1); expect($backupSet->items->first()->payload['id'])->toBe('policy-1'); $firstVersion = PolicyVersion::find($backupSet->items->first()->policy_version_id); @@ -140,3 +141,61 @@ public function request(string $method, string $path, array $options = []): Grap 'resource_id' => (string) $backupSet->id, ]); }); + +test('backup service skips ignored policies', function () { + $this->mock(PolicySnapshotService::class, function (MockInterface $mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturnUsing(function ($tenant, $policy) { + return [ + 'payload' => [ + 'id' => $policy->external_id, + 'name' => $policy->display_name, + 'roleScopeTagIds' => ['0'], + ], + 'metadata' => [], + 'warnings' => [], + ]; + }); + }); + + $tenant = Tenant::create([ + 'name' => 'Test tenant', + 'external_id' => 'tenant-1', + 'tenant_id' => 'tenant-1', + 'status' => 'active', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $policyA = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Policy A', + 'platform' => 'windows', + 'last_synced_at' => now(), + ]); + + $policyB = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-2', + 'policy_type' => 'deviceCompliancePolicy', + 'display_name' => 'Policy B', + 'platform' => 'windows', + 'last_synced_at' => now(), + 'ignored_at' => now(), + ]); + + $service = app(\App\Services\Intune\BackupService::class); + $backupSet = $service->createBackupSet( + tenant: $tenant, + policyIds: [$policyA->id, $policyB->id], + actorEmail: 'tester@example.com', + actorName: 'Tester', + ); + + expect($backupSet->item_count)->toBe(1); + expect($backupSet->items->pluck('policy_id')->all())->toBe([$policyA->id]); +}); diff --git a/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php b/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php index 587329c..45ec5a2 100644 --- a/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php +++ b/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php @@ -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) diff --git a/tests/Feature/Filament/RestoreExecutionTest.php b/tests/Feature/Filament/RestoreExecutionTest.php index 252aafe..706e2a0 100644 --- a/tests/Feature/Filament/RestoreExecutionTest.php +++ b/tests/Feature/Filament/RestoreExecutionTest.php @@ -182,3 +182,213 @@ 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, + ]); +}); + +test('restore execution creates an autopilot profile when missing', function () { + $graphClient = new class implements GraphClientInterface + { + public int $applyCalls = 0; + + public int $getCalls = 0; + + public int $createCalls = 0; + + public array $createPayloads = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + $this->getCalls++; + + return new GraphResponse(false, [], 404, [], [], [ + 'error_code' => 'ResourceNotFound', + 'error_message' => 'Resource not found.', + ]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applyCalls++; + + return new GraphResponse(false, [], 500, [], [], [ + 'error_code' => 'InternalServerError', + 'error_message' => 'An internal server error has occurred.', + ]); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + if ($method === 'POST' && str_contains($path, 'windowsAutopilotDeploymentProfiles')) { + $this->createCalls++; + $this->createPayloads[] = $options['json'] ?? []; + + return new GraphResponse(true, ['id' => 'autopilot-created']); + } + + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }; + + app()->instance(GraphClientInterface::class, $graphClient); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory() + ->for($tenant) + ->for($backupSet) + ->state([ + 'policy_id' => null, + 'policy_identifier' => 'autopilot-1', + 'policy_type' => 'windowsAutopilotDeploymentProfile', + 'platform' => 'windows', + 'payload' => [ + '@odata.type' => '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile', + 'displayName' => 'Autopilot Profile', + 'language' => 'en-US', + ], + ]) + ->create(); + + $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($graphClient->applyCalls)->toBe(1); + expect($graphClient->getCalls)->toBe(1); + expect($graphClient->createCalls)->toBe(1); + expect($graphClient->createPayloads[0]['displayName'] ?? null)->toBe('Restored_Autopilot Profile'); + expect($run->status)->toBe('completed'); + expect($run->results[0]['status'])->toBe('applied'); + expect($run->results[0]['created_policy_id'])->toBe('autopilot-created'); +}); diff --git a/tests/Feature/Filament/RestoreItemSelectionTest.php b/tests/Feature/Filament/RestoreItemSelectionTest.php index 0699485..a3ce170 100644 --- a/tests/Feature/Filament/RestoreItemSelectionTest.php +++ b/tests/Feature/Filament/RestoreItemSelectionTest.php @@ -22,6 +22,14 @@ 'display_name' => 'Policy Display', 'platform' => 'windows', ]); + $ignoredPolicy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-ignored', + 'policy_type' => 'deviceCompliancePolicy', + 'display_name' => 'Ignored Policy', + 'platform' => 'windows', + 'ignored_at' => now(), + ]); $backupSet = BackupSet::factory()->for($tenant)->create([ 'item_count' => 2, @@ -39,6 +47,18 @@ ]) ->create(); + BackupItem::factory() + ->for($tenant) + ->for($backupSet) + ->state([ + 'policy_id' => $ignoredPolicy->id, + 'policy_identifier' => $ignoredPolicy->external_id, + 'policy_type' => $ignoredPolicy->policy_type, + 'platform' => $ignoredPolicy->platform, + 'payload' => ['id' => $ignoredPolicy->external_id], + ]) + ->create(); + BackupItem::factory() ->for($tenant) ->for($backupSet) @@ -65,6 +85,7 @@ 'backup_set_id' => $backupSet->id, ]) ->assertSee('Policy Display') + ->assertDontSee('Ignored Policy') ->assertSee('Scope Tag Alpha') ->assertSee('Settings Catalog Policy') ->assertSee('Scope Tag') diff --git a/tests/Feature/Filament/RestorePreviewTest.php b/tests/Feature/Filament/RestorePreviewTest.php index 75196aa..fc441dc 100644 --- a/tests/Feature/Filament/RestorePreviewTest.php +++ b/tests/Feature/Filament/RestorePreviewTest.php @@ -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); +}); diff --git a/tests/Feature/Filament/SettingsCatalogRestoreTest.php b/tests/Feature/Filament/SettingsCatalogRestoreTest.php index 24a7a79..0570dad 100644 --- a/tests/Feature/Filament/SettingsCatalogRestoreTest.php +++ b/tests/Feature/Filament/SettingsCatalogRestoreTest.php @@ -536,7 +536,9 @@ public function request(string $method, string $path, array $options = []): Grap expect($client->requestCalls[1]['path'])->toBe('deviceManagement/configurationPolicies'); expect($client->requestCalls[1]['payload'])->toHaveKey('settings'); expect($client->requestCalls[1]['payload'])->toHaveKey('name'); + expect($client->requestCalls[1]['payload']['name'])->toBe('Restored_Settings Catalog Epsilon'); expect($client->requestCalls[2]['path'])->toBe('deviceManagement/configurationPolicies'); expect($client->requestCalls[2]['payload'])->not->toHaveKey('settings'); expect($client->requestCalls[2]['payload'])->toHaveKey('name'); + expect($client->requestCalls[2]['payload']['name'])->toBe('Restored_Settings Catalog Epsilon'); }); diff --git a/tests/Feature/PolicyVersionViewAssignmentsTest.php b/tests/Feature/PolicyVersionViewAssignmentsTest.php index 239ff72..af5de47 100644 --- a/tests/Feature/PolicyVersionViewAssignmentsTest.php +++ b/tests/Feature/PolicyVersionViewAssignmentsTest.php @@ -92,3 +92,23 @@ $response->assertOk(); $response->assertSee('Assignments were not captured for this version'); }); + +it('shows empty assignments message when assignments were fetched', function () { + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $this->policy->id, + 'version_number' => 1, + 'assignments' => null, + 'metadata' => [ + 'assignments_fetched' => true, + 'assignments_count' => 0, + ], + ]); + + $this->actingAs($this->user); + + $response = $this->get("/admin/policy-versions/{$version->id}"); + + $response->assertOk(); + $response->assertSee('No assignments found for this version'); +}); diff --git a/tests/Feature/VersionCaptureWithAssignmentsTest.php b/tests/Feature/VersionCaptureWithAssignmentsTest.php index 459ef23..2a1ebfb 100644 --- a/tests/Feature/VersionCaptureWithAssignmentsTest.php +++ b/tests/Feature/VersionCaptureWithAssignmentsTest.php @@ -98,6 +98,70 @@ expect($version->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices'); }); +it('hydrates assignment filter names when filter data is stored at root', function () { + $this->mock(PolicySnapshotService::class, function ($mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + 'payload' => [ + 'id' => 'test-policy-id', + 'name' => 'Test Policy', + 'settings' => [], + ], + ]); + }); + + $this->mock(AssignmentFetcher::class, function ($mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + [ + 'id' => 'assignment-1', + 'intent' => 'apply', + 'deviceAndAppManagementAssignmentFilterId' => 'filter-123', + 'deviceAndAppManagementAssignmentFilterType' => 'include', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-123', + ], + ], + ]); + }); + + $this->mock(GroupResolver::class, function ($mock) { + $mock->shouldReceive('resolveGroupIds') + ->once() + ->andReturn([ + 'group-123' => [ + 'id' => 'group-123', + 'displayName' => 'Test Group', + 'orphaned' => false, + ], + ]); + }); + + $this->mock(AssignmentFilterResolver::class, function ($mock) { + $mock->shouldReceive('resolve') + ->once() + ->andReturn([ + ['id' => 'filter-123', 'displayName' => 'Targeted Devices'], + ]); + }); + + $versionService = app(VersionService::class); + $version = $versionService->captureFromGraph( + $this->tenant, + $this->policy, + 'test@example.com' + ); + + expect($version->assignments)->not->toBeNull() + ->and($version->assignments)->toHaveCount(1) + ->and($version->assignments[0]['target']['deviceAndAppManagementAssignmentFilterId'])->toBe('filter-123') + ->and($version->assignments[0]['target']['deviceAndAppManagementAssignmentFilterType'])->toBe('include') + ->and($version->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices'); +}); + it('captures policy version without assignments when none exist', function () { // Mock dependencies $this->mock(PolicySnapshotService::class, function ($mock) { @@ -127,7 +191,9 @@ expect($version)->not->toBeNull() ->and($version->assignments)->toBeNull() - ->and($version->assignments_hash)->toBeNull(); + ->and($version->assignments_hash)->toBeNull() + ->and($version->metadata['assignments_fetched'])->toBeTrue() + ->and($version->metadata['assignments_count'])->toBe(0); }); it('handles assignment fetch failure gracefully', function () { diff --git a/tests/Unit/AssignmentBackupServiceTest.php b/tests/Unit/AssignmentBackupServiceTest.php new file mode 100644 index 0000000..d66a058 --- /dev/null +++ b/tests/Unit/AssignmentBackupServiceTest.php @@ -0,0 +1,93 @@ +create([ + 'tenant_id' => 'tenant-123', + 'external_id' => 'tenant-123', + ]); + + $backupItem = BackupItem::factory()->create([ + 'tenant_id' => $tenant->id, + 'metadata' => [], + 'assignments' => null, + ]); + + $policyPayload = [ + 'roleScopeTagIds' => ['0'], + ]; + + $this->mock(AssignmentFetcher::class, function (MockInterface $mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + [ + 'id' => 'assignment-1', + 'intent' => 'apply', + 'deviceAndAppManagementAssignmentFilterId' => 'filter-123', + 'deviceAndAppManagementAssignmentFilterType' => 'include', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-123', + ], + ], + ]); + }); + + $this->mock(GroupResolver::class, function (MockInterface $mock) { + $mock->shouldReceive('resolveGroupIds') + ->once() + ->andReturn([ + 'group-123' => [ + 'id' => 'group-123', + 'displayName' => 'Test Group', + 'orphaned' => false, + ], + ]); + }); + + $this->mock(AssignmentFilterResolver::class, function (MockInterface $mock) use ($tenant) { + $mock->shouldReceive('resolve') + ->once() + ->with(['filter-123'], $tenant) + ->andReturn([ + ['id' => 'filter-123', 'displayName' => 'Targeted Devices'], + ]); + }); + + $this->mock(ScopeTagResolver::class, function (MockInterface $mock) use ($tenant) { + $mock->shouldReceive('resolve') + ->once() + ->with(['0'], $tenant) + ->andReturn([ + ['id' => '0', 'displayName' => 'Default'], + ]); + }); + + $service = app(AssignmentBackupService::class); + $updated = $service->enrichWithAssignments( + backupItem: $backupItem, + tenant: $tenant, + policyType: 'settingsCatalogPolicy', + policyId: 'policy-123', + policyPayload: $policyPayload, + includeAssignments: true + ); + + expect($updated->assignments)->toHaveCount(1) + ->and($updated->assignments[0]['target']['deviceAndAppManagementAssignmentFilterId'])->toBe('filter-123') + ->and($updated->assignments[0]['target']['deviceAndAppManagementAssignmentFilterType'])->toBe('include') + ->and($updated->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices'); +}); diff --git a/tests/Unit/AssignmentFetcherTest.php b/tests/Unit/AssignmentFetcherTest.php index f685067..2626ab1 100644 --- a/tests/Unit/AssignmentFetcherTest.php +++ b/tests/Unit/AssignmentFetcherTest.php @@ -1,6 +1,7 @@ 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() + ->twice() ->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,34 @@ ->once() ->andReturn($fallbackResponse); - $result = $this->fetcher->fetch($tenantId, $policyId); + $result = $this->fetcher->fetch($policyType, $tenantId, $policyId); expect($result)->toBe([]); }); + +test('throws when both endpoints fail with throwOnFailure enabled', function () { + $tenantId = 'tenant-123'; + $policyId = 'policy-456'; + $policyType = 'settingsCatalogPolicy'; + + $failureResponse = new GraphResponse( + success: false, + data: [], + status: 403, + errors: [['message' => 'Forbidden']] + ); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", Mockery::any()) + ->andReturn($failureResponse); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('GET', 'deviceManagement/configurationPolicies', Mockery::any()) + ->andReturn($failureResponse); + + $this->fetcher->fetch($policyType, $tenantId, $policyId, [], true); +})->throws(GraphException::class); diff --git a/tests/Unit/AssignmentRestoreServiceTest.php b/tests/Unit/AssignmentRestoreServiceTest.php new file mode 100644 index 0000000..1d0b96b --- /dev/null +++ b/tests/Unit/AssignmentRestoreServiceTest.php @@ -0,0 +1,202 @@ +set('graph_contracts.types.deviceManagementScript', [ + 'assignments_create_path' => '/deviceManagement/deviceManagementScripts/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'deviceManagementScriptAssignments', + ]); + config()->set('graph_contracts.types.settingsCatalogPolicy', [ + 'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign', + 'assignments_create_method' => 'POST', + ]); + + $this->graphClient = Mockery::mock(GraphClientInterface::class); + $this->auditLogger = Mockery::mock(AuditLogger::class); + $this->filterResolver = Mockery::mock(AssignmentFilterResolver::class); + $this->filterResolver->shouldReceive('resolve')->andReturn([])->byDefault(); + + $this->service = new AssignmentRestoreService( + $this->graphClient, + app(GraphContractRegistry::class), + app(GraphLogger::class), + $this->auditLogger, + $this->filterResolver, + ); +}); + +it('uses the contract assignment payload key for assign actions', function () { + $tenant = Tenant::factory()->make([ + 'tenant_id' => 'tenant-123', + 'app_client_id' => null, + 'app_client_secret' => null, + ]); + $policyId = 'policy-123'; + $assignments = [ + [ + 'id' => 'assignment-1', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-1', + ], + ], + ]; + $expectedAssignments = [ + [ + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-1', + ], + ], + ]; + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('POST', "/deviceManagement/deviceManagementScripts/{$policyId}/assign", Mockery::on( + fn (array $options) => ($options['json']['deviceManagementScriptAssignments'] ?? null) === $expectedAssignments + )) + ->andReturn(new GraphResponse(success: true, data: [])); + + $this->auditLogger + ->shouldReceive('log') + ->once() + ->andReturn(new AuditLog); + + $result = $this->service->restore( + $tenant, + 'deviceManagementScript', + $policyId, + $assignments, + [] + ); + + expect($result['summary']['success'])->toBe(1); + expect($result['summary']['failed'])->toBe(0); + expect($result['summary']['skipped'])->toBe(0); +}); + +it('maps assignment filter ids stored at the root of assignments', function () { + $tenant = Tenant::factory()->make([ + 'tenant_id' => 'tenant-123', + 'app_client_id' => null, + 'app_client_secret' => null, + ]); + $policyId = 'policy-789'; + $assignments = [ + [ + 'id' => 'assignment-1', + 'deviceAndAppManagementAssignmentFilterId' => 'filter-source', + 'deviceAndAppManagementAssignmentFilterType' => 'include', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-1', + ], + ], + ]; + $expectedAssignments = [ + [ + 'deviceAndAppManagementAssignmentFilterId' => 'filter-target', + 'deviceAndAppManagementAssignmentFilterType' => 'include', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-1', + ], + ], + ]; + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('POST', "/deviceManagement/configurationPolicies/{$policyId}/assign", Mockery::on( + fn (array $options) => ($options['json']['assignments'] ?? null) === $expectedAssignments + )) + ->andReturn(new GraphResponse(success: true, data: [])); + + $this->auditLogger + ->shouldReceive('log') + ->once() + ->andReturn(new AuditLog); + + $result = $this->service->restore( + $tenant, + 'settingsCatalogPolicy', + $policyId, + $assignments, + [], + [ + 'assignmentFilter' => [ + 'filter-source' => 'filter-target', + ], + ] + ); + + expect($result['summary']['success'])->toBe(1); + expect($result['summary']['failed'])->toBe(0); + expect($result['summary']['skipped'])->toBe(0); +}); + +it('keeps assignment filters when mapping is missing but filter exists in target', function () { + $tenant = Tenant::factory()->make([ + 'tenant_id' => 'tenant-123', + 'app_client_id' => null, + 'app_client_secret' => null, + ]); + $policyId = 'policy-999'; + $assignments = [ + [ + 'id' => 'assignment-1', + 'deviceAndAppManagementAssignmentFilterId' => 'filter-1', + 'deviceAndAppManagementAssignmentFilterType' => 'include', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-1', + ], + ], + ]; + + $this->filterResolver + ->shouldReceive('resolve') + ->once() + ->with(['filter-1'], $tenant) + ->andReturn([['id' => 'filter-1', 'displayName' => 'Test']]); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('POST', "/deviceManagement/configurationPolicies/{$policyId}/assign", Mockery::on( + fn (array $options) => ($options['json']['assignments'][0]['deviceAndAppManagementAssignmentFilterId'] ?? null) === 'filter-1' + )) + ->andReturn(new GraphResponse(success: true, data: [])); + + $this->auditLogger + ->shouldReceive('log') + ->once() + ->andReturn(new AuditLog); + + $result = $this->service->restore( + $tenant, + 'settingsCatalogPolicy', + $policyId, + $assignments, + [], + [] + ); + + expect($result['summary']['success'])->toBe(1); + expect($result['summary']['failed'])->toBe(0); + expect($result['summary']['skipped'])->toBe(0); +}); diff --git a/tests/Unit/CompliancePolicyNormalizerTest.php b/tests/Unit/CompliancePolicyNormalizerTest.php new file mode 100644 index 0000000..a2c5bca --- /dev/null +++ b/tests/Unit/CompliancePolicyNormalizerTest.php @@ -0,0 +1,36 @@ + '#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'); +}); diff --git a/tests/Unit/DeviceConfigurationPolicyNormalizerTest.php b/tests/Unit/DeviceConfigurationPolicyNormalizerTest.php new file mode 100644 index 0000000..913dd2d --- /dev/null +++ b/tests/Unit/DeviceConfigurationPolicyNormalizerTest.php @@ -0,0 +1,39 @@ + '#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'); +}); diff --git a/tests/Unit/GraphContractRegistryActualDataTest.php b/tests/Unit/GraphContractRegistryActualDataTest.php index 0bb4065..813aecf 100644 --- a/tests/Unit/GraphContractRegistryActualDataTest.php +++ b/tests/Unit/GraphContractRegistryActualDataTest.php @@ -49,3 +49,47 @@ // Null values should be preserved (Graph might need them) expect(array_key_exists('settingValueTemplateReference', $sanitized[0]['settingInstance']['choiceSettingValue']))->toBeTrue(); }); + +it('exposes autopilot assignments paths', function () { + $contract = $this->registry->get('windowsAutopilotDeploymentProfile'); + + expect($contract)->not->toBeEmpty(); + expect($contract['assignments_list_path'] ?? null) + ->toBe('/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments'); + expect($contract['assignments_create_path'] ?? null) + ->toBe('/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments'); + expect($contract['assignments_delete_path'] ?? null) + ->toBe('/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments/{assignmentId}'); + expect($this->registry->matchesTypeFamily( + 'windowsAutopilotDeploymentProfile', + '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile' + ))->toBeTrue(); + expect($this->registry->matchesTypeFamily( + 'windowsAutopilotDeploymentProfile', + '#microsoft.graph.activeDirectoryWindowsAutopilotDeploymentProfile' + ))->toBeTrue(); +}); + +it('sanitizes autopilot update payload by stripping odata and assignments', function () { + $payload = [ + '@odata.type' => '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile', + 'id' => 'profile-1', + 'displayName' => 'Autopilot Profile', + 'assignments' => [['id' => 'assignment-1']], + 'managementServiceAppId' => 'service-app', + 'outOfBoxExperienceSetting' => ['deviceUsageType' => 'shared'], + 'hardwareHashExtractionEnabled' => true, + 'locale' => 'de-DE', + ]; + + $sanitized = $this->registry->sanitizeUpdatePayload('windowsAutopilotDeploymentProfile', $payload); + + expect($sanitized)->toHaveKey('displayName'); + expect($sanitized)->toHaveKey('@odata.type'); + expect($sanitized)->not->toHaveKey('id'); + expect($sanitized)->not->toHaveKey('assignments'); + expect($sanitized)->not->toHaveKey('managementServiceAppId'); + expect($sanitized)->not->toHaveKey('outOfBoxExperienceSetting'); + expect($sanitized)->not->toHaveKey('hardwareHashExtractionEnabled'); + expect($sanitized)->not->toHaveKey('locale'); +}); diff --git a/tests/Unit/PolicyNormalizerRoutingTest.php b/tests/Unit/PolicyNormalizerRoutingTest.php new file mode 100644 index 0000000..50be439 --- /dev/null +++ b/tests/Unit/PolicyNormalizerRoutingTest.php @@ -0,0 +1,44 @@ + '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'); +}); diff --git a/tests/Unit/SettingsCatalogPolicyNormalizerTest.php b/tests/Unit/SettingsCatalogPolicyNormalizerTest.php new file mode 100644 index 0000000..fcc9a14 --- /dev/null +++ b/tests/Unit/SettingsCatalogPolicyNormalizerTest.php @@ -0,0 +1,33 @@ + '#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'); +});