resolveScopeTagNames($scopeTagIds, $tenant); $metadata = $backupItem->metadata ?? []; $metadata['scope_tag_ids'] = $scopeTagIds; $metadata['scope_tag_names'] = $scopeTagNames; // Only fetch assignments if explicitly requested if (! $includeAssignments) { $metadata['assignment_count'] = 0; $backupItem->update([ 'assignments' => null, 'metadata' => $metadata, ]); return $backupItem->refresh(); } // Fetch assignments from Graph API $graphOptions = $this->graphOptionsResolver->resolveForTenant($tenant); $tenantId = (string) ($graphOptions['tenant'] ?? ''); $assignments = $this->assignmentFetcher->fetch( $policyType, $tenantId, $policyId, $graphOptions, false, $policyPayload['@odata.type'] ?? null, ); if (empty($assignments)) { // No assignments or fetch failed $metadata['assignment_count'] = 0; $metadata['assignments_fetch_failed'] = true; $metadata['has_orphaned_assignments'] = false; $backupItem->update([ 'assignments' => [], // Return empty array instead of null 'metadata' => $metadata, ]); $this->recordFetchOperationRun($backupItem, $tenant, $metadata); Log::warning('No assignments fetched for policy', [ 'tenant_id' => $tenantId, 'policy_id' => $policyId, 'backup_item_id' => $backupItem->id, ]); return $backupItem->refresh(); } // Extract group IDs and resolve for orphan detection $groupIds = $this->extractGroupIds($assignments); $resolvedGroups = []; $hasOrphanedGroups = false; if (! empty($groupIds)) { $resolvedGroups = $this->groupResolver->resolveGroupIds($groupIds, $tenantId, $graphOptions); $hasOrphanedGroups = collect($resolvedGroups) ->contains(fn (array $group) => $group['orphaned'] ?? false); } $filterIds = $this->extractAssignmentFilterIds($assignments); $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); $filterNames = collect($filters) ->pluck('displayName', 'id') ->all(); $assignments = $this->enrichAssignments($assignments, $resolvedGroups, $filterNames); // Update backup item with assignments and metadata $metadata['assignment_count'] = count($assignments); $metadata['assignments_fetch_failed'] = false; $metadata['has_orphaned_assignments'] = $hasOrphanedGroups; $backupItem->update([ 'assignments' => $assignments, 'metadata' => $metadata, ]); $this->recordFetchOperationRun($backupItem, $tenant, $metadata); Log::info('Assignments enriched for backup item', [ 'tenant_id' => $tenantId, 'policy_id' => $policyId, 'backup_item_id' => $backupItem->id, 'assignment_count' => count($assignments), 'has_orphaned' => $hasOrphanedGroups, ]); return $backupItem->refresh(); } /** * @param array $captureMetadata */ public function recordFetchOperationRun(BackupItem $backupItem, Tenant $tenant, array $captureMetadata = []): void { $run = $this->operationRunService->ensureRunWithIdentity( tenant: $tenant, type: 'assignments.fetch', identityInputs: [ 'backup_item_id' => (int) $backupItem->getKey(), ], context: [ 'backup_set_id' => (int) $backupItem->backup_set_id, 'backup_item_id' => (int) $backupItem->getKey(), 'policy_id' => is_numeric($backupItem->policy_id) ? (int) $backupItem->policy_id : null, 'policy_identifier' => (string) $backupItem->policy_identifier, ], ); if ($run->status === 'completed') { return; } $this->operationRunService->updateRun($run, 'running'); $fetchFailed = (bool) ($captureMetadata['assignments_fetch_failed'] ?? false); $reasonCandidate = $captureMetadata['assignments_fetch_error_code'] ?? $captureMetadata['assignments_fetch_error'] ?? ProviderReasonCodes::UnknownError; $reasonCode = RunFailureSanitizer::normalizeReasonCode( $this->normalizeReasonCandidate($reasonCandidate) ); $this->operationRunService->updateRun( $run, status: 'completed', outcome: $fetchFailed ? 'failed' : 'succeeded', summaryCounts: [ 'total' => 1, 'processed' => $fetchFailed ? 0 : 1, 'failed' => $fetchFailed ? 1 : 0, ], failures: $fetchFailed ? [[ 'code' => 'assignments.fetch_failed', 'reason_code' => $reasonCode, 'message' => (string) ($captureMetadata['assignments_fetch_error'] ?? 'Assignments fetch failed'), ]] : [], ); } /** * Resolve scope tag IDs to display names. */ private function resolveScopeTagNames(array $scopeTagIds, Tenant $tenant): array { $scopeTags = $this->scopeTagResolver->resolve($scopeTagIds, $tenant); $names = []; foreach ($scopeTagIds as $id) { $scopeTag = collect($scopeTags)->firstWhere('id', $id); $names[] = $scopeTag['displayName'] ?? "Unknown (ID: {$id})"; } return $names; } /** * Extract group IDs from assignment array. */ private function extractGroupIds(array $assignments): array { $groupIds = []; foreach ($assignments as $assignment) { $target = $assignment['target'] ?? []; $odataType = $target['@odata.type'] ?? ''; if (in_array($odataType, [ '#microsoft.graph.groupAssignmentTarget', '#microsoft.graph.exclusionGroupAssignmentTarget', ], true) && isset($target['groupId'])) { $groupIds[] = $target['groupId']; } } return array_unique($groupIds); } /** * @param array> $assignments * @param array $groups * @param array $filterNames * @return array> */ private function enrichAssignments(array $assignments, array $groups, array $filterNames): array { return array_map(function (array $assignment) use ($groups, $filterNames): array { $target = $assignment['target'] ?? []; $groupId = $target['groupId'] ?? null; if ($groupId && isset($groups[$groupId])) { $target['group_display_name'] = $groups[$groupId]['displayName'] ?? null; $target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false; } $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] ?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null); $filterType = $assignment['deviceAndAppManagementAssignmentFilterType'] ?? ($target['deviceAndAppManagementAssignmentFilterType'] ?? null); if ($filterId) { $target['deviceAndAppManagementAssignmentFilterId'] = $target['deviceAndAppManagementAssignmentFilterId'] ?? $filterId; if ($filterType) { $target['deviceAndAppManagementAssignmentFilterType'] = $target['deviceAndAppManagementAssignmentFilterType'] ?? $filterType; } if (isset($filterNames[$filterId])) { $target['assignment_filter_name'] = $filterNames[$filterId]; } } $assignment['target'] = $target; return $assignment; }, $assignments); } /** * @param array> $assignments * @return array */ private function extractAssignmentFilterIds(array $assignments): array { $filterIds = []; foreach ($assignments as $assignment) { if (! is_array($assignment)) { continue; } $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] ?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null); if (is_string($filterId) && $filterId !== '') { $filterIds[] = $filterId; } } return array_values(array_unique($filterIds)); } private function normalizeReasonCandidate(mixed $candidate): string { if (! is_string($candidate) && ! is_numeric($candidate)) { return ProviderReasonCodes::UnknownError; } $raw = trim((string) $candidate); if ($raw === '') { return ProviderReasonCodes::UnknownError; } $raw = preg_replace('/(?