merge: agent session work

This commit is contained in:
Ahmed Darrazi 2025-12-23 00:09:17 +01:00
commit 0f8ecfe470
8 changed files with 329 additions and 12 deletions

View File

@ -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<int, array<string, mixed>> $assignments
* @param array<string, array{id:string,displayName:?string,orphaned:bool}> $groups
* @param array<string, string> $filterNames
* @return array<int, array<string, mixed>>
*/
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);
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Services\Graph;
use App\Models\Tenant;
use Illuminate\Support\Facades\Cache;
class AssignmentFilterResolver
{
public function __construct(
private readonly MicrosoftGraphClient $graphClient,
) {}
/**
* @param array<int, string> $filterIds
* @return array<int, array{id:string,displayName:?string}>
*/
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<int, array{id:string,displayName:?string}>
*/
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'] ?? [];
});
}
}

View File

@ -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<int, array<string, mixed>> $assignments
* @param array<string, array{id:string,displayName:?string,orphaned:bool}> $groups
* @param array<string, string> $filterNames
* @return array<int, array<string, mixed>>
*/
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<int, string> $scopeTagIds
* @return array{ids:array<int, string>,names:array<int, string>}

View File

@ -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();
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<int, array<string, mixed>> $assignments
* @param array<string, array{id:string,displayName:?string,orphaned:bool}> $groups
* @param array<string, string> $filterNames
* @return array<int, array<string, mixed>>
*/
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<int, string> $scopeTagIds
* @return array{ids:array<int, string>,names:array<int, string>}

View File

@ -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
<div class="flex items-center gap-2 text-sm">
@ -73,9 +78,16 @@
@if($groupId)
<span class="text-gray-600 dark:text-gray-400">:</span>
@if($hasOrphaned)
@if($groupOrphaned)
<span class="text-warning-600 dark:text-warning-400">
⚠️ Unknown Group (ID: {{ $groupId }})
⚠️ Unknown group (ID: {{ $groupId }})
</span>
@elseif($groupName)
<span class="text-gray-700 dark:text-gray-300">
{{ $groupName }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-500">
({{ $groupId }})
</span>
@else
<span class="text-gray-700 dark:text-gray-300">
@ -84,6 +96,12 @@
@endif
@endif
@if($filterId && $filterType !== 'none')
<span class="text-xs text-gray-500 dark:text-gray-500">
Filter ({{ $filterType }}): {{ $filterName ?? $filterId }}
</span>
@endif
<span class="ml-auto text-xs text-gray-500 dark:text-gray-500">({{ $intent }})</span>
</div>
@endforeach
@ -115,4 +133,3 @@
</div>
@endif
</div>

View File

@ -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 () {

View File

@ -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 () {

View File

@ -0,0 +1,65 @@
<?php
use App\Services\Graph\AssignmentFilterResolver;
use App\Services\Graph\GraphResponse;
use App\Services\Graph\MicrosoftGraphClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Cache::flush();
$this->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);
});