From 3eaa99e3f382babaa5ccb734ed7f3153505c07f2 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Tue, 23 Dec 2025 11:20:35 +0100 Subject: [PATCH] fix(004): use assign action for assignment restore --- app/Services/AssignmentRestoreService.php | 200 ++++++++++++++---- config/graph_contracts.php | 2 +- .../entries/restore-results.blade.php | 18 +- ...olicy-version-assignments-widget.blade.php | 23 +- specs/004-assignments-scope-tags/spec.md | 51 +++-- specs/004-assignments-scope-tags/tasks.md | 10 +- .../RestoreAssignmentApplicationTest.php | 25 ++- 7 files changed, 227 insertions(+), 102 deletions(-) diff --git a/app/Services/AssignmentRestoreService.php b/app/Services/AssignmentRestoreService.php index fcb290f..1505be7 100644 --- a/app/Services/AssignmentRestoreService.php +++ b/app/Services/AssignmentRestoreService.php @@ -51,12 +51,13 @@ public function restore( } $contract = $this->contracts->get($policyType); - $listPath = $this->resolvePath($contract['assignments_list_path'] ?? null, $policyId); - $deletePathTemplate = $contract['assignments_delete_path'] ?? null; $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'); + $listPath = $this->resolvePath($contract['assignments_list_path'] ?? null, $policyId); + $deletePathTemplate = $contract['assignments_delete_path'] ?? null; - if (! $listPath || ! $createPath || ! $deletePathTemplate) { + if (! $createPath || (! $usesAssignAction && (! $listPath || ! $deletePathTemplate))) { $outcomes[] = $this->failureOutcome(null, 'Assignments endpoints are not configured for this policy type.'); $summary['failed']++; @@ -76,6 +77,138 @@ public function restore( 'restore_run_id' => $restoreRun?->id, ]; + $preparedAssignments = []; + $preparedMeta = []; + + foreach ($assignments as $assignment) { + if (! is_array($assignment)) { + continue; + } + + $groupId = $assignment['target']['groupId'] ?? null; + $mappedGroupId = $groupId && isset($groupMapping[$groupId]) ? $groupMapping[$groupId] : null; + + if ($mappedGroupId === 'SKIP') { + $outcomes[] = $this->skipOutcome($assignment, $groupId, $mappedGroupId); + $summary['skipped']++; + $this->logAssignmentOutcome( + status: 'skipped', + tenant: $tenant, + assignment: $assignment, + restoreRun: $restoreRun, + actorEmail: $actorEmail, + actorName: $actorName, + metadata: [ + 'policy_id' => $policyId, + 'policy_type' => $policyType, + 'group_id' => $groupId, + 'mapped_group_id' => $mappedGroupId, + ] + ); + + continue; + } + + $assignmentToRestore = $this->applyGroupMapping($assignment, $mappedGroupId); + $assignmentToRestore = $this->sanitizeAssignment($assignmentToRestore); + + $preparedAssignments[] = $assignmentToRestore; + $preparedMeta[] = [ + 'assignment' => $assignment, + 'group_id' => $groupId, + 'mapped_group_id' => $mappedGroupId, + ]; + } + + if ($preparedAssignments === []) { + return [ + 'outcomes' => $outcomes, + 'summary' => $summary, + ]; + } + + if ($usesAssignAction) { + $this->graphLogger->logRequest('restore_assignments_assign', $context + [ + 'method' => $createMethod, + 'endpoint' => $createPath, + 'assignments' => count($preparedAssignments), + ]); + + $assignResponse = $this->graphClient->request($createMethod, $createPath, [ + 'json' => ['assignments' => $preparedAssignments], + ] + $graphOptions); + + $this->graphLogger->logResponse('restore_assignments_assign', $assignResponse, $context + [ + 'method' => $createMethod, + 'endpoint' => $createPath, + 'assignments' => count($preparedAssignments), + ]); + + if ($assignResponse->successful()) { + foreach ($preparedMeta as $meta) { + $outcomes[] = $this->successOutcome( + $meta['assignment'], + $meta['group_id'], + $meta['mapped_group_id'] + ); + $summary['success']++; + $this->logAssignmentOutcome( + status: 'created', + tenant: $tenant, + assignment: $meta['assignment'], + restoreRun: $restoreRun, + actorEmail: $actorEmail, + actorName: $actorName, + metadata: [ + 'policy_id' => $policyId, + 'policy_type' => $policyType, + 'group_id' => $meta['group_id'], + 'mapped_group_id' => $meta['mapped_group_id'], + ] + ); + } + } else { + $reason = $assignResponse->meta['error_message'] ?? 'Graph assign failed'; + + if ($preparedMeta === []) { + $outcomes[] = $this->failureOutcome(null, $reason, null, null, $assignResponse); + $summary['failed']++; + } + + foreach ($preparedMeta as $meta) { + $outcomes[] = $this->failureOutcome( + $meta['assignment'], + $reason, + $meta['group_id'], + $meta['mapped_group_id'], + $assignResponse + ); + $summary['failed']++; + $this->logAssignmentOutcome( + status: 'failed', + tenant: $tenant, + assignment: $meta['assignment'], + restoreRun: $restoreRun, + actorEmail: $actorEmail, + actorName: $actorName, + metadata: [ + 'policy_id' => $policyId, + 'policy_type' => $policyType, + 'group_id' => $meta['group_id'], + 'mapped_group_id' => $meta['mapped_group_id'], + 'graph_error_message' => $assignResponse->meta['error_message'] ?? null, + 'graph_error_code' => $assignResponse->meta['error_code'] ?? null, + ], + ); + } + } + + return [ + 'outcomes' => $outcomes, + 'summary' => $summary, + ]; + } + $this->graphLogger->logRequest('restore_assignments_list', $context + [ 'method' => 'GET', 'endpoint' => $listPath, @@ -126,43 +259,18 @@ public function restore( } } - foreach ($assignments as $assignment) { - if (! is_array($assignment)) { + foreach ($preparedMeta as $index => $meta) { + $assignmentToRestore = $preparedAssignments[$index] ?? null; + + if (! is_array($assignmentToRestore)) { continue; } - $groupId = $assignment['target']['groupId'] ?? null; - $mappedGroupId = $groupId && isset($groupMapping[$groupId]) ? $groupMapping[$groupId] : null; - - if ($mappedGroupId === 'SKIP') { - $outcomes[] = $this->skipOutcome($assignment, $groupId, $mappedGroupId); - $summary['skipped']++; - $this->logAssignmentOutcome( - status: 'skipped', - tenant: $tenant, - assignment: $assignment, - restoreRun: $restoreRun, - actorEmail: $actorEmail, - actorName: $actorName, - metadata: [ - 'policy_id' => $policyId, - 'policy_type' => $policyType, - 'group_id' => $groupId, - 'mapped_group_id' => $mappedGroupId, - ] - ); - - continue; - } - - $assignmentToRestore = $this->applyGroupMapping($assignment, $mappedGroupId); - $assignmentToRestore = $this->sanitizeAssignment($assignmentToRestore); - $this->graphLogger->logRequest('restore_assignments_create', $context + [ 'method' => $createMethod, 'endpoint' => $createPath, - 'group_id' => $groupId, - 'mapped_group_id' => $mappedGroupId, + 'group_id' => $meta['group_id'], + 'mapped_group_id' => $meta['mapped_group_id'], ]); $createResponse = $this->graphClient->request($createMethod, $createPath, [ @@ -172,48 +280,48 @@ public function restore( $this->graphLogger->logResponse('restore_assignments_create', $createResponse, $context + [ 'method' => $createMethod, 'endpoint' => $createPath, - 'group_id' => $groupId, - 'mapped_group_id' => $mappedGroupId, + 'group_id' => $meta['group_id'], + 'mapped_group_id' => $meta['mapped_group_id'], ]); if ($createResponse->successful()) { - $outcomes[] = $this->successOutcome($assignment, $groupId, $mappedGroupId); + $outcomes[] = $this->successOutcome($meta['assignment'], $meta['group_id'], $meta['mapped_group_id']); $summary['success']++; $this->logAssignmentOutcome( status: 'created', tenant: $tenant, - assignment: $assignment, + assignment: $meta['assignment'], restoreRun: $restoreRun, actorEmail: $actorEmail, actorName: $actorName, metadata: [ 'policy_id' => $policyId, 'policy_type' => $policyType, - 'group_id' => $groupId, - 'mapped_group_id' => $mappedGroupId, + 'group_id' => $meta['group_id'], + 'mapped_group_id' => $meta['mapped_group_id'], ] ); } else { $outcomes[] = $this->failureOutcome( - $assignment, + $meta['assignment'], $createResponse->meta['error_message'] ?? 'Graph create failed', - $groupId, - $mappedGroupId, + $meta['group_id'], + $meta['mapped_group_id'], $createResponse ); $summary['failed']++; $this->logAssignmentOutcome( status: 'failed', tenant: $tenant, - assignment: $assignment, + assignment: $meta['assignment'], restoreRun: $restoreRun, actorEmail: $actorEmail, actorName: $actorName, metadata: [ 'policy_id' => $policyId, 'policy_type' => $policyType, - 'group_id' => $groupId, - 'mapped_group_id' => $mappedGroupId, + 'group_id' => $meta['group_id'], + 'mapped_group_id' => $meta['mapped_group_id'], 'graph_error_message' => $createResponse->meta['error_message'] ?? null, 'graph_error_code' => $createResponse->meta['error_code'] ?? null, ], diff --git a/config/graph_contracts.php b/config/graph_contracts.php index 35e6886..e77eff5 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -73,7 +73,7 @@ // Assignments CRUD (standard Graph pattern) 'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments', - 'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign', 'assignments_create_method' => 'POST', 'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}', 'assignments_update_method' => 'PATCH', diff --git a/resources/views/filament/infolists/entries/restore-results.blade.php b/resources/views/filament/infolists/entries/restore-results.blade.php index c95c264..8c429e3 100644 --- a/resources/views/filament/infolists/entries/restore-results.blade.php +++ b/resources/views/filament/infolists/entries/restore-results.blade.php @@ -42,9 +42,14 @@ - @if (! empty($item['reason'])) + @php + $itemReason = $item['reason'] ?? null; + $itemGraphMessage = $item['graph_error_message'] ?? null; + @endphp + + @if (! empty($itemReason) && ($itemGraphMessage === null || $itemGraphMessage !== $itemReason))
- {{ $item['reason'] }} + {{ $itemReason }}
@endif @@ -95,9 +100,14 @@ @endif - @if (! empty($outcome['reason'])) + @php + $outcomeReason = $outcome['reason'] ?? null; + $outcomeGraphMessage = $outcome['graph_error_message'] ?? null; + @endphp + + @if (! empty($outcomeReason) && ($outcomeGraphMessage === null || $outcomeGraphMessage !== $outcomeReason))
- {{ $outcome['reason'] }} + {{ $outcomeReason }}
@endif diff --git a/resources/views/livewire/policy-version-assignments-widget.blade.php b/resources/views/livewire/policy-version-assignments-widget.blade.php index 59c3e31..fb6238b 100644 --- a/resources/views/livewire/policy-version-assignments-widget.blade.php +++ b/resources/views/livewire/policy-version-assignments-widget.blade.php @@ -53,23 +53,26 @@ @foreach($version->assignments as $assignment) @php $target = $assignment['target'] ?? []; - $type = $target['@odata.type'] ?? 'unknown'; + $type = $target['@odata.type'] ?? ''; + $typeKey = strtolower((string) $type); $intent = $assignment['intent'] ?? 'apply'; - $typeName = match($type) { - '#microsoft.graph.groupAssignmentTarget' => 'Include group', - '#microsoft.graph.exclusionGroupAssignmentTarget' => 'Exclude group', - '#microsoft.graph.allLicensedUsersAssignmentTarget' => 'All Users', - '#microsoft.graph.allDevicesAssignmentTarget' => 'All Devices', - default => 'Unknown' + $typeName = match (true) { + str_contains($typeKey, 'exclusiongroupassignmenttarget') => 'Exclude group', + str_contains($typeKey, 'groupassignmenttarget') => 'Include group', + str_contains($typeKey, 'alllicensedusersassignmenttarget') => 'All Users', + str_contains($typeKey, 'alldevicesassignmenttarget') => 'All Devices', + default => 'Unknown', }; $groupId = $target['groupId'] ?? null; $groupName = $target['group_display_name'] ?? null; $groupOrphaned = $target['group_orphaned'] ?? ($version->metadata['has_orphaned_assignments'] ?? false); $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; - $filterType = $target['deviceAndAppManagementAssignmentFilterType'] ?? 'none'; + $filterTypeRaw = strtolower((string) ($target['deviceAndAppManagementAssignmentFilterType'] ?? 'none')); + $filterType = $filterTypeRaw !== '' ? $filterTypeRaw : 'none'; $filterName = $target['assignment_filter_name'] ?? null; + $filterLabel = $filterName ?? $filterId; @endphp
@@ -96,9 +99,9 @@ @endif @endif - @if($filterId && $filterType !== 'none') + @if($filterLabel) - Filter ({{ $filterType }}): {{ $filterName ?? $filterId }} + Filter{{ $filterType !== 'none' ? " ({$filterType})" : '' }}: {{ $filterLabel }} @endif diff --git a/specs/004-assignments-scope-tags/spec.md b/specs/004-assignments-scope-tags/spec.md index 7229547..2011a3c 100644 --- a/specs/004-assignments-scope-tags/spec.md +++ b/specs/004-assignments-scope-tags/spec.md @@ -28,7 +28,8 @@ ## Scope - **Policy Types**: `settingsCatalogPolicy` only (initially) - **Graph Endpoints**: - GET `/deviceManagement/configurationPolicies/{id}/assignments` - - POST/PATCH `/deviceManagement/configurationPolicies/{id}/assignments` + - POST `/deviceManagement/configurationPolicies/{id}/assign` (assign action, replaces assignments) + - DELETE `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}` (fallback) - GET `/deviceManagement/roleScopeTags` (for reference data) - GET `/deviceManagement/assignmentFilters` (for filter names) - **Backup Behavior**: Optional at capture time with separate checkboxes ("Include assignments", "Include scope tags") on Add Policies and Capture Snapshot actions (defaults: true) @@ -190,10 +191,12 @@ ### Restore with Group Mapping 1. Replace source group IDs with mapped target group IDs in assignment objects 2. Skip assignments marked "Skip" in group mapping 3. Preserve include/exclude intent and filters -4. Execute restore via DELETE-then-CREATE pattern: - - Step 1: GET existing assignments from target policy - - Step 2: DELETE each existing assignment (via DELETE `/assignments/{id}`) - - Step 3: POST each new/mapped assignment (via POST `/assignments`) +4. Execute restore via assign action when supported: + - Step 1: POST `/assign` with `{ assignments: [...] }` to replace assignments + - Step 2 (fallback): If `/assign` is unsupported, use DELETE-then-CREATE: + - GET existing assignments from target policy + - DELETE each existing assignment (via DELETE `/assignments/{id}`) + - POST each new/mapped assignment (via POST `/assignments`) 5. Handle failures gracefully: - 204 No Content on DELETE = success - 201 Created on POST = success @@ -316,30 +319,32 @@ ### Endpoints to Add (Production-Tested Strategies) - Client-side filter to extract assignments - **Reason**: Known Graph API quirks with assignment expansion on certain template families -2. **Assignment CRUD Operations** (Standard Graph Pattern) +2. **Assignment Apply** (Assign action + fallback) - - **POST** `/deviceManagement/configurationPolicies/{id}/assignments` - - Body: Single assignment object - - Returns: 201 Created with assignment object + - **POST** `/deviceManagement/configurationPolicies/{id}/assign` + - Body: `{ "assignments": [ ... ] }` + - Returns: 200/204 on success (no per-assignment IDs) - Example: ```json { - "target": { - "@odata.type": "#microsoft.graph.groupAssignmentTarget", - "groupId": "abc-123-def" - }, - "intent": "apply" + "assignments": [ + { + "target": { + "@odata.type": "#microsoft.graph.groupAssignmentTarget", + "groupId": "abc-123-def" + }, + "intent": "apply" + } + ] } ``` + + - **Fallback** (when `/assign` is unsupported): + - **GET** `/deviceManagement/configurationPolicies/{id}/assignments` + - **DELETE** `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}` + - **POST** `/deviceManagement/configurationPolicies/{id}/assignments` (single assignment object) - - **PATCH** `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}` - - Body: Assignment object (partial update) - - Returns: 200 OK with updated assignment - - - **DELETE** `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}` - - Returns: 204 No Content - - - **Restore Strategy**: DELETE all existing assignments, then POST new ones (best-effort; record per-assignment outcomes, no transactional rollback) + - **Restore Strategy**: Prefer `/assign`; if unsupported, delete existing assignments then POST new ones (best-effort; record outcomes, no transactional rollback). 3. **POST** `/directoryObjects/getByIds` (Stable Group Resolution) - Body: `{ "ids": ["id1", "id2"], "types": ["group"] }` @@ -372,7 +377,7 @@ ### Graph Contract Updates // Assignments CRUD (standard Graph pattern) 'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments', - 'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign', 'assignments_create_method' => 'POST', 'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}', 'assignments_update_method' => 'PATCH', diff --git a/specs/004-assignments-scope-tags/tasks.md b/specs/004-assignments-scope-tags/tasks.md index 1229786..e464608 100644 --- a/specs/004-assignments-scope-tags/tasks.md +++ b/specs/004-assignments-scope-tags/tasks.md @@ -77,7 +77,7 @@ ### Tasks **1.9** [X] ⭐ Update `config/graph_contracts.php` with assignments endpoints - Add `assignments_list_path` (GET) -- Add `assignments_create_path` (POST) +- Add `assignments_create_path` (POST `/assign` for settingsCatalogPolicy) - Add `assignments_delete_path` (DELETE) - Add `supports_scope_tags: true` - Add `scope_tag_field: 'roleScopeTagIds'` @@ -351,15 +351,15 @@ ### Tasks **5.8** Create service: `AssignmentRestoreService` - File: `app/Services/AssignmentRestoreService.php` - Method: `restore(string $policyId, array $assignments, array $groupMapping): array` -- Implement DELETE-then-CREATE pattern +- Prefer `/assign` action when supported; fallback to DELETE-then-CREATE pattern -**5.9** Implement DELETE existing assignments +**5.9** Implement DELETE existing assignments (fallback) - Step 1: GET `/assignments` for target policy - Step 2: Loop and DELETE each assignment - Handle 204 No Content (success) - Log warnings on failure, continue -**5.10** Implement CREATE new assignments with mapping +**5.10** Implement CREATE new assignments with mapping (fallback) - Step 3: Loop through source assignments - Apply group mapping: replace source group IDs with target IDs - Skip assignments marked `"SKIP"` in mapping @@ -367,7 +367,7 @@ ### Tasks - Handle 201 Created (success) - Log per-assignment outcome -**5.11** Add rate limit protection +**5.11** Add rate limit protection (fallback only) - Add 100ms delay between sequential POST calls: `usleep(100000)` - Log request IDs for failed calls diff --git a/tests/Feature/RestoreAssignmentApplicationTest.php b/tests/Feature/RestoreAssignmentApplicationTest.php index b8d529b..f2a2b4b 100644 --- a/tests/Feature/RestoreAssignmentApplicationTest.php +++ b/tests/Feature/RestoreAssignmentApplicationTest.php @@ -67,10 +67,7 @@ public function request(string $method, string $path, array $options = []): Grap test('restore applies assignments with mapped groups', function () { $applyResponse = new GraphResponse(true, []); $requestResponses = [ - new GraphResponse(true, ['value' => [['id' => 'assign-old-1']]]), // list - new GraphResponse(true, [], 204), // delete - new GraphResponse(true, ['id' => 'assign-new-1'], 201), // create 1 - new GraphResponse(true, ['id' => 'assign-new-2'], 201), // create 2 + new GraphResponse(true, []), // assign action ]; $client = new RestoreAssignmentGraphClient($applyResponse, $requestResponses); @@ -153,23 +150,25 @@ public function request(string $method, string $path, array $options = []): Grap ->filter(fn (array $call) => $call['method'] === 'POST') ->values(); - expect($postCalls)->toHaveCount(2); - expect($postCalls[0]['payload']['target']['groupId'])->toBe('target-group-1'); - expect($postCalls[0]['payload'])->not->toHaveKey('id'); + expect($postCalls)->toHaveCount(1); + expect($postCalls[0]['path'])->toBe('/deviceManagement/configurationPolicies/scp-1/assign'); + + $payloadAssignments = $postCalls[0]['payload']['assignments'] ?? []; + $groupIds = collect($payloadAssignments)->pluck('target.groupId')->all(); + + expect($groupIds)->toBe(['target-group-1', 'target-group-2']); + expect($payloadAssignments[0])->not->toHaveKey('id'); }); test('restore handles assignment failures gracefully', function () { $applyResponse = new GraphResponse(true, []); $requestResponses = [ - new GraphResponse(true, ['value' => [['id' => 'assign-old-1']]]), // list - new GraphResponse(true, [], 204), // delete - new GraphResponse(true, ['id' => 'assign-new-1'], 201), // create 1 new GraphResponse(false, ['error' => ['message' => 'Bad request']], 400, [ ['code' => 'BadRequest', 'message' => 'Bad request'], ], [], [ 'error_code' => 'BadRequest', 'error_message' => 'Bad request', - ]), // create 2 fails + ]), // assign action fails ]; $client = new RestoreAssignmentGraphClient($applyResponse, $requestResponses); @@ -244,7 +243,7 @@ public function request(string $method, string $path, array $options = []): Grap $summary = $run->results[0]['assignment_summary'] ?? null; expect($summary)->not->toBeNull(); - expect($summary['success'])->toBe(1); - expect($summary['failed'])->toBe(1); + expect($summary['success'])->toBe(0); + expect($summary['failed'])->toBe(2); expect($run->results[0]['status'])->toBe('partial'); });