merge: agent session work
This commit is contained in:
commit
92bf7af017
@ -51,12 +51,13 @@ public function restore(
|
|||||||
}
|
}
|
||||||
|
|
||||||
$contract = $this->contracts->get($policyType);
|
$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);
|
$createPath = $this->resolvePath($contract['assignments_create_path'] ?? null, $policyId);
|
||||||
$createMethod = strtoupper((string) ($contract['assignments_create_method'] ?? 'POST'));
|
$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.');
|
$outcomes[] = $this->failureOutcome(null, 'Assignments endpoints are not configured for this policy type.');
|
||||||
$summary['failed']++;
|
$summary['failed']++;
|
||||||
|
|
||||||
@ -76,6 +77,138 @@ public function restore(
|
|||||||
'restore_run_id' => $restoreRun?->id,
|
'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 + [
|
$this->graphLogger->logRequest('restore_assignments_list', $context + [
|
||||||
'method' => 'GET',
|
'method' => 'GET',
|
||||||
'endpoint' => $listPath,
|
'endpoint' => $listPath,
|
||||||
@ -126,43 +259,18 @@ public function restore(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($assignments as $assignment) {
|
foreach ($preparedMeta as $index => $meta) {
|
||||||
if (! is_array($assignment)) {
|
$assignmentToRestore = $preparedAssignments[$index] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($assignmentToRestore)) {
|
||||||
continue;
|
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 + [
|
$this->graphLogger->logRequest('restore_assignments_create', $context + [
|
||||||
'method' => $createMethod,
|
'method' => $createMethod,
|
||||||
'endpoint' => $createPath,
|
'endpoint' => $createPath,
|
||||||
'group_id' => $groupId,
|
'group_id' => $meta['group_id'],
|
||||||
'mapped_group_id' => $mappedGroupId,
|
'mapped_group_id' => $meta['mapped_group_id'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$createResponse = $this->graphClient->request($createMethod, $createPath, [
|
$createResponse = $this->graphClient->request($createMethod, $createPath, [
|
||||||
@ -172,48 +280,48 @@ public function restore(
|
|||||||
$this->graphLogger->logResponse('restore_assignments_create', $createResponse, $context + [
|
$this->graphLogger->logResponse('restore_assignments_create', $createResponse, $context + [
|
||||||
'method' => $createMethod,
|
'method' => $createMethod,
|
||||||
'endpoint' => $createPath,
|
'endpoint' => $createPath,
|
||||||
'group_id' => $groupId,
|
'group_id' => $meta['group_id'],
|
||||||
'mapped_group_id' => $mappedGroupId,
|
'mapped_group_id' => $meta['mapped_group_id'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($createResponse->successful()) {
|
if ($createResponse->successful()) {
|
||||||
$outcomes[] = $this->successOutcome($assignment, $groupId, $mappedGroupId);
|
$outcomes[] = $this->successOutcome($meta['assignment'], $meta['group_id'], $meta['mapped_group_id']);
|
||||||
$summary['success']++;
|
$summary['success']++;
|
||||||
$this->logAssignmentOutcome(
|
$this->logAssignmentOutcome(
|
||||||
status: 'created',
|
status: 'created',
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
assignment: $assignment,
|
assignment: $meta['assignment'],
|
||||||
restoreRun: $restoreRun,
|
restoreRun: $restoreRun,
|
||||||
actorEmail: $actorEmail,
|
actorEmail: $actorEmail,
|
||||||
actorName: $actorName,
|
actorName: $actorName,
|
||||||
metadata: [
|
metadata: [
|
||||||
'policy_id' => $policyId,
|
'policy_id' => $policyId,
|
||||||
'policy_type' => $policyType,
|
'policy_type' => $policyType,
|
||||||
'group_id' => $groupId,
|
'group_id' => $meta['group_id'],
|
||||||
'mapped_group_id' => $mappedGroupId,
|
'mapped_group_id' => $meta['mapped_group_id'],
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
$outcomes[] = $this->failureOutcome(
|
$outcomes[] = $this->failureOutcome(
|
||||||
$assignment,
|
$meta['assignment'],
|
||||||
$createResponse->meta['error_message'] ?? 'Graph create failed',
|
$createResponse->meta['error_message'] ?? 'Graph create failed',
|
||||||
$groupId,
|
$meta['group_id'],
|
||||||
$mappedGroupId,
|
$meta['mapped_group_id'],
|
||||||
$createResponse
|
$createResponse
|
||||||
);
|
);
|
||||||
$summary['failed']++;
|
$summary['failed']++;
|
||||||
$this->logAssignmentOutcome(
|
$this->logAssignmentOutcome(
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
assignment: $assignment,
|
assignment: $meta['assignment'],
|
||||||
restoreRun: $restoreRun,
|
restoreRun: $restoreRun,
|
||||||
actorEmail: $actorEmail,
|
actorEmail: $actorEmail,
|
||||||
actorName: $actorName,
|
actorName: $actorName,
|
||||||
metadata: [
|
metadata: [
|
||||||
'policy_id' => $policyId,
|
'policy_id' => $policyId,
|
||||||
'policy_type' => $policyType,
|
'policy_type' => $policyType,
|
||||||
'group_id' => $groupId,
|
'group_id' => $meta['group_id'],
|
||||||
'mapped_group_id' => $mappedGroupId,
|
'mapped_group_id' => $meta['mapped_group_id'],
|
||||||
'graph_error_message' => $createResponse->meta['error_message'] ?? null,
|
'graph_error_message' => $createResponse->meta['error_message'] ?? null,
|
||||||
'graph_error_code' => $createResponse->meta['error_code'] ?? null,
|
'graph_error_code' => $createResponse->meta['error_code'] ?? null,
|
||||||
],
|
],
|
||||||
|
|||||||
@ -73,7 +73,7 @@
|
|||||||
|
|
||||||
// Assignments CRUD (standard Graph pattern)
|
// Assignments CRUD (standard Graph pattern)
|
||||||
'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments',
|
'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_create_method' => 'POST',
|
||||||
'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
|
'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
|
||||||
'assignments_update_method' => 'PATCH',
|
'assignments_update_method' => 'PATCH',
|
||||||
|
|||||||
@ -42,9 +42,14 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div class="mt-2 text-sm text-gray-800">
|
||||||
{{ $item['reason'] }}
|
{{ $itemReason }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@ -95,9 +100,14 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@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">
|
<div class="mt-1 text-[11px] text-gray-800">
|
||||||
{{ $outcome['reason'] }}
|
{{ $outcomeReason }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|||||||
@ -53,23 +53,26 @@
|
|||||||
@foreach($version->assignments as $assignment)
|
@foreach($version->assignments as $assignment)
|
||||||
@php
|
@php
|
||||||
$target = $assignment['target'] ?? [];
|
$target = $assignment['target'] ?? [];
|
||||||
$type = $target['@odata.type'] ?? 'unknown';
|
$type = $target['@odata.type'] ?? '';
|
||||||
|
$typeKey = strtolower((string) $type);
|
||||||
$intent = $assignment['intent'] ?? 'apply';
|
$intent = $assignment['intent'] ?? 'apply';
|
||||||
|
|
||||||
$typeName = match($type) {
|
$typeName = match (true) {
|
||||||
'#microsoft.graph.groupAssignmentTarget' => 'Include group',
|
str_contains($typeKey, 'exclusiongroupassignmenttarget') => 'Exclude group',
|
||||||
'#microsoft.graph.exclusionGroupAssignmentTarget' => 'Exclude group',
|
str_contains($typeKey, 'groupassignmenttarget') => 'Include group',
|
||||||
'#microsoft.graph.allLicensedUsersAssignmentTarget' => 'All Users',
|
str_contains($typeKey, 'alllicensedusersassignmenttarget') => 'All Users',
|
||||||
'#microsoft.graph.allDevicesAssignmentTarget' => 'All Devices',
|
str_contains($typeKey, 'alldevicesassignmenttarget') => 'All Devices',
|
||||||
default => 'Unknown'
|
default => 'Unknown',
|
||||||
};
|
};
|
||||||
|
|
||||||
$groupId = $target['groupId'] ?? null;
|
$groupId = $target['groupId'] ?? null;
|
||||||
$groupName = $target['group_display_name'] ?? null;
|
$groupName = $target['group_display_name'] ?? null;
|
||||||
$groupOrphaned = $target['group_orphaned'] ?? ($version->metadata['has_orphaned_assignments'] ?? false);
|
$groupOrphaned = $target['group_orphaned'] ?? ($version->metadata['has_orphaned_assignments'] ?? false);
|
||||||
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null;
|
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null;
|
||||||
$filterType = $target['deviceAndAppManagementAssignmentFilterType'] ?? 'none';
|
$filterTypeRaw = strtolower((string) ($target['deviceAndAppManagementAssignmentFilterType'] ?? 'none'));
|
||||||
|
$filterType = $filterTypeRaw !== '' ? $filterTypeRaw : 'none';
|
||||||
$filterName = $target['assignment_filter_name'] ?? null;
|
$filterName = $target['assignment_filter_name'] ?? null;
|
||||||
|
$filterLabel = $filterName ?? $filterId;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<div class="flex items-center gap-2 text-sm">
|
||||||
@ -96,9 +99,9 @@
|
|||||||
@endif
|
@endif
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if($filterId && $filterType !== 'none')
|
@if($filterLabel)
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-500">
|
<span class="text-xs text-gray-500 dark:text-gray-500">
|
||||||
Filter ({{ $filterType }}): {{ $filterName ?? $filterId }}
|
Filter{{ $filterType !== 'none' ? " ({$filterType})" : '' }}: {{ $filterLabel }}
|
||||||
</span>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|||||||
@ -28,7 +28,8 @@ ## Scope
|
|||||||
- **Policy Types**: `settingsCatalogPolicy` only (initially)
|
- **Policy Types**: `settingsCatalogPolicy` only (initially)
|
||||||
- **Graph Endpoints**:
|
- **Graph Endpoints**:
|
||||||
- GET `/deviceManagement/configurationPolicies/{id}/assignments`
|
- 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/roleScopeTags` (for reference data)
|
||||||
- GET `/deviceManagement/assignmentFilters` (for filter names)
|
- 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)
|
- **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
|
1. Replace source group IDs with mapped target group IDs in assignment objects
|
||||||
2. Skip assignments marked "Skip" in group mapping
|
2. Skip assignments marked "Skip" in group mapping
|
||||||
3. Preserve include/exclude intent and filters
|
3. Preserve include/exclude intent and filters
|
||||||
4. Execute restore via DELETE-then-CREATE pattern:
|
4. Execute restore via assign action when supported:
|
||||||
- Step 1: GET existing assignments from target policy
|
- Step 1: POST `/assign` with `{ assignments: [...] }` to replace assignments
|
||||||
- Step 2: DELETE each existing assignment (via DELETE `/assignments/{id}`)
|
- Step 2 (fallback): If `/assign` is unsupported, use DELETE-then-CREATE:
|
||||||
- Step 3: POST each new/mapped assignment (via POST `/assignments`)
|
- 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:
|
5. Handle failures gracefully:
|
||||||
- 204 No Content on DELETE = success
|
- 204 No Content on DELETE = success
|
||||||
- 201 Created on POST = success
|
- 201 Created on POST = success
|
||||||
@ -316,30 +319,32 @@ ### Endpoints to Add (Production-Tested Strategies)
|
|||||||
- Client-side filter to extract assignments
|
- Client-side filter to extract assignments
|
||||||
- **Reason**: Known Graph API quirks with assignment expansion on certain template families
|
- **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`
|
- **POST** `/deviceManagement/configurationPolicies/{id}/assign`
|
||||||
- Body: Single assignment object
|
- Body: `{ "assignments": [ ... ] }`
|
||||||
- Returns: 201 Created with assignment object
|
- Returns: 200/204 on success (no per-assignment IDs)
|
||||||
- Example:
|
- Example:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"target": {
|
"assignments": [
|
||||||
"@odata.type": "#microsoft.graph.groupAssignmentTarget",
|
{
|
||||||
"groupId": "abc-123-def"
|
"target": {
|
||||||
},
|
"@odata.type": "#microsoft.graph.groupAssignmentTarget",
|
||||||
"intent": "apply"
|
"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}`
|
- **Restore Strategy**: Prefer `/assign`; if unsupported, delete existing assignments then POST new ones (best-effort; record outcomes, no transactional rollback).
|
||||||
- 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)
|
|
||||||
|
|
||||||
3. **POST** `/directoryObjects/getByIds` (Stable Group Resolution)
|
3. **POST** `/directoryObjects/getByIds` (Stable Group Resolution)
|
||||||
- Body: `{ "ids": ["id1", "id2"], "types": ["group"] }`
|
- Body: `{ "ids": ["id1", "id2"], "types": ["group"] }`
|
||||||
@ -372,7 +377,7 @@ ### Graph Contract Updates
|
|||||||
|
|
||||||
// Assignments CRUD (standard Graph pattern)
|
// Assignments CRUD (standard Graph pattern)
|
||||||
'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments',
|
'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_create_method' => 'POST',
|
||||||
'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
|
'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
|
||||||
'assignments_update_method' => 'PATCH',
|
'assignments_update_method' => 'PATCH',
|
||||||
|
|||||||
@ -77,7 +77,7 @@ ### Tasks
|
|||||||
|
|
||||||
**1.9** [X] ⭐ Update `config/graph_contracts.php` with assignments endpoints
|
**1.9** [X] ⭐ Update `config/graph_contracts.php` with assignments endpoints
|
||||||
- Add `assignments_list_path` (GET)
|
- Add `assignments_list_path` (GET)
|
||||||
- Add `assignments_create_path` (POST)
|
- Add `assignments_create_path` (POST `/assign` for settingsCatalogPolicy)
|
||||||
- Add `assignments_delete_path` (DELETE)
|
- Add `assignments_delete_path` (DELETE)
|
||||||
- Add `supports_scope_tags: true`
|
- Add `supports_scope_tags: true`
|
||||||
- Add `scope_tag_field: 'roleScopeTagIds'`
|
- Add `scope_tag_field: 'roleScopeTagIds'`
|
||||||
@ -351,15 +351,15 @@ ### Tasks
|
|||||||
**5.8** Create service: `AssignmentRestoreService`
|
**5.8** Create service: `AssignmentRestoreService`
|
||||||
- File: `app/Services/AssignmentRestoreService.php`
|
- File: `app/Services/AssignmentRestoreService.php`
|
||||||
- Method: `restore(string $policyId, array $assignments, array $groupMapping): array`
|
- 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 1: GET `/assignments` for target policy
|
||||||
- Step 2: Loop and DELETE each assignment
|
- Step 2: Loop and DELETE each assignment
|
||||||
- Handle 204 No Content (success)
|
- Handle 204 No Content (success)
|
||||||
- Log warnings on failure, continue
|
- 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
|
- Step 3: Loop through source assignments
|
||||||
- Apply group mapping: replace source group IDs with target IDs
|
- Apply group mapping: replace source group IDs with target IDs
|
||||||
- Skip assignments marked `"SKIP"` in mapping
|
- Skip assignments marked `"SKIP"` in mapping
|
||||||
@ -367,7 +367,7 @@ ### Tasks
|
|||||||
- Handle 201 Created (success)
|
- Handle 201 Created (success)
|
||||||
- Log per-assignment outcome
|
- 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)`
|
- Add 100ms delay between sequential POST calls: `usleep(100000)`
|
||||||
- Log request IDs for failed calls
|
- Log request IDs for failed calls
|
||||||
|
|
||||||
|
|||||||
@ -67,10 +67,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
test('restore applies assignments with mapped groups', function () {
|
test('restore applies assignments with mapped groups', function () {
|
||||||
$applyResponse = new GraphResponse(true, []);
|
$applyResponse = new GraphResponse(true, []);
|
||||||
$requestResponses = [
|
$requestResponses = [
|
||||||
new GraphResponse(true, ['value' => [['id' => 'assign-old-1']]]), // list
|
new GraphResponse(true, []), // assign action
|
||||||
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
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$client = new RestoreAssignmentGraphClient($applyResponse, $requestResponses);
|
$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')
|
->filter(fn (array $call) => $call['method'] === 'POST')
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
expect($postCalls)->toHaveCount(2);
|
expect($postCalls)->toHaveCount(1);
|
||||||
expect($postCalls[0]['payload']['target']['groupId'])->toBe('target-group-1');
|
expect($postCalls[0]['path'])->toBe('/deviceManagement/configurationPolicies/scp-1/assign');
|
||||||
expect($postCalls[0]['payload'])->not->toHaveKey('id');
|
|
||||||
|
$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 () {
|
test('restore handles assignment failures gracefully', function () {
|
||||||
$applyResponse = new GraphResponse(true, []);
|
$applyResponse = new GraphResponse(true, []);
|
||||||
$requestResponses = [
|
$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, [
|
new GraphResponse(false, ['error' => ['message' => 'Bad request']], 400, [
|
||||||
['code' => 'BadRequest', 'message' => 'Bad request'],
|
['code' => 'BadRequest', 'message' => 'Bad request'],
|
||||||
], [], [
|
], [], [
|
||||||
'error_code' => 'BadRequest',
|
'error_code' => 'BadRequest',
|
||||||
'error_message' => 'Bad request',
|
'error_message' => 'Bad request',
|
||||||
]), // create 2 fails
|
]), // assign action fails
|
||||||
];
|
];
|
||||||
|
|
||||||
$client = new RestoreAssignmentGraphClient($applyResponse, $requestResponses);
|
$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;
|
$summary = $run->results[0]['assignment_summary'] ?? null;
|
||||||
|
|
||||||
expect($summary)->not->toBeNull();
|
expect($summary)->not->toBeNull();
|
||||||
expect($summary['success'])->toBe(1);
|
expect($summary['success'])->toBe(0);
|
||||||
expect($summary['failed'])->toBe(1);
|
expect($summary['failed'])->toBe(2);
|
||||||
expect($run->results[0]['status'])->toBe('partial');
|
expect($run->results[0]['status'])->toBe('partial');
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user