> $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); $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 (! $createPath || (! $usesAssignAction && (! $listPath || ! $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, ]; $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, ]); $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 ($preparedMeta as $index => $meta) { $assignmentToRestore = $preparedAssignments[$index] ?? null; if (! is_array($assignmentToRestore)) { continue; } $this->graphLogger->logRequest('restore_assignments_create', $context + [ 'method' => $createMethod, 'endpoint' => $createPath, 'group_id' => $meta['group_id'], 'mapped_group_id' => $meta['mapped_group_id'], ]); $createResponse = $this->graphClient->request($createMethod, $createPath, [ 'json' => $assignmentToRestore, ] + $graphOptions); $this->graphLogger->logResponse('restore_assignments_create', $createResponse, $context + [ 'method' => $createMethod, 'endpoint' => $createPath, 'group_id' => $meta['group_id'], 'mapped_group_id' => $meta['mapped_group_id'], ]); if ($createResponse->successful()) { $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 { $outcomes[] = $this->failureOutcome( $meta['assignment'], $createResponse->meta['error_message'] ?? 'Graph create failed', $meta['group_id'], $meta['mapped_group_id'], $createResponse ); $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' => $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 ); } }