fix(004): use assign action for assignment restore

This commit is contained in:
Ahmed Darrazi 2025-12-23 11:20:35 +01:00
parent df4b0efc9b
commit 3eaa99e3f3
7 changed files with 227 additions and 102 deletions

View File

@ -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,
],

View File

@ -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',

View File

@ -42,9 +42,14 @@
</span>
</div>
@if (! empty($item['reason']))
@php
$itemReason = $item['reason'] ?? null;
$itemGraphMessage = $item['graph_error_message'] ?? null;
@endphp
@if (! empty($itemReason) && ($itemGraphMessage === null || $itemGraphMessage !== $itemReason))
<div class="mt-2 text-sm text-gray-800">
{{ $item['reason'] }}
{{ $itemReason }}
</div>
@endif
@ -95,9 +100,14 @@
</div>
@endif
@if (! empty($outcome['reason']))
@php
$outcomeReason = $outcome['reason'] ?? null;
$outcomeGraphMessage = $outcome['graph_error_message'] ?? null;
@endphp
@if (! empty($outcomeReason) && ($outcomeGraphMessage === null || $outcomeGraphMessage !== $outcomeReason))
<div class="mt-1 text-[11px] text-gray-800">
{{ $outcome['reason'] }}
{{ $outcomeReason }}
</div>
@endif

View File

@ -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
<div class="flex items-center gap-2 text-sm">
@ -96,9 +99,9 @@
@endif
@endif
@if($filterId && $filterType !== 'none')
@if($filterLabel)
<span class="text-xs text-gray-500 dark:text-gray-500">
Filter ({{ $filterType }}): {{ $filterName ?? $filterId }}
Filter{{ $filterType !== 'none' ? " ({$filterType})" : '' }}: {{ $filterLabel }}
</span>
@endif

View File

@ -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',

View File

@ -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

View File

@ -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');
});