> $assignments * @param array $groupMapping * @return array{outcomes: array>, summary: array{success:int,failed:int,skipped:int}} */ public function restore( Tenant $tenant, string $policyType, string $policyId, array $assignments, array $groupMapping, ?RestoreRun $restoreRun = null, ?string $actorEmail = null, ?string $actorName = null, ): array { $outcomes = []; $summary = [ 'success' => 0, 'failed' => 0, 'skipped' => 0, ]; if ($assignments === []) { return [ 'outcomes' => $outcomes, 'summary' => $summary, ]; } $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')); if (! $listPath || ! $createPath || ! $deletePathTemplate) { $outcomes[] = $this->failureOutcome(null, 'Assignments endpoints are not configured for this policy type.'); $summary['failed']++; return [ 'outcomes' => $outcomes, 'summary' => $summary, ]; } $graphOptions = $tenant->graphOptions(); $tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey(); $context = [ 'tenant' => $tenantIdentifier, 'policy_id' => $policyId, 'policy_type' => $policyType, 'restore_run_id' => $restoreRun?->id, ]; $this->graphLogger->logRequest('restore_assignments_list', $context + [ 'method' => 'GET', 'endpoint' => $listPath, ]); $response = $this->graphClient->request('GET', $listPath, $graphOptions); $this->graphLogger->logResponse('restore_assignments_list', $response, $context + [ 'method' => 'GET', 'endpoint' => $listPath, ]); $existingAssignments = $response->data['value'] ?? []; foreach ($existingAssignments as $existing) { $assignmentId = $existing['id'] ?? null; if (! is_string($assignmentId) || $assignmentId === '') { continue; } $deletePath = $this->resolvePath($deletePathTemplate, $policyId, $assignmentId); if (! $deletePath) { continue; } $this->graphLogger->logRequest('restore_assignments_delete', $context + [ 'method' => 'DELETE', 'endpoint' => $deletePath, 'assignment_id' => $assignmentId, ]); $deleteResponse = $this->graphClient->request('DELETE', $deletePath, $graphOptions); $this->graphLogger->logResponse('restore_assignments_delete', $deleteResponse, $context + [ 'method' => 'DELETE', 'endpoint' => $deletePath, 'assignment_id' => $assignmentId, ]); if ($deleteResponse->failed()) { Log::warning('Failed to delete existing assignment during restore', $context + [ 'assignment_id' => $assignmentId, 'graph_error_message' => $deleteResponse->meta['error_message'] ?? null, 'graph_error_code' => $deleteResponse->meta['error_code'] ?? null, ]); } } 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); $this->graphLogger->logRequest('restore_assignments_create', $context + [ 'method' => $createMethod, 'endpoint' => $createPath, 'group_id' => $groupId, 'mapped_group_id' => $mappedGroupId, ]); $createResponse = $this->graphClient->request($createMethod, $createPath, [ 'json' => $assignmentToRestore, ] + $graphOptions); $this->graphLogger->logResponse('restore_assignments_create', $createResponse, $context + [ 'method' => $createMethod, 'endpoint' => $createPath, 'group_id' => $groupId, 'mapped_group_id' => $mappedGroupId, ]); if ($createResponse->successful()) { $outcomes[] = $this->successOutcome($assignment, $groupId, $mappedGroupId); $summary['success']++; $this->logAssignmentOutcome( status: 'created', tenant: $tenant, assignment: $assignment, restoreRun: $restoreRun, actorEmail: $actorEmail, actorName: $actorName, metadata: [ 'policy_id' => $policyId, 'policy_type' => $policyType, 'group_id' => $groupId, 'mapped_group_id' => $mappedGroupId, ] ); } else { $outcomes[] = $this->failureOutcome( $assignment, $createResponse->meta['error_message'] ?? 'Graph create failed', $groupId, $mappedGroupId, $createResponse ); $summary['failed']++; $this->logAssignmentOutcome( status: 'failed', tenant: $tenant, assignment: $assignment, restoreRun: $restoreRun, actorEmail: $actorEmail, actorName: $actorName, metadata: [ 'policy_id' => $policyId, 'policy_type' => $policyType, 'group_id' => $groupId, 'mapped_group_id' => $mappedGroupId, 'graph_error_message' => $createResponse->meta['error_message'] ?? null, 'graph_error_code' => $createResponse->meta['error_code'] ?? null, ], ); } usleep(100000); } return [ 'outcomes' => $outcomes, 'summary' => $summary, ]; } private function resolvePath(?string $template, string $policyId, ?string $assignmentId = null): ?string { if (! is_string($template) || $template === '') { return null; } $path = str_replace('{id}', urlencode($policyId), $template); if ($assignmentId !== null) { $path = str_replace('{assignmentId}', urlencode($assignmentId), $path); } return $path; } private function applyGroupMapping(array $assignment, ?string $mappedGroupId): array { if (! $mappedGroupId) { return $assignment; } $target = $assignment['target'] ?? []; $odataType = $target['@odata.type'] ?? ''; if (in_array($odataType, [ '#microsoft.graph.groupAssignmentTarget', '#microsoft.graph.exclusionGroupAssignmentTarget', ], true) && isset($target['groupId'])) { $target['groupId'] = $mappedGroupId; $assignment['target'] = $target; } return $assignment; } private function sanitizeAssignment(array $assignment): array { $assignment = Arr::except($assignment, ['id']); $target = $assignment['target'] ?? []; unset( $target['group_display_name'], $target['group_orphaned'], $target['assignment_filter_name'] ); $assignment['target'] = $target; return $assignment; } private function successOutcome(array $assignment, ?string $groupId, ?string $mappedGroupId): array { return [ 'status' => 'success', 'assignment' => $this->sanitizeAssignment($assignment), 'group_id' => $groupId, 'mapped_group_id' => $mappedGroupId, ]; } private function skipOutcome(array $assignment, ?string $groupId, ?string $mappedGroupId): array { return [ 'status' => 'skipped', 'assignment' => $this->sanitizeAssignment($assignment), 'group_id' => $groupId, 'mapped_group_id' => $mappedGroupId, ]; } private function failureOutcome( ?array $assignment, string $reason, ?string $groupId = null, ?string $mappedGroupId = null, ?GraphResponse $response = null ): array { return array_filter([ 'status' => 'failed', 'assignment' => $assignment ? $this->sanitizeAssignment($assignment) : null, 'group_id' => $groupId, 'mapped_group_id' => $mappedGroupId, 'reason' => $reason, 'graph_error_message' => $response?->meta['error_message'] ?? null, 'graph_error_code' => $response?->meta['error_code'] ?? null, 'graph_request_id' => $response?->meta['request_id'] ?? null, 'graph_client_request_id' => $response?->meta['client_request_id'] ?? null, ], static fn ($value) => $value !== null); } private function logAssignmentOutcome( string $status, Tenant $tenant, array $assignment, ?RestoreRun $restoreRun, ?string $actorEmail, ?string $actorName, array $metadata ): void { $action = match ($status) { 'created' => 'restore.assignment.created', 'failed' => 'restore.assignment.failed', default => 'restore.assignment.skipped', }; $statusLabel = match ($status) { 'created' => 'success', 'failed' => 'failed', default => 'warning', }; $this->auditLogger->log( tenant: $tenant, action: $action, context: [ 'metadata' => $metadata, 'assignment' => $this->sanitizeAssignment($assignment), ], actorEmail: $actorEmail, actorName: $actorName, status: $statusLabel, resourceType: 'restore_run', resourceId: $restoreRun ? (string) $restoreRun->id : null ); } }