Src/Private/Assignment-Helpers.ps1
|
#region --- Assignment Resolution Helpers --- # Used across all section functions to resolve: # - Group IDs -> display names (cached) # - Included vs excluded group assignments # - Scope tag IDs -> names (cached) # - Empty group detection (member count = 0) # All lookups are initialised once at report startup and stored in script-scoped hashtables. #region --- Initialise Lookup Tables --- function Initialize-IntuneGroupLookup { <# .SYNOPSIS Pre-fetches all Azure AD groups referenced in Intune assignments and builds an ID -> DisplayName hashtable. Called once at report startup. Stores results in $script:GroupNameCache. #> [CmdletBinding()] param() $script:GroupNameCache = @{} $script:GroupMemberCache = @{} # groupId -> member count (-1 = not fetched) Write-AbrDebugLog 'Group lookup cache initialised (lazy-populated on first use)' 'INFO' 'GROUPS' } function Invoke-IntuneGroupCachePrewarm { <# .SYNOPSIS Collects only the group IDs that appear in actual Intune policy assignments, then bulk-resolves their display names and caches them. Strategy: 1. Walk a curated list of Intune policy assignment endpoints (config profiles, compliance, app protection, endpoint security, scripts, autopilot, etc.) 2. Collect every unique groupId from groupAssignmentTarget and exclusionGroupAssignmentTarget entries. 3. Batch-resolve display names for those IDs only (chunks of 15 via $filter). 4. Also stores $script:AssignedGroupIds (ordered set) so the Entra Groups section can enumerate exactly which groups are policy-assigned. This prevents the tenant's entire group directory from being listed in the report -- only groups actually wired to an Intune policy appear. #> [CmdletBinding()] param() # Assignment endpoints to walk. Each returns { value: [ { assignments: [...] } ] } # or { value: [...assignments...] } directly for some endpoints. $assignmentEndpoints = @( # Configuration profiles "$($script:GraphEndpoint)/beta/deviceManagement/deviceConfigurations?`$select=id&`$expand=assignments(`$select=target)&`$top=999", "$($script:GraphEndpoint)/beta/deviceManagement/configurationPolicies?`$select=id&`$expand=assignments(`$select=target)&`$top=999", "$($script:GraphEndpoint)/beta/deviceManagement/groupPolicyConfigurations?`$select=id&`$expand=assignments(`$select=target)&`$top=999", # Compliance "$($script:GraphEndpoint)/beta/deviceManagement/deviceCompliancePolicies?`$select=id&`$expand=assignments(`$select=target)&`$top=999", # App protection / config "$($script:GraphEndpoint)/beta/deviceAppManagement/managedAppPolicies?`$select=id&`$expand=assignments(`$select=target)&`$top=999", "$($script:GraphEndpoint)/beta/deviceAppManagement/targetedManagedAppConfigurations?`$select=id&`$expand=assignments(`$select=target)&`$top=999", # Endpoint security (intents) "$($script:GraphEndpoint)/beta/deviceManagement/intents?`$select=id&`$expand=assignments(`$select=target)&`$top=999", # Autopilot "$($script:GraphEndpoint)/beta/deviceManagement/windowsAutopilotDeploymentProfiles?`$select=id&`$expand=assignments(`$select=target)&`$top=999", # Scripts "$($script:GraphEndpoint)/beta/deviceManagement/deviceManagementScripts?`$select=id&`$expand=assignments(`$select=target)&`$top=999", "$($script:GraphEndpoint)/beta/deviceManagement/deviceShellScripts?`$select=id&`$expand=assignments(`$select=target)&`$top=999", "$($script:GraphEndpoint)/beta/deviceManagement/deviceHealthScripts?`$select=id&`$expand=assignments(`$select=target)&`$top=999", # Enrollment restrictions "$($script:GraphEndpoint)/beta/deviceManagement/deviceEnrollmentConfigurations?`$select=id&`$expand=assignments(`$select=target)&`$top=999", # Apps "$($script:GraphEndpoint)/beta/deviceAppManagement/mobileApps?`$select=id&`$expand=assignments(`$select=target)&`$top=999" ) $collectedIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) Write-Host " - Pre-warming group name cache (policy-assignment-scoped)..." -ForegroundColor Cyan foreach ($ep in $assignmentEndpoints) { try { $nextLink = $ep while ($nextLink) { $resp = Invoke-MgGraphRequest -Method GET -Uri $nextLink -ErrorAction Stop foreach ($item in $resp.value) { $targets = if ($item.assignments) { $item.assignments | ForEach-Object { $_.target } } else { @() } foreach ($t in $targets) { if ($t.'@odata.type' -in @( '#microsoft.graph.groupAssignmentTarget', '#microsoft.graph.exclusionGroupAssignmentTarget') -and $t.groupId) { $null = $collectedIds.Add($t.groupId) } } } $nextLink = $resp.'@odata.nextLink' } } catch { # Non-fatal — some endpoints may not be licensed/available Write-AbrDebugLog "Group prewarm: endpoint skipped ($ep): $($_.Exception.Message)" 'DEBUG' 'GROUPS' } } # Store the scoped ID set for the Entra Groups section $script:AssignedGroupIds = $collectedIds if ($collectedIds.Count -eq 0) { Write-Host " - Group cache: no policy-assigned groups found" -ForegroundColor Yellow Write-AbrDebugLog "Group prewarm: no assigned group IDs discovered" 'WARN' 'GROUPS' return } # Batch-resolve display names in chunks of 15 (Graph $filter limit) $idList = @($collectedIds) $chunkSize = 15 $fetched = 0 for ($i = 0; $i -lt $idList.Count; $i += $chunkSize) { $chunk = $idList[$i..([Math]::Min($i + $chunkSize - 1, $idList.Count - 1))] $idFilter = ($chunk | ForEach-Object { "id eq '$_'" }) -join ' or ' try { $resp = Invoke-MgGraphRequest -Method GET ` -Uri "$($script:GraphEndpoint)/v1.0/groups?`$select=id,displayName&`$filter=$idFilter" ` -ErrorAction Stop foreach ($g in $resp.value) { if ($g.id -and $g.displayName) { $script:GroupNameCache[$g.id] = $g.displayName $fetched++ } } } catch { Write-AbrDebugLog "Group prewarm batch resolve failed: $($_.Exception.Message)" 'WARN' 'GROUPS' } } Write-Host " - Group cache pre-warmed: $fetched of $($collectedIds.Count) assigned group(s) resolved" -ForegroundColor Cyan Write-AbrDebugLog "Group cache pre-warmed: $fetched groups from $($collectedIds.Count) assigned IDs" 'INFO' 'GROUPS' } function Initialize-IntuneScopeTagLookup { <# .SYNOPSIS Fetches all Intune Role Scope Tags and builds an ID -> name hashtable. Stored in $script:ScopeTagCache. Called once at report startup. #> [CmdletBinding()] param() $script:ScopeTagCache = @{ '0' = 'Default' } try { $resp = Invoke-MgGraphRequest -Method GET ` -Uri "$($script:GraphEndpoint)/beta/deviceManagement/roleScopeTags?`$select=id,displayName" ` -ErrorAction SilentlyContinue if ($resp.value) { foreach ($tag in $resp.value) { $script:ScopeTagCache["$($tag.id)"] = $tag.displayName } } Write-Host " - Scope tag lookup: $($script:ScopeTagCache.Count) tag(s) loaded" -ForegroundColor Cyan Write-AbrDebugLog "Scope tag lookup: $($script:ScopeTagCache.Count) tags" 'INFO' 'SCOPETAGS' } catch { Write-AbrDebugLog "Scope tag lookup failed: $($_.Exception.Message)" 'WARN' 'SCOPETAGS' } } #endregion #region --- Group Name Resolution --- function Get-IntuneGroupName { <# .SYNOPSIS Returns the display name for an Azure AD group ID. Uses $script:GroupNameCache to avoid repeated Graph calls. Falls back to a truncated ID if the group cannot be resolved. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string]$GroupId ) if (-not $GroupId) { return '--' } # Return from cache if available if ($script:GroupNameCache.ContainsKey($GroupId)) { return $script:GroupNameCache[$GroupId] } # Fetch from Graph try { $resp = Invoke-MgGraphRequest -Method GET ` -Uri "$($script:GraphEndpoint)/v1.0/groups/${GroupId}?`$select=id,displayName" ` -ErrorAction Stop $name = if ($resp.displayName) { $resp.displayName } else { "Unknown ($GroupId)" } $script:GroupNameCache[$GroupId] = $name return $name } catch { $short = if ($GroupId.Length -gt 8) { "$($GroupId.Substring(0,8))..." } else { $GroupId } $script:GroupNameCache[$GroupId] = "Unknown ($short)" return $script:GroupNameCache[$GroupId] } } function Get-IntuneGroupMemberCount { <# .SYNOPSIS Returns the member count for an Azure AD group. Returns -1 if unavailable, 0 if empty (triggers HealthCheck Warning). Uses $script:GroupMemberCache to avoid repeated calls. #> [CmdletBinding()] [OutputType([int])] param( [Parameter(Mandatory)] [string]$GroupId ) if (-not $GroupId) { return -1 } if ($script:GroupMemberCache.ContainsKey($GroupId)) { return $script:GroupMemberCache[$GroupId] } try { $resp = Invoke-MgGraphRequest -Method GET ` -Uri "$($script:GraphEndpoint)/v1.0/groups/$GroupId/members/`$count" ` -Headers @{ 'ConsistencyLevel' = 'eventual' } ` -ErrorAction Stop $count = [int]$resp $script:GroupMemberCache[$GroupId] = $count return $count } catch { $script:GroupMemberCache[$GroupId] = -1 return -1 } } function Resolve-IntuneAssignments { <# .SYNOPSIS Resolves an array of Intune assignment objects into human-readable strings. Returns a [pscustomobject] with: - IncludedGroups : comma-separated group display names (included targets) - ExcludedGroups : comma-separated group display names (excluded targets) - AllUsers : $true if All Users virtual group is targeted - AllDevices : $true if All Devices virtual group is targeted - HasEmptyGroup : $true if any included group has 0 members - AssignmentSummary : short single string for summary tables .PARAMETER Assignments Array of assignment objects from a Graph API response (with .target property). .PARAMETER CheckMemberCount If $true, fetches member counts for each group (slower but detects empty groups). #> [CmdletBinding()] param( [Parameter()] [object[]]$Assignments, [switch]$CheckMemberCount ) $includedNames = [System.Collections.ArrayList]::new() $excludedNames = [System.Collections.ArrayList]::new() $allUsers = $false $allDevices = $false $hasEmptyGroup = $false if (-not $Assignments -or $Assignments.Count -eq 0) { return [pscustomobject]@{ IncludedGroups = '--' ExcludedGroups = '--' AllUsers = $false AllDevices = $false HasEmptyGroup = $false AssignmentSummary = 'Not assigned' } } foreach ($a in $Assignments) { $target = $a.target $odataType = $target.'@odata.type' switch ($odataType) { '#microsoft.graph.allLicensedUsersAssignmentTarget' { $allUsers = $true } '#microsoft.graph.allDevicesAssignmentTarget' { $allDevices = $true } '#microsoft.graph.groupAssignmentTarget' { $groupId = $target.groupId $groupName = Get-IntuneGroupName -GroupId $groupId $null = $includedNames.Add($groupName) if ($CheckMemberCount) { $count = Get-IntuneGroupMemberCount -GroupId $groupId if ($count -eq 0) { $hasEmptyGroup = $true } } } '#microsoft.graph.exclusionGroupAssignmentTarget' { $groupId = $target.groupId $groupName = Get-IntuneGroupName -GroupId $groupId $null = $excludedNames.Add("[EXCLUDED] $groupName") } } } # Build summary string $parts = [System.Collections.ArrayList]::new() if ($allUsers) { $null = $parts.Add('All Users') } if ($allDevices) { $null = $parts.Add('All Devices') } if ($includedNames.Count -gt 0) { $null = $parts.Add($includedNames -join ', ') } $summary = if ($parts.Count -gt 0) { $parts -join ' | ' } else { 'Not assigned' } $includedStr = if ($allUsers) { 'All Users' } elseif ($allDevices) { 'All Devices' } elseif ($includedNames.Count -gt 0) { $includedNames -join ', ' } else { '--' } if ($allUsers -and $includedNames.Count -gt 0) { $includedStr = "All Users, $($includedNames -join ', ')" } elseif ($allDevices -and $includedNames.Count -gt 0) { $includedStr = "All Devices, $($includedNames -join ', ')" } $excludedStr = if ($excludedNames.Count -gt 0) { $excludedNames -join ', ' } else { '--' } return [pscustomobject]@{ IncludedGroups = $includedStr ExcludedGroups = $excludedStr AllUsers = $allUsers AllDevices = $allDevices HasEmptyGroup = $hasEmptyGroup AssignmentSummary = $summary } } #endregion #region --- Scope Tag Resolution --- function Get-IntuneScopeTagNames { <# .SYNOPSIS Resolves an array of scope tag IDs to display names using the cached lookup. Returns a comma-separated string, e.g. "Default, APAC-Region, IT-Helpdesk" #> [CmdletBinding()] [OutputType([string])] param( [Parameter()] [object[]]$ScopeTagIds ) if (-not $ScopeTagIds -or $ScopeTagIds.Count -eq 0) { return 'Default' } $names = $ScopeTagIds | ForEach-Object { $id = "$_" if ($script:ScopeTagCache -and $script:ScopeTagCache.ContainsKey($id)) { $script:ScopeTagCache[$id] } else { "Tag-$id" } } return $names -join ', ' } #endregion #endregion |