From 818c98df3c6e8471cf123c2d76a032b2489635a0 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Tue, 23 Dec 2025 00:09:03 +0100 Subject: [PATCH] fix: show assignment types and filters --- app/Services/AssignmentBackupService.php | 53 ++++++++++++++- .../Graph/AssignmentFilterResolver.php | 54 +++++++++++++++ .../Intune/PolicyCaptureOrchestrator.php | 60 ++++++++++++++++- app/Services/Intune/VersionService.php | 50 +++++++++++++- ...olicy-version-assignments-widget.blade.php | 27 ++++++-- .../PolicyVersionViewAssignmentsTest.php | 18 ++++- .../VersionCaptureWithAssignmentsTest.php | 14 ++++ tests/Unit/AssignmentFilterResolverTest.php | 65 +++++++++++++++++++ 8 files changed, 329 insertions(+), 12 deletions(-) create mode 100644 app/Services/Graph/AssignmentFilterResolver.php create mode 100644 tests/Unit/AssignmentFilterResolverTest.php diff --git a/app/Services/AssignmentBackupService.php b/app/Services/AssignmentBackupService.php index d46c92a..f94c697 100644 --- a/app/Services/AssignmentBackupService.php +++ b/app/Services/AssignmentBackupService.php @@ -5,6 +5,7 @@ use App\Models\BackupItem; use App\Models\Tenant; use App\Services\Graph\AssignmentFetcher; +use App\Services\Graph\AssignmentFilterResolver; use App\Services\Graph\GroupResolver; use App\Services\Graph\ScopeTagResolver; use Illuminate\Support\Facades\Log; @@ -14,6 +15,7 @@ class AssignmentBackupService public function __construct( private readonly AssignmentFetcher $assignmentFetcher, private readonly GroupResolver $groupResolver, + private readonly AssignmentFilterResolver $assignmentFilterResolver, private readonly ScopeTagResolver $scopeTagResolver, ) {} @@ -80,13 +82,29 @@ public function enrichWithAssignments( // Extract group IDs and resolve for orphan detection $groupIds = $this->extractGroupIds($assignments); + $resolvedGroups = []; $hasOrphanedGroups = false; if (! empty($groupIds)) { $resolvedGroups = $this->groupResolver->resolveGroupIds($groupIds, $tenantId, $graphOptions); - $hasOrphanedGroups = collect($resolvedGroups)->contains('orphaned', true); + $hasOrphanedGroups = collect($resolvedGroups) + ->contains(fn (array $group) => $group['orphaned'] ?? false); } + $filterIds = collect($assignments) + ->pluck('target.deviceAndAppManagementAssignmentFilterId') + ->filter() + ->unique() + ->values() + ->all(); + + $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); + $filterNames = collect($filters) + ->pluck('displayName', 'id') + ->all(); + + $assignments = $this->enrichAssignments($assignments, $resolvedGroups, $filterNames); + // Update backup item with assignments and metadata $metadata['assignment_count'] = count($assignments); $metadata['assignments_fetch_failed'] = false; @@ -135,11 +153,42 @@ private function extractGroupIds(array $assignments): array $target = $assignment['target'] ?? []; $odataType = $target['@odata.type'] ?? ''; - if ($odataType === '#microsoft.graph.groupAssignmentTarget' && isset($target['groupId'])) { + if (in_array($odataType, [ + '#microsoft.graph.groupAssignmentTarget', + '#microsoft.graph.exclusionGroupAssignmentTarget', + ], true) && isset($target['groupId'])) { $groupIds[] = $target['groupId']; } } return array_unique($groupIds); } + + /** + * @param array> $assignments + * @param array $groups + * @param array $filterNames + * @return array> + */ + private function enrichAssignments(array $assignments, array $groups, array $filterNames): array + { + return array_map(function (array $assignment) use ($groups, $filterNames): array { + $target = $assignment['target'] ?? []; + $groupId = $target['groupId'] ?? null; + + if ($groupId && isset($groups[$groupId])) { + $target['group_display_name'] = $groups[$groupId]['displayName'] ?? null; + $target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false; + } + + $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; + if ($filterId && isset($filterNames[$filterId])) { + $target['assignment_filter_name'] = $filterNames[$filterId]; + } + + $assignment['target'] = $target; + + return $assignment; + }, $assignments); + } } diff --git a/app/Services/Graph/AssignmentFilterResolver.php b/app/Services/Graph/AssignmentFilterResolver.php new file mode 100644 index 0000000..ab7b0eb --- /dev/null +++ b/app/Services/Graph/AssignmentFilterResolver.php @@ -0,0 +1,54 @@ + $filterIds + * @return array + */ + public function resolve(array $filterIds, ?Tenant $tenant = null): array + { + if (empty($filterIds)) { + return []; + } + + $allFilters = $this->fetchAllFilters($tenant); + + return array_values(array_filter($allFilters, function (array $filter) use ($filterIds): bool { + return in_array($filter['id'] ?? null, $filterIds, true); + })); + } + + /** + * @return array + */ + private function fetchAllFilters(?Tenant $tenant = null): array + { + $cacheKey = $tenant ? "assignment_filters:tenant:{$tenant->id}" : 'assignment_filters:all'; + + return Cache::remember($cacheKey, 3600, function () use ($tenant): array { + $options = ['query' => ['$select' => 'id,displayName']]; + + if ($tenant) { + $options = array_merge($options, $tenant->graphOptions()); + } + + $response = $this->graphClient->request( + 'GET', + '/deviceManagement/assignmentFilters', + $options + ); + + return $response->data['value'] ?? []; + }); + } +} diff --git a/app/Services/Intune/PolicyCaptureOrchestrator.php b/app/Services/Intune/PolicyCaptureOrchestrator.php index d4d10ba..0ac4573 100644 --- a/app/Services/Intune/PolicyCaptureOrchestrator.php +++ b/app/Services/Intune/PolicyCaptureOrchestrator.php @@ -6,6 +6,7 @@ use App\Models\PolicyVersion; use App\Models\Tenant; use App\Services\Graph\AssignmentFetcher; +use App\Services\Graph\AssignmentFilterResolver; use App\Services\Graph\GroupResolver; use App\Services\Graph\ScopeTagResolver; use Illuminate\Support\Facades\Log; @@ -22,6 +23,7 @@ public function __construct( private readonly PolicySnapshotService $snapshotService, private readonly AssignmentFetcher $assignmentFetcher, private readonly GroupResolver $groupResolver, + private readonly AssignmentFilterResolver $assignmentFilterResolver, private readonly ScopeTagResolver $scopeTagResolver, ) {} @@ -59,7 +61,7 @@ public function capture( $rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions); if (! empty($rawAssignments)) { - $assignments = $rawAssignments; + $resolvedGroups = []; // Resolve groups for orphaned detection $groupIds = collect($rawAssignments) @@ -75,6 +77,19 @@ public function capture( ->contains(fn (array $group) => $group['orphaned'] ?? false); } + $filterIds = collect($rawAssignments) + ->pluck('target.deviceAndAppManagementAssignmentFilterId') + ->filter() + ->unique() + ->values() + ->all(); + + $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); + $filterNames = collect($filters) + ->pluck('displayName', 'id') + ->all(); + + $assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames); $captureMetadata['assignments_count'] = count($rawAssignments); } } catch (\Throwable $e) { @@ -230,7 +245,7 @@ public function ensureVersionHasAssignments( $rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions); if (! empty($rawAssignments)) { - $assignments = $rawAssignments; + $resolvedGroups = []; // Resolve groups $groupIds = collect($rawAssignments) @@ -246,6 +261,19 @@ public function ensureVersionHasAssignments( ->contains(fn (array $group) => $group['orphaned'] ?? false); } + $filterIds = collect($rawAssignments) + ->pluck('target.deviceAndAppManagementAssignmentFilterId') + ->filter() + ->unique() + ->values() + ->all(); + + $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); + $filterNames = collect($filters) + ->pluck('displayName', 'id') + ->all(); + + $assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames); $metadata['assignments_count'] = count($rawAssignments); } } catch (\Throwable $e) { @@ -291,6 +319,34 @@ public function ensureVersionHasAssignments( return $version->refresh(); } + /** + * @param array> $assignments + * @param array $groups + * @param array $filterNames + * @return array> + */ + private function enrichAssignments(array $assignments, array $groups, array $filterNames): array + { + return array_map(function (array $assignment) use ($groups, $filterNames): array { + $target = $assignment['target'] ?? []; + $groupId = $target['groupId'] ?? null; + + if ($groupId && isset($groups[$groupId])) { + $target['group_display_name'] = $groups[$groupId]['displayName'] ?? null; + $target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false; + } + + $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; + if ($filterId && isset($filterNames[$filterId])) { + $target['assignment_filter_name'] = $filterNames[$filterId]; + } + + $assignment['target'] = $target; + + return $assignment; + }, $assignments); + } + /** * @param array $scopeTagIds * @return array{ids:array,names:array} diff --git a/app/Services/Intune/VersionService.php b/app/Services/Intune/VersionService.php index 1aad5bc..3d4f4da 100644 --- a/app/Services/Intune/VersionService.php +++ b/app/Services/Intune/VersionService.php @@ -6,6 +6,7 @@ use App\Models\PolicyVersion; use App\Models\Tenant; use App\Services\Graph\AssignmentFetcher; +use App\Services\Graph\AssignmentFilterResolver; use App\Services\Graph\GroupResolver; use App\Services\Graph\ScopeTagResolver; use Carbon\CarbonImmutable; @@ -17,6 +18,7 @@ public function __construct( private readonly PolicySnapshotService $snapshotService, private readonly AssignmentFetcher $assignmentFetcher, private readonly GroupResolver $groupResolver, + private readonly AssignmentFilterResolver $assignmentFilterResolver, private readonly ScopeTagResolver $scopeTagResolver, ) {} @@ -92,7 +94,7 @@ public function captureFromGraph( $rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions); if (! empty($rawAssignments)) { - $assignments = $rawAssignments; + $resolvedGroups = []; // Resolve groups $groupIds = collect($rawAssignments) @@ -102,11 +104,27 @@ public function captureFromGraph( ->values() ->toArray(); - $resolvedGroups = $this->groupResolver->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions); + if (! empty($groupIds)) { + $resolvedGroups = $this->groupResolver->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions); + } $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(); + + $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); + $filterNames = collect($filters) + ->pluck('displayName', 'id') + ->all(); + + $assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames); } } catch (\Throwable $e) { $assignmentMetadata['assignments_fetch_failed'] = true; @@ -135,6 +153,34 @@ public function captureFromGraph( ); } + /** + * @param array> $assignments + * @param array $groups + * @param array $filterNames + * @return array> + */ + private function enrichAssignments(array $assignments, array $groups, array $filterNames): array + { + return array_map(function (array $assignment) use ($groups, $filterNames): array { + $target = $assignment['target'] ?? []; + $groupId = $target['groupId'] ?? null; + + if ($groupId && isset($groups[$groupId])) { + $target['group_display_name'] = $groups[$groupId]['displayName'] ?? null; + $target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false; + } + + $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; + if ($filterId && isset($filterNames[$filterId])) { + $target['assignment_filter_name'] = $filterNames[$filterId]; + } + + $assignment['target'] = $target; + + return $assignment; + }, $assignments); + } + /** * @param array $scopeTagIds * @return array{ids:array,names:array} diff --git a/resources/views/livewire/policy-version-assignments-widget.blade.php b/resources/views/livewire/policy-version-assignments-widget.blade.php index 44ecdeb..59c3e31 100644 --- a/resources/views/livewire/policy-version-assignments-widget.blade.php +++ b/resources/views/livewire/policy-version-assignments-widget.blade.php @@ -57,14 +57,19 @@ $intent = $assignment['intent'] ?? 'apply'; $typeName = match($type) { - '#microsoft.graph.groupAssignmentTarget' => 'Group', + '#microsoft.graph.groupAssignmentTarget' => 'Include group', + '#microsoft.graph.exclusionGroupAssignmentTarget' => 'Exclude group', '#microsoft.graph.allLicensedUsersAssignmentTarget' => 'All Users', '#microsoft.graph.allDevicesAssignmentTarget' => 'All Devices', default => 'Unknown' }; $groupId = $target['groupId'] ?? null; - $hasOrphaned = $version->metadata['has_orphaned_assignments'] ?? false; + $groupName = $target['group_display_name'] ?? null; + $groupOrphaned = $target['group_orphaned'] ?? ($version->metadata['has_orphaned_assignments'] ?? false); + $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; + $filterType = $target['deviceAndAppManagementAssignmentFilterType'] ?? 'none'; + $filterName = $target['assignment_filter_name'] ?? null; @endphp
@@ -73,9 +78,16 @@ @if($groupId) : - @if($hasOrphaned) + @if($groupOrphaned) - ⚠️ Unknown Group (ID: {{ $groupId }}) + ⚠️ Unknown group (ID: {{ $groupId }}) + + @elseif($groupName) + + {{ $groupName }} + + + ({{ $groupId }}) @else @@ -83,6 +95,12 @@ @endif @endif + + @if($filterId && $filterType !== 'none') + + Filter ({{ $filterType }}): {{ $filterName ?? $filterId }} + + @endif ({{ $intent }})
@@ -115,4 +133,3 @@ @endif - diff --git a/tests/Feature/PolicyVersionViewAssignmentsTest.php b/tests/Feature/PolicyVersionViewAssignmentsTest.php index a6b1779..eb977a6 100644 --- a/tests/Feature/PolicyVersionViewAssignmentsTest.php +++ b/tests/Feature/PolicyVersionViewAssignmentsTest.php @@ -39,6 +39,19 @@ 'target' => [ '@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-123', + 'assignment_filter_name' => 'Targeted Devices', + 'deviceAndAppManagementAssignmentFilterId' => 'filter-1', + 'deviceAndAppManagementAssignmentFilterType' => 'include', + ], + ], + [ + 'id' => 'assignment-2', + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.exclusionGroupAssignmentTarget', + 'groupId' => 'group-456', + 'deviceAndAppManagementAssignmentFilterId' => null, + 'deviceAndAppManagementAssignmentFilterType' => 'none', ], ], ], @@ -54,7 +67,10 @@ $response->assertOk(); $response->assertSeeLivewire('policy-version-assignments-widget'); - $response->assertSee('1 assignment(s)'); + $response->assertSee('2 assignment(s)'); + $response->assertSee('Include group'); + $response->assertSee('Exclude group'); + $response->assertSee('Filter (include): Targeted Devices'); }); it('displays empty state when version has no assignments', function () { diff --git a/tests/Feature/VersionCaptureWithAssignmentsTest.php b/tests/Feature/VersionCaptureWithAssignmentsTest.php index 9b0ef51..459ef23 100644 --- a/tests/Feature/VersionCaptureWithAssignmentsTest.php +++ b/tests/Feature/VersionCaptureWithAssignmentsTest.php @@ -3,6 +3,7 @@ use App\Models\Policy; use App\Models\Tenant; use App\Services\Graph\AssignmentFetcher; +use App\Services\Graph\AssignmentFilterResolver; use App\Services\Graph\GroupResolver; use App\Services\Graph\ScopeTagResolver; use App\Services\Intune\PolicySnapshotService; @@ -47,6 +48,8 @@ 'target' => [ '@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-123', + 'deviceAndAppManagementAssignmentFilterId' => 'filter-123', + 'deviceAndAppManagementAssignmentFilterType' => 'include', ], ], ]); @@ -64,6 +67,14 @@ ]); }); + $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, @@ -82,6 +93,9 @@ 'ids' => ['0'], 'names' => ['Default'], ]); + + expect($version->assignments[0]['target']['group_display_name'])->toBe('Test Group'); + expect($version->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices'); }); it('captures policy version without assignments when none exist', function () { diff --git a/tests/Unit/AssignmentFilterResolverTest.php b/tests/Unit/AssignmentFilterResolverTest.php new file mode 100644 index 0000000..dc6888e --- /dev/null +++ b/tests/Unit/AssignmentFilterResolverTest.php @@ -0,0 +1,65 @@ +graphClient = Mockery::mock(MicrosoftGraphClient::class); + $this->resolver = new AssignmentFilterResolver($this->graphClient); +}); + +test('resolves assignment filters by id', function () { + $filters = [ + ['id' => 'filter-1', 'displayName' => 'Targeted Devices'], + ['id' => 'filter-2', 'displayName' => 'Excluded Devices'], + ]; + + $response = new GraphResponse( + success: true, + data: ['value' => $filters] + ); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('GET', '/deviceManagement/assignmentFilters', [ + 'query' => [ + '$select' => 'id,displayName', + ], + ]) + ->andReturn($response); + + $result = $this->resolver->resolve(['filter-1']); + + expect($result)->toHaveCount(1) + ->and($result[0]['id'])->toBe('filter-1') + ->and($result[0]['displayName'])->toBe('Targeted Devices'); +}); + +test('uses cache for repeated lookups', function () { + $filters = [ + ['id' => 'filter-1', 'displayName' => 'Targeted Devices'], + ]; + + $response = new GraphResponse( + success: true, + data: ['value' => $filters] + ); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->andReturn($response); + + $result1 = $this->resolver->resolve(['filter-1']); + $result2 = $this->resolver->resolve(['filter-1']); + + expect($result1)->toBe($result2); +});