Public/NC.Intune.ps1
|
#Requires -Version 5.0 using namespace System.Management.Automation # Nebula.Core: Intune helpers ======================================================================================================================= function Get-IntuneProfileAssignmentsByGroup { <# .SYNOPSIS Shows where an Entra group is used in Intune assignments. .DESCRIPTION Searches Intune device configurations, settings catalog policies, and apps for assignments that target the specified Entra group. Supports lookup by group name or group ID, with optional filtering by profile name or profile ID. Can also include parent groups that contain the requested group as a member. .PARAMETER GroupName Target Entra group display name. Accepts pipeline input. .PARAMETER GroupId Target Entra group object ID. Use this instead of GroupName. .PARAMETER ProfileName Optional profile or app display name filter. .PARAMETER ProfileId Optional filter for a specific Intune object ID. .PARAMETER IncludeNestedGroups Also match parent groups that include the requested Entra group. .PARAMETER GridView Show additional details in Out-GridView. .PARAMETER Diagnostic Include diagnostic columns in the returned objects. .EXAMPLE Get-IntuneProfileAssignmentsByGroup -GroupName "Windows 11 Pilot" .EXAMPLE Get-IntuneProfileAssignmentsByGroup -GroupId "00000000-0000-0000-0000-000000000000" .EXAMPLE "Windows 11 Pilot" | Get-IntuneProfileAssignmentsByGroup -GridView .EXAMPLE Get-IntuneProfileAssignmentsByGroup -GroupName "Intune - Reception" -IncludeNestedGroups .EXAMPLE Get-IntuneProfileAssignmentsByGroup -GroupName "Intune - Reception" -ProfileName "Zoom Workplace" -Diagnostic #> [CmdletBinding(DefaultParameterSetName = 'ByName')] param( [Parameter(Mandatory = $true, ParameterSetName = 'ByName', Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [Alias('Group', 'DisplayName', 'Name', 'Identity')] [string]$GroupName, [Parameter(Mandatory = $true, ParameterSetName = 'ById')] [string]$GroupId, [string]$ProfileName, [string]$ProfileId, [switch]$IncludeNestedGroups, [switch]$GridView, [switch]$Diagnostic ) process { Invoke-NCIntuneGroupUsageCore -ParameterSetName $PSCmdlet.ParameterSetName -GroupName $GroupName -GroupId $GroupId -ProfileName $ProfileName -ProfileId $ProfileId -IncludeNestedGroups:$IncludeNestedGroups -GridView:$GridView -Diagnostic:$Diagnostic } } function Search-IntuneProfileLocation { <# .SYNOPSIS Finds where an Intune profile lives across multiple Microsoft Graph surfaces. .DESCRIPTION Connects to Microsoft Graph and searches a curated set of Intune endpoints for profile names matching the provided text. Use this command to identify the correct source before querying assignments or extending support for new profile families. .PARAMETER SearchText Profile name text to search for. .PARAMETER Exact Match the profile name exactly instead of using a contains search. .PARAMETER GridView Show the results in Out-GridView instead of returning objects. .EXAMPLE Search-IntuneProfileLocation -SearchText "iOS - Wi-Fi M-Smartphone" .EXAMPLE Search-IntuneProfileLocation -SearchText "Wi-Fi" -GridView #> [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [Alias('Name', 'DisplayName', 'ProfileName', 'Query')] [string]$SearchText, [switch]$Exact, [switch]$GridView ) begin { $graphConnected = $null } process { if ($null -eq $graphConnected) { $graphConnected = Test-MgGraphConnection -Scopes @('DeviceManagementConfiguration.Read.All', 'DeviceManagementApps.Read.All', 'Group.Read.All', 'Directory.Read.All') -EnsureExchangeOnline:$false if (-not $graphConnected) { Add-EmptyLine Write-NCMessage "Can't connect or use Microsoft Graph modules. Please check logs." -Level ERROR return } if (-not (Get-Command -Name Invoke-MgGraphRequest -ErrorAction SilentlyContinue)) { Write-NCMessage "Invoke-MgGraphRequest is not available in the current Microsoft Graph session." -Level ERROR return } } if ([string]::IsNullOrWhiteSpace($SearchText)) { Write-NCMessage "SearchText cannot be empty." -Level WARNING return } $endpoints = @( @{ Source = 'deviceConfigurations'; Uris = @('v1.0/deviceManagement/deviceConfigurations?$top=100') }, @{ Source = 'betaDeviceConfigurations'; Uris = @('beta/deviceManagement/deviceConfigurations?$top=100') }, @{ Source = 'configurationPolicies'; Uris = @('v1.0/deviceManagement/configurationPolicies?$top=100', 'beta/deviceManagement/configurationPolicies?$top=100') }, @{ Source = 'groupPolicyConfigurations'; Uris = @('beta/deviceManagement/groupPolicyConfigurations?$top=100') }, @{ Source = 'resourceAccessProfiles'; Uris = @('beta/deviceManagement/resourceAccessProfiles?$top=100') }, @{ Source = 'deviceCompliancePolicies'; Uris = @('v1.0/deviceManagement/deviceCompliancePolicies?$top=100') }, @{ Source = 'deviceEnrollmentConfigurations'; Uris = @('v1.0/deviceManagement/deviceEnrollmentConfigurations?$top=100') }, @{ Source = 'deviceHealthScripts'; Uris = @('beta/deviceManagement/deviceHealthScripts?$top=100') }, @{ Source = 'deviceManagementScripts'; Uris = @('beta/deviceManagement/deviceManagementScripts?$top=100') }, @{ Source = 'deviceShellScripts'; Uris = @('beta/deviceManagement/deviceShellScripts?$top=100') } ) $results = [System.Collections.Generic.List[object]]::new() $normalizedSearch = $SearchText.Trim() foreach ($endpoint in $endpoints) { $items = @() $queried = $false $lastError = $null foreach ($endpointUri in $endpoint.Uris) { try { $items = @(Invoke-NCGraphCollectionRequest -Uri $endpointUri) $queried = $true break } catch { $lastError = $_.Exception.Message } } if (-not $queried) { if ($lastError) { Write-NCMessage "Unable to query $($endpoint.Source): $lastError" -Level WARNING } continue } foreach ($item in $items) { $itemName = Get-NCIntuneItemName -Item $item if ([string]::IsNullOrWhiteSpace($itemName)) { continue } $isMatch = if ($Exact.IsPresent) { $itemName -eq $normalizedSearch } else { $itemName -like "*$normalizedSearch*" } if (-not $isMatch) { continue } $results.Add([pscustomobject][ordered]@{ 'Profile Name' = $itemName 'Source' = $endpoint.Source 'Profile Id' = Get-NCIntuneItemId -Item $item 'Profile Type' = Get-NCIntuneItemODataType -Item $item }) | Out-Null } } Add-EmptyLine Write-Verbose "Intune profiles found for '$normalizedSearch': $($results.Count)" if ($results.Count -eq 0) { Write-NCMessage "No Intune profiles found for '$normalizedSearch' in the currently scanned endpoints." -Level WARNING return } $sorted = $results | Sort-Object 'Profile Name', 'Source' -Unique if ($GridView.IsPresent) { $sorted | Out-NCGridView -Title "Intune Profile Search - $normalizedSearch" } else { $sorted } } } function Export-IntuneAppInventory { <# .SYNOPSIS Reports Intune-managed devices that have matching applications installed. .DESCRIPTION Connects to Microsoft Graph, scans managed devices for detected apps, and can optionally enrich the report with deployed app device status information. The output is report-friendly and can also be exported to CSV and/or JSON. .PARAMETER ApplicationName Application name or wildcard pattern to match. Accepts pipeline input. .PARAMETER MinimumVersion Minimum application version to keep in the report. .PARAMETER FilterByType Optional app type filter when deployed app data is included. .PARAMETER FilterByPlatform Optional device platform filter. .PARAMETER LastInventory Include the managed device last Intune sync date in the report. .PARAMETER OnlySuccessfulInstalls When deployed app data is included, keep only successful installs. .PARAMETER IncludeDeployedApps Also query deployed app device statuses in addition to detected apps. .PARAMETER MaxDevices Maximum number of devices to process. Use 0 for all devices. .PARAMETER OutputCsvPath Optional CSV export path. .PARAMETER OutputJsonPath Optional JSON export path. .PARAMETER BatchSize Number of rows to flush at a time when writing CSV output. .PARAMETER Resume Resume CSV export from the latest matching CSV or from -CsvPath. .PARAMETER CsvPath Explicit CSV file to resume or export to. When omitted, a default file is used. .PARAMETER MaxConsecutiveErrors Stop after this many consecutive device-level failures. .PARAMETER PivotSummary Print a per-app summary after the report is built. .EXAMPLE Export-IntuneAppInventory -ApplicationName "TeamViewer" .EXAMPLE Export-IntuneAppInventory -ApplicationName "Microsoft*" -IncludeDeployedApps -FilterByType Win32 -OutputCsvPath "apps.csv" .EXAMPLE Export-IntuneAppInventory -ApplicationName "*java*" -FilterByPlatform Windows -LastInventory #> [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [Alias('SearchText', 'Name', 'DisplayName', 'Query', 'AppName')] [string]$ApplicationName, [string]$MinimumVersion, [ValidateSet('Win32', 'Store', 'LOB', 'Web', 'iOS', 'Android', 'macOS', 'All')] [string]$FilterByType = 'All', [ValidateSet('Windows', 'iOS', 'Android', 'macOS', 'All')] [string]$FilterByPlatform = 'All', [switch]$OnlySuccessfulInstalls, [switch]$IncludeDeployedApps, [switch]$LastInventory, [ValidateRange(0, [int]::MaxValue)] [int]$MaxDevices = 0, [string]$OutputCsvPath, [string]$OutputJsonPath, [ValidateRange(1, 500)] [int]$BatchSize = 25, [switch]$Resume, [string]$CsvPath, [ValidateRange(1, 100)] [int]$MaxConsecutiveErrors = 5, [switch]$PivotSummary ) begin { $graphConnected = $null } process { try { if ($null -eq $graphConnected) { $graphConnected = Test-MgGraphConnection -Scopes @('DeviceManagementManagedDevices.Read.All', 'DeviceManagementApps.Read.All', 'Directory.Read.All') -EnsureExchangeOnline:$false if (-not $graphConnected) { Add-EmptyLine Write-NCMessage "Can't connect or use Microsoft Graph modules. Please check logs." -Level ERROR return } if (-not (Get-Command -Name Invoke-MgGraphRequest -ErrorAction SilentlyContinue)) { Write-NCMessage "Invoke-MgGraphRequest is not available in the current Microsoft Graph session." -Level ERROR return } } if ([string]::IsNullOrWhiteSpace($ApplicationName)) { Write-NCMessage "ApplicationName cannot be empty." -Level WARNING return } $normalizeText = { param($value) return Get-NormalizedText -Value $value } $buildRowKey = { param($row) $app = & $normalizeText $row.AppName $device = & $normalizeText $row.DeviceId $source = & $normalizeText $row.Source $installState = & $normalizeText $row.InstallState $version = & $normalizeText $row.Version $publisher = & $normalizeText $row.Publisher return "{0}|{1}|{2}|{3}|{4}|{5}" -f $app, $device, $source, $installState, $version, $publisher } $existingRowKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $exportCsv = $false $csvPathResolved = $null $processedSinceFlush = 0 $consecutiveErrors = 0 $aborted = $false Write-NCMessage "Starting app inventory reporting ..." -Level INFO # Pull devices $devicesUri = "v1.0/deviceManagement/managedDevices?`$select=id,deviceName,operatingSystem,userPrincipalName,lastSyncDateTime" if ($MaxDevices -gt 0) { $devicesUri += "&`$top=$MaxDevices" } $devices = @(Invoke-NCGraphAllPagesCore -Uri $devicesUri) $lastInventoryCache = @{} if ($LastInventory) { foreach ($device in $devices) { if (-not [string]::IsNullOrWhiteSpace([string]$device.lastSyncDateTime)) { $lastInventoryCache[[string]$device.id] = Format-NCDateTime -Value $device.lastSyncDateTime -AsLocalTime } } } if ($FilterByPlatform -ne "All") { $devices = @($devices | Where-Object { $_.operatingSystem -like "$FilterByPlatform*" }) } Write-NCMessage "Managed devices retrieved: $($devices.Count)" -Level INFO $lastInventoryFailed = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) # Devices already read in v1.0 (even with a blank last-sync date): never requested again $lastInventoryChecked = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) # Reads missing last-sync dates for the given devices in Graph batches (v1.0, 20 per request). $loadLastInventory = { param([string[]]$DeviceIds) if (-not $LastInventory) { return } $missing = @($DeviceIds | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and -not ($lastInventoryCache.ContainsKey($_) -and -not [string]::IsNullOrWhiteSpace([string]$lastInventoryCache[$_])) -and -not $lastInventoryFailed.Contains($_) -and -not $lastInventoryChecked.Contains($_) } | Select-Object -Unique) if ($missing.Count -eq 0) { return } $detailRequests = @(for ($i = 0; $i -lt $missing.Count; $i++) { @{ Id = "l$i"; Method = 'GET'; Url = "/deviceManagement/managedDevices/$([uri]::EscapeDataString($missing[$i]))?`$select=lastSyncDateTime" } }) $detailResponses = @(Invoke-NCGraphBatch -Requests $detailRequests -ApiVersion 'v1.0' -Activity 'Reading last inventory dates') for ($i = 0; $i -lt $missing.Count; $i++) { $detailResponse = $detailResponses[$i] $detailError = $detailResponse.ErrorMessage if ($detailResponse.Success) { try { $lastInventoryValue = $detailResponse.Body.lastSyncDateTime if (-not [string]::IsNullOrWhiteSpace([string]$lastInventoryValue)) { $lastInventoryCache[$missing[$i]] = Format-NCDateTime -Value $lastInventoryValue -AsLocalTime } $null = $lastInventoryChecked.Add($missing[$i]) } catch { $detailError = $_.Exception.Message } } if ($detailError) { $null = $lastInventoryFailed.Add($missing[$i]) Write-NCMessage "Unable to read last inventory date for device $($missing[$i]): $detailError" -Level WARNING } } } function Get-NCIntuneManagedDeviceLastInventory { param( [Parameter(Mandatory = $true)] [string]$DeviceId ) if (-not $LastInventory) { return $null } if ($lastInventoryCache.ContainsKey($DeviceId) -and -not [string]::IsNullOrWhiteSpace([string]$lastInventoryCache[$DeviceId])) { return [string]$lastInventoryCache[$DeviceId] } return $null } # Build app --> device mapping from Detected Apps $appDeviceMap = @{} $processed = 0 $batchNoticeWritten = $false if ($devices.Count -gt 0) { $batchNoticeWritten = Write-NCGraphBatchNotice -Count ($devices.Count) -Noun 'device(s)' -PassThru } for ($offset = 0; $offset -lt $devices.Count -and -not $aborted; $offset += 20) { $deviceChunk = @($devices[$offset..([Math]::Min($offset + 20, $devices.Count) - 1)]) $Percentage = Get-NCProgressPercent -Current $processed -Total $devices.Count Write-Progress -Activity "Reading Detected Apps" -Status "$($deviceChunk[0].deviceName) - $processed / $($devices.Count) devices - $Percentage%" -PercentComplete $Percentage $appRequests = @(for ($i = 0; $i -lt $deviceChunk.Count; $i++) { @{ Id = "d$i"; Method = 'GET'; Url = "/deviceManagement/managedDevices/$([uri]::EscapeDataString([string]$deviceChunk[$i].id))?`$expand=detectedApps" } }) $appResponses = @(Invoke-NCGraphBatch -Requests $appRequests -ApiVersion 'beta' -Activity 'Reading detected apps') # Matching apps per device (after name and version filters), then last-sync dates for the matched devices in one go $matchedApps = @{} for ($i = 0; $i -lt $deviceChunk.Count; $i++) { if (-not $appResponses[$i].Success) { continue } $matchedApps[$i] = @(foreach ($app in ($appResponses[$i].Body.detectedApps | Where-Object { $_.displayName -like $ApplicationName })) { if ($MinimumVersion -and $app.version) { if (-not (Test-NCIntuneVersionAtLeast -CurrentVersion $app.version -MinimumVersion $MinimumVersion)) { continue } } $app }) } if ($LastInventory) { & $loadLastInventory -DeviceIds @(for ($i = 0; $i -lt $deviceChunk.Count; $i++) { if ($matchedApps.ContainsKey($i) -and $matchedApps[$i].Count -gt 0) { [string]$deviceChunk[$i].id } }) } for ($i = 0; $i -lt $deviceChunk.Count; $i++) { $device = $deviceChunk[$i] $processed++ $Percentage = Get-NCProgressPercent -Current $processed -Total $devices.Count if (-not $appResponses[$i].Success) { Write-NCMessage "Error reading apps for $($device.deviceName): $($appResponses[$i].ErrorMessage)" -Level WARNING $consecutiveErrors++ if ($MaxConsecutiveErrors -gt 0 -and $consecutiveErrors -ge $MaxConsecutiveErrors) { $aborted = $true break } continue } foreach ($app in $matchedApps[$i]) { Write-Progress -Activity "Reading Detected Apps" -Status "$($device.deviceName) / $($app.displayName) - $processed / $($devices.Count) devices - $Percentage%" -PercentComplete $Percentage $key = $app.displayName if (-not $appDeviceMap.ContainsKey($key)) { $appDeviceMap[$key] = [ordered]@{ Devices = @(); Versions = @{}; Publishers = @{} } } $deviceRow = [ordered]@{ DeviceId = $device.id DeviceName = $device.deviceName Platform = $device.operatingSystem User = $device.userPrincipalName Version = $app.version Publisher = $app.publisher Source = "DetectedApps" } if ($LastInventory) { $deviceRow.LastInventory = Get-NCIntuneManagedDeviceLastInventory -DeviceId $device.id } $appDeviceMap[$key].Devices += $deviceRow if ($app.version) { $appDeviceMap[$key].Versions[$app.version] = ($appDeviceMap[$key].Versions[$app.version] + 1) } if ($app.publisher) { $appDeviceMap[$key].Publishers[$app.publisher] = ($appDeviceMap[$key].Publishers[$app.publisher] + 1) } } } } Write-Progress -Activity "Reading Detected Apps" -Completed # Optionally incorporate deployment statuses (broadens coverage) if ($IncludeDeployedApps) { Write-NCMessage "Including deployed apps device status ..." -Level INFO $appsUri = "beta/deviceAppManagement/mobileApps" $allApps = @(Invoke-NCGraphAllPagesCore -Uri $appsUri) $deployedCandidates = @(foreach ($app in $allApps | Where-Object { $_.displayName -like $ApplicationName }) { $appType = Get-NCIntuneAppTypeFromODataType -ODataType $app.'@odata.type' if ($FilterByType -ne "All" -and $appType -ne $FilterByType) { continue } [pscustomobject]@{ App = $app; AppType = $appType } }) if ($deployedCandidates.Count -gt 0 -and -not $batchNoticeWritten) { $batchNoticeWritten = Write-NCGraphBatchNotice -Count ($deployedCandidates.Count) -Noun 'app(s)' -PassThru } # Deployment statuses in Graph batches (beta, 20 apps per request), in app order $deployedStatuses = [System.Collections.Generic.List[object]]::new() for ($offset = 0; $offset -lt $deployedCandidates.Count; $offset += 20) { $candidateChunk = @($deployedCandidates[$offset..([Math]::Min($offset + 20, $deployedCandidates.Count) - 1)]) $statusRequests = @(for ($i = 0; $i -lt $candidateChunk.Count; $i++) { @{ Id = "s$i"; Method = 'GET'; Url = "/deviceAppManagement/mobileApps/$([uri]::EscapeDataString([string]$candidateChunk[$i].App.id))/deviceStatuses" } }) $statusResponses = @(Invoke-NCGraphBatchCollection -Requests $statusRequests -ApiVersion 'beta' -Activity 'Reading app deployment status') $chunkEntries = @(for ($i = 0; $i -lt $candidateChunk.Count; $i++) { $chunkStatuses = @() if ($statusResponses[$i].Success) { $chunkStatuses = @($statusResponses[$i].Items) } else { Write-NCMessage "Error fetching data: $($statusResponses[$i].ErrorMessage)" -Level WARNING } [pscustomobject]@{ App = $candidateChunk[$i].App; AppType = $candidateChunk[$i].AppType; Statuses = $chunkStatuses } }) if ($LastInventory) { $statusDeviceIds = @(foreach ($entry in $chunkEntries) { $entry.Statuses | Where-Object { -not ($OnlySuccessfulInstalls -and $_.installState -ne "installed") } | ForEach-Object { [string]$_.deviceId } | Where-Object { $devices.id -contains $_ } }) & $loadLastInventory -DeviceIds $statusDeviceIds } foreach ($entry in $chunkEntries) { $deployedStatuses.Add($entry) | Out-Null } } foreach ($entry in $deployedStatuses) { $app = $entry.App $appType = $entry.AppType $statuses = $entry.Statuses foreach ($s in $statuses) { if ($OnlySuccessfulInstalls -and $s.installState -ne "installed") { continue } $d = $devices | Where-Object { $_.id -eq $s.deviceId } | Select-Object -First 1 if (-not $d) { continue } $key = $app.displayName if (-not $appDeviceMap.ContainsKey($key)) { $appDeviceMap[$key] = [ordered]@{ Devices = @(); Versions = @{}; Publishers = @{} } } # Avoid duplicates for the same device/app when DetectedApps already included it $exists = $appDeviceMap[$key].Devices | Where-Object { $_.DeviceId -eq $d.id } if (-not $exists) { $deviceRow = [ordered]@{ DeviceId = $d.id DeviceName = $d.deviceName Platform = $d.operatingSystem User = $d.userPrincipalName Version = $null Publisher = $null InstallState = $s.installState AppType = $appType Source = "DeploymentStatus" } if ($LastInventory) { $deviceRow.LastInventory = Get-NCIntuneManagedDeviceLastInventory -DeviceId $d.id } $appDeviceMap[$key].Devices += $deviceRow } } } } # Optional app type filter when only Detected Apps were used if (-not $IncludeDeployedApps -and $FilterByType -ne "All") { Write-Verbose "FilterByType applies only when -IncludeDeployedApps is used. Skipping type filter for Detected Apps only." } # Build flat rows $rows = @() foreach ($appName in $appDeviceMap.Keys) { foreach ($dev in $appDeviceMap[$appName].Devices) { $rows += [pscustomobject]@{ AppName = $appName Version = $dev.Version Publisher = $dev.Publisher AppType = $dev.AppType DeviceName = $dev.DeviceName DeviceId = $dev.DeviceId Platform = $dev.Platform LastInventory = $dev.LastInventory User = $dev.User InstallState = $dev.InstallState Source = $dev.Source } } } if ($OutputCsvPath -or $Resume.IsPresent -or -not [string]::IsNullOrWhiteSpace($CsvPath)) { if (-not [string]::IsNullOrWhiteSpace($CsvPath)) { $csvPathResolved = $CsvPath } elseif (-not [string]::IsNullOrWhiteSpace($OutputCsvPath)) { $csvPathResolved = $OutputCsvPath } elseif ($Resume) { $existingCsv = Get-ChildItem -Path (Get-Location).Path -File -Filter "*_M365-IntuneAppInventory-Report.csv" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($existingCsv) { $csvPathResolved = $existingCsv.FullName } else { $csvPathResolved = New-File (Join-Path -Path (Get-Location).Path -ChildPath "$((Get-Date -Format $NCVars.DateTimeString_CSV))_M365-IntuneAppInventory-Report.csv") } } if ($Resume -and (Test-Path -LiteralPath $csvPathResolved)) { try { foreach ($row in (Import-CSV -LiteralPath $csvPathResolved -Delimiter $NCVars.CSV_DefaultLimiter -ErrorAction Stop)) { $null = $existingRowKeys.Add((& $buildRowKey $row)) } Write-NCMessage ("Resuming Intune app inventory export from {0}; {1} row(s) already recorded." -f $csvPathResolved, $existingRowKeys.Count) -Level INFO } catch { Write-NCMessage ("Unable to read existing CSV '{0}' for resume. {1}" -f $csvPathResolved, $_.Exception.Message) -Level WARNING $existingRowKeys.Clear() } } elseif ($Resume -and -not (Test-Path -LiteralPath $csvPathResolved)) { Write-NCMessage ("Resume requested for '{0}', but the file does not exist. Starting a new report at that path." -f $csvPathResolved) -Level INFO } $exportCsv = $true Write-NCMessage ("Intune app inventory CSV export will flush every {0} row(s). Resume: {1}. Stop after {2} consecutive error(s)." -f $BatchSize, $Resume.IsPresent, $MaxConsecutiveErrors) -Level INFO Write-NCMessage "Saving report to $csvPathResolved" -Level DEBUG } if (-not $rows -or $rows.Count -eq 0) { if ($exportCsv -and (Test-Path -LiteralPath $csvPathResolved) -and ((Get-Item -LiteralPath $csvPathResolved).Length -gt 0)) { Write-NCMessage "No new Intune app inventory rows found. Existing CSV at $csvPathResolved already contains the requested rows." -Level INFO } else { Write-NCMessage "No applications matched '$ApplicationName' with the provided filters." -Level WARNING } return } $displayColumns = @('AppName', 'Version', 'DeviceName') $exportColumns = @('AppName', 'Version', 'Publisher', 'DeviceName', 'DeviceId') if ($IncludeDeployedApps) { $exportColumns += 'AppType' } if ($FilterByPlatform -eq 'All') { $displayColumns += 'Platform' $exportColumns += 'Platform' } if ($LastInventory) { $displayColumns += 'LastInventory' $exportColumns += 'LastInventory' } $displayColumns += @('User', 'Source') $exportColumns += 'User' if ($IncludeDeployedApps) { $exportColumns += 'InstallState' } $exportColumns += 'Source' $reportRows = @($rows | Select-Object -Property $exportColumns) # Console table output $reportRows | Sort-Object AppName, DeviceName | Select-Object -Property $displayColumns | Format-Table -AutoSize # Optional exports if ($exportCsv) { try { $sortedRows = $reportRows | Sort-Object AppName, DeviceName, DeviceId, Source $exportRows = if ($Resume) { @($sortedRows | Where-Object { -not $existingRowKeys.Contains((& $buildRowKey $_)) }) } else { @($sortedRows) } if ($exportRows.Count -eq 0) { Write-NCMessage "No new CSV rows needed to be written to $csvPathResolved." -Level INFO } else { $chunkSize = if ($BatchSize -gt 0) { $BatchSize } else { $exportRows.Count } for ($offset = 0; $offset -lt $exportRows.Count; $offset += $chunkSize) { $chunk = $exportRows[$offset..([Math]::Min($offset + $chunkSize - 1, $exportRows.Count - 1))] if ((Test-Path -LiteralPath $csvPathResolved) -and ((Get-Item -LiteralPath $csvPathResolved).Length -gt 0)) { $chunk | Export-Csv -LiteralPath $csvPathResolved -NoTypeInformation -Encoding UTF8 -Delimiter $NCVars.CSV_DefaultLimiter -Append } else { $chunk | Export-Csv -LiteralPath $csvPathResolved -NoTypeInformation -Encoding UTF8 -Delimiter $NCVars.CSV_DefaultLimiter } } if ($aborted) { Write-NCMessage "Intune app inventory CSV export stopped early. Partial data kept at $csvPathResolved." -Level ERROR } else { Write-NCMessage "CSV exported to $csvPathResolved" -Level SUCCESS } } } catch { Write-NCMessage "Failed to export CSV: $($_.Exception.Message)" -Level WARNING } } if ($OutputJsonPath) { try { $reportRows | ConvertTo-Json -Depth 5 | Out-File -FilePath $OutputJsonPath -Encoding UTF8 Write-NCMessage "JSON exported to $OutputJsonPath" -Level SUCCESS } catch { Write-NCMessage "Failed to export JSON: $($_.Exception.Message)" -Level WARNING } } # Optional per-app pivot/summary if ($PivotSummary) { Write-NCMessage "`n=== SUMMARY BY APPLICATION ===" -Level INFO $reportRows | Group-Object AppName | Sort-Object Count -Descending | ForEach-Object { $app = $_.Name $count = $_.Count $versions = ($_.Group | Where-Object Version | Group-Object Version | Sort-Object Count -Descending | ForEach-Object { "{0} ({1})" -f $_.Name, $_.Count }) -join ", " $publishers = ($_.Group | Where-Object Publisher | Group-Object Publisher | Sort-Object Count -Descending | Select-Object -First 3 | ForEach-Object { "{0} ({1})" -f $_.Name, $_.Count }) -join ", " "- {0}: {1} devices`n Versions: {2}`n Top publishers: {3}" -f $app, $count, ($(if ($versions) { $versions } else { 'n/a' })), ($(if ($publishers) { $publishers } else { 'n/a' })) } } Write-NCMessage "App inventory report completed successfully." -Level SUCCESS } catch { Write-NCMessage "Script execution failed: $($_.Exception.Message)" -Level ERROR return } } } function Get-IntuneAppPresence { <# .SYNOPSIS Checks whether a single Intune-managed device has a matching app installed. .DESCRIPTION Queries one managed device and returns a single summary row with the match result. Use this when you want a quick yes/no check without the full inventory report. .PARAMETER DeviceName Intune managed device name to inspect. .PARAMETER ApplicationName Application name or wildcard pattern to match. .PARAMETER MinimumVersion Minimum application version to consider a match. .EXAMPLE Get-IntuneAppPresence -DeviceName "UE5CG30740PT" -ApplicationName "*java*" #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$DeviceName, [Parameter(Mandatory = $true)] [Alias('SearchText', 'Name', 'DisplayName', 'Query', 'AppName')] [string]$ApplicationName, [string]$MinimumVersion ) try { $graphConnected = Test-MgGraphConnection -Scopes @('DeviceManagementManagedDevices.Read.All', 'Directory.Read.All') -EnsureExchangeOnline:$false if (-not $graphConnected) { Add-EmptyLine Write-NCMessage "Can't connect or use Microsoft Graph modules. Please check logs." -Level ERROR return } if (-not (Get-Command -Name Invoke-MgGraphRequest -ErrorAction SilentlyContinue)) { Write-NCMessage "Invoke-MgGraphRequest is not available in the current Microsoft Graph session." -Level ERROR return } $escapedDeviceName = $DeviceName.Replace("'", "''") $devicesUri = "v1.0/deviceManagement/managedDevices?`$filter=$([uri]::EscapeDataString("deviceName eq '$escapedDeviceName'"))&`$select=id,deviceName,operatingSystem,userPrincipalName,lastSyncDateTime" $devices = @(@(Invoke-MgGraphRequest -Uri $devicesUri -Method GET -ErrorAction Stop).value | Where-Object { $_ }) # Re-enrolled devices leave stale records with the same name: use the most recently synced one $devices = @($devices | Sort-Object -Property { if ($_.lastSyncDateTime) { [datetimeoffset]$_.lastSyncDateTime } else { [datetimeoffset]::MinValue } } -Descending) $device = $devices | Select-Object -First 1 if ($devices.Count -gt 1) { $otherIds = ($devices | Select-Object -Skip 1 | ForEach-Object { $_.id }) -join ', ' Write-NCMessage "$($devices.Count) Intune devices are named '$DeviceName'. Using the most recently synced one ($($device.id)); others: $otherIds." -Level WARNING } if (-not $device) { return [pscustomobject]@{ DeviceName = $DeviceName DeviceId = $null AppName = $ApplicationName Present = $false Version = $null Publisher = $null LastInventory = $null Source = 'ManagedDevice' Status = 'DeviceNotFound' } } $deviceAppsUri = "beta/deviceManagement/managedDevices/$($device.id)?`$expand=detectedApps" $deviceWithApps = Invoke-MgGraphRequest -Uri $deviceAppsUri -Method GET -ErrorAction Stop $matches = @($deviceWithApps.detectedApps | Where-Object { $_.displayName -like $ApplicationName }) if ($MinimumVersion) { $matches = @($matches | Where-Object { $_.version -and (Test-NCIntuneVersionAtLeast -CurrentVersion $_.version -MinimumVersion $MinimumVersion) }) } $match = $matches | Select-Object -First 1 $present = [bool]$match $lastInventoryValue = Format-NCDateTime -Value $device.lastSyncDateTime -AsLocalTime return [pscustomobject]@{ DeviceName = $device.deviceName DeviceId = $device.id AppName = if ($match) { $match.displayName } else { $ApplicationName } Present = $present Version = if ($match) { $match.version } else { $null } Publisher = if ($match) { $match.publisher } else { $null } LastInventory = $lastInventoryValue Source = 'DetectedApps' Status = if ($present) { 'Found' } else { 'NotFound' } } } catch { Write-NCMessage "Get-IntuneAppPresence failed: $($_.Exception.Message)" -Level ERROR throw } } function New-IntuneAppBasedGroup { <# .SYNOPSIS Creates Entra groups based on apps installed on Intune-managed devices. .DESCRIPTION Queries Intune-managed devices, discovers matching apps through detected apps and deployed app status data, and creates or updates Entra security groups populated with the matching Entra device objects. Use this for dynamic device targeting based on installed software. .PARAMETER ApplicationName Application name or wildcard pattern to match. .PARAMETER GroupName Explicit full group name to use instead of generating one from prefix and suffix. When supplied, all matching devices are collected into a single group target. .PARAMETER GroupPrefix Prefix applied to generated group names. .PARAMETER GroupSuffix Suffix applied to generated group names. .PARAMETER UpdateExisting Update matching groups instead of skipping them when they already exist. .PARAMETER MinimumVersion Minimum application version to keep in the result set. .PARAMETER FilterByType Optional app type filter for deployment-status coverage. .PARAMETER FilterByPlatform Optional device platform filter. .PARAMETER OnlySuccessfulInstalls When deployment data is used, keep only successful installs. .PARAMETER DryRun Preview changes without creating or updating groups. .PARAMETER MaxDevices Maximum number of devices to process. Use 0 for all devices. .EXAMPLE New-IntuneAppBasedGroup -ApplicationName "TeamViewer" .EXAMPLE New-IntuneAppBasedGroup -ApplicationName "TeamViewer" -GroupName "Devices - TeamViewer" .EXAMPLE New-IntuneAppBasedGroup -ApplicationName "Microsoft*" -GroupPrefix "SW-" -GroupSuffix "-Installed" .EXAMPLE New-IntuneAppBasedGroup -ApplicationName "Chrome" -MinimumVersion "120.0" -UpdateExisting .EXAMPLE New-IntuneAppBasedGroup -ApplicationName "*" -FilterByType Win32 -DryRun #> [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [Alias('SearchText', 'Name', 'DisplayName', 'Query', 'AppName')] [string]$ApplicationName, [string]$GroupName, [string]$GroupPrefix = 'Devices-With-', [string]$GroupSuffix = '', [switch]$UpdateExisting, [string]$MinimumVersion, [ValidateSet('Win32', 'Store', 'LOB', 'Web', 'iOS', 'Android', 'macOS', 'All')] [string]$FilterByType = 'All', [ValidateSet('Windows', 'iOS', 'Android', 'macOS', 'All')] [string]$FilterByPlatform = 'All', [switch]$OnlySuccessfulInstalls, [switch]$DryRun, [ValidateRange(0, [int]::MaxValue)] [int]$MaxDevices = 0 ) begin { $graphConnected = $null } process { try { if ($null -eq $graphConnected) { $graphConnected = Test-MgGraphConnection -Scopes @( 'DeviceManagementManagedDevices.Read.All', 'DeviceManagementApps.Read.All', 'Group.ReadWrite.All', 'Directory.Read.All' ) -EnsureExchangeOnline:$false if (-not $graphConnected) { Add-EmptyLine Write-NCMessage "Can't connect or use Microsoft Graph modules. Please check logs." -Level ERROR return } if (-not (Get-Command -Name Invoke-MgGraphRequest -ErrorAction SilentlyContinue)) { Write-NCMessage "Invoke-MgGraphRequest is not available in the current Microsoft Graph session." -Level ERROR return } } if ([string]::IsNullOrWhiteSpace($ApplicationName)) { Write-NCMessage "ApplicationName cannot be empty." -Level WARNING return } $useExplicitGroupName = -not [string]::IsNullOrWhiteSpace($GroupName) if ($useExplicitGroupName -and ($PSBoundParameters.ContainsKey('GroupPrefix') -or $PSBoundParameters.ContainsKey('GroupSuffix'))) { Write-Verbose 'GroupName was supplied; GroupPrefix and GroupSuffix will be ignored.' } Write-NCMessage "Starting app-based group creation process ..." -Level INFO Write-NCMessage "Retrieving managed devices ..." -Level INFO $devicesUri = "beta/deviceManagement/managedDevices?`$select=id,deviceName,operatingSystem,userPrincipalName,azureADDeviceId,azureActiveDirectoryDeviceId" if ($MaxDevices -gt 0) { $devicesUri += "&`$top=$MaxDevices" } $devices = @(Invoke-NCGraphAllPagesCore -Uri $devicesUri) if ($FilterByPlatform -ne 'All') { $devices = @($devices | Where-Object { $_.operatingSystem -like "$FilterByPlatform*" }) } $managedDeviceLabel = if ($devices.Count -eq 1) { 'managed device' } else { 'managed devices' } Write-NCMessage "Found $($devices.Count) $managedDeviceLabel" -Level INFO $appDeviceMap = @{} $processedDevices = 0 $batchNoticeWritten = $false Write-NCMessage "Scanning device applications ..." -Level INFO if ($devices.Count -gt 0) { $batchNoticeWritten = Write-NCGraphBatchNotice -Count ($devices.Count) -Noun 'device(s)' -PassThru } for ($offset = 0; $offset -lt $devices.Count; $offset += 20) { $deviceChunk = @($devices[$offset..([Math]::Min($offset + 20, $devices.Count) - 1)]) $Percentage = Get-NCProgressPercent -Current $processedDevices -Total $devices.Count Write-Progress -Activity 'Processing Devices' -Status "$($deviceChunk[0].deviceName) - $processedDevices of $($devices.Count) devices - $Percentage%" -PercentComplete $Percentage $appRequests = @(for ($i = 0; $i -lt $deviceChunk.Count; $i++) { @{ Id = "d$i"; Method = 'GET'; Url = "/deviceManagement/managedDevices/$([uri]::EscapeDataString([string]$deviceChunk[$i].id))?`$select=id,deviceName,operatingSystem,userPrincipalName,azureADDeviceId,azureActiveDirectoryDeviceId&`$expand=detectedApps" } }) $appResponses = @(Invoke-NCGraphBatch -Requests $appRequests -ApiVersion 'beta' -Activity 'Reading detected apps') for ($i = 0; $i -lt $deviceChunk.Count; $i++) { $device = $deviceChunk[$i] $processedDevices++ $Percentage = Get-NCProgressPercent -Current $processedDevices -Total $devices.Count if (-not $appResponses[$i].Success) { Write-NCMessage "Error processing device $($device.deviceName): $($appResponses[$i].ErrorMessage)" -Level WARNING continue } $deviceWithApps = $appResponses[$i].Body if ($deviceWithApps.detectedApps) { foreach ($app in $deviceWithApps.detectedApps) { if ($app.displayName -like $ApplicationName) { Write-Progress -Activity 'Processing Devices' -Status "$($device.deviceName) / $($app.displayName) - $processedDevices of $($devices.Count) devices - $Percentage%" -PercentComplete $Percentage if ($MinimumVersion -and $app.version) { if (-not (Test-NCIntuneVersionAtLeast -CurrentVersion $app.version -MinimumVersion $MinimumVersion)) { continue } } $appKey = $app.displayName if (-not $appDeviceMap.ContainsKey($appKey)) { $appDeviceMap[$appKey] = @{ Devices = @() Versions = @{} Publishers = @{} DeviceIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) } } if ($appDeviceMap[$appKey].DeviceIds.Add([string]$device.id)) { $appDeviceMap[$appKey].Devices += [ordered]@{ DeviceId = $device.id DeviceName = $device.deviceName Platform = $device.operatingSystem User = $device.userPrincipalName Version = $app.version Publisher = $app.publisher Source = 'DetectedApps' } } if ($app.version) { if ($appDeviceMap[$appKey].Versions.ContainsKey($app.version)) { $appDeviceMap[$appKey].Versions[$app.version]++ } else { $appDeviceMap[$appKey].Versions[$app.version] = 1 } } if ($app.publisher) { if ($appDeviceMap[$appKey].Publishers.ContainsKey($app.publisher)) { $appDeviceMap[$appKey].Publishers[$app.publisher]++ } else { $appDeviceMap[$appKey].Publishers[$app.publisher] = 1 } } } } } } } Write-Progress -Activity 'Processing Devices' -Completed if ($FilterByType -ne 'All' -or $OnlySuccessfulInstalls.IsPresent) { Write-NCMessage "Retrieving deployed application data ..." -Level INFO $appsUri = 'beta/deviceAppManagement/mobileApps' $deployedApps = @(Invoke-NCGraphAllPagesCore -Uri $appsUri) $deployedCandidates = @(foreach ($app in $deployedApps) { if ($app.displayName -like $ApplicationName) { $candidateType = Get-NCIntuneAppTypeFromODataType -ODataType ([string]$app.'@odata.type') if ($FilterByType -ne 'All' -and $candidateType -ne $FilterByType) { continue } [pscustomobject]@{ App = $app; AppType = $candidateType } } }) if ($deployedCandidates.Count -gt 0 -and -not $batchNoticeWritten) { $batchNoticeWritten = Write-NCGraphBatchNotice -Count ($deployedCandidates.Count) -Noun 'app(s)' -PassThru } for ($offset = 0; $offset -lt $deployedCandidates.Count; $offset += 20) { $candidateChunk = @($deployedCandidates[$offset..([Math]::Min($offset + 20, $deployedCandidates.Count) - 1)]) $statusRequests = @(for ($i = 0; $i -lt $candidateChunk.Count; $i++) { @{ Id = "s$i"; Method = 'GET'; Url = "/deviceAppManagement/mobileApps/$([uri]::EscapeDataString([string]$candidateChunk[$i].App.id))/deviceStatuses" } }) $statusResponses = @(Invoke-NCGraphBatchCollection -Requests $statusRequests -ApiVersion 'beta' -Activity 'Reading app deployment status') for ($i = 0; $i -lt $candidateChunk.Count; $i++) { if (-not $statusResponses[$i].Success) { throw [System.InvalidOperationException]::new([string]$statusResponses[$i].ErrorMessage) } $app = $candidateChunk[$i].App $appType = $candidateChunk[$i].AppType $deviceStatuses = @($statusResponses[$i].Items) foreach ($status in $deviceStatuses) { if ($OnlySuccessfulInstalls.IsPresent -and $status.installState -ne 'installed') { continue } $matchingDevice = $devices | Where-Object { $_.id -eq $status.deviceId } | Select-Object -First 1 if ($matchingDevice) { $appKey = $app.displayName if (-not $appDeviceMap.ContainsKey($appKey)) { $appDeviceMap[$appKey] = @{ Devices = @() Versions = @{} Publishers = @{} AppType = $appType DeviceIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) } } if ($appDeviceMap[$appKey].DeviceIds.Add([string]$matchingDevice.id)) { $appDeviceMap[$appKey].Devices += [ordered]@{ DeviceId = $matchingDevice.id DeviceName = $matchingDevice.deviceName Platform = $matchingDevice.operatingSystem User = $matchingDevice.userPrincipalName InstallState = $status.installState AppType = $appType Source = 'DeploymentStatus' } } } } } } } elseif ($FilterByType -ne 'All') { Write-Verbose 'FilterByType applies only when deployment data is used. Skipping type filter for detected apps only.' } Write-NCMessage "Preparing groups for $($appDeviceMap.Count) applications ..." -Level INFO $groupsCreated = 0 $groupsUpdated = 0 $totalDevicesProcessed = 0 $groupTargets = @() if ($useExplicitGroupName) { $allDevices = @($appDeviceMap.Values | ForEach-Object { $_.Devices } | Where-Object { $_ }) $allVersions = @{} $allPublishers = @{} foreach ($appInfo in $appDeviceMap.Values) { foreach ($version in $appInfo.Versions.Keys) { if (-not $allVersions.ContainsKey($version)) { $allVersions[$version] = 0 } $allVersions[$version] += $appInfo.Versions[$version] } foreach ($publisher in $appInfo.Publishers.Keys) { if (-not $allPublishers.ContainsKey($publisher)) { $allPublishers[$publisher] = 0 } $allPublishers[$publisher] += $appInfo.Publishers[$publisher] } } $groupTargets += [ordered]@{ AppName = if ($appDeviceMap.Keys.Count -gt 1) { 'Matching apps' } else { $appDeviceMap.Keys | Select-Object -First 1 } Devices = $allDevices Versions = $allVersions Publishers = $allPublishers GroupName = $GroupName GroupScope = 'ExplicitGroupName' } } else { foreach ($appName in $appDeviceMap.Keys) { $groupTargets += [ordered]@{ AppName = $appName Devices = $appDeviceMap[$appName].Devices Versions = $appDeviceMap[$appName].Versions Publishers = $appDeviceMap[$appName].Publishers GroupName = $null GroupScope = 'PerApp' } } } # Device and group names per target (targets without devices are skipped) $targetInfos = [System.Collections.Generic.List[object]]::new() foreach ($target in $groupTargets) { $uniqueDeviceIds = @( $target.Devices | ForEach-Object { Get-NCCoreProperty -Object $_ -Names @('DeviceId', 'deviceId', 'Id', 'id') } | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | Select-Object -Unique ) if ($uniqueDeviceIds.Count -eq 0) { $targetInfos.Add($null) continue } if ($useExplicitGroupName) { $targetGroupName = Get-NCIntuneAppBasedGroupName -GroupName $GroupName $targetGroupDescription = 'Devices matching selected Intune apps (Created via Nebula.Core)' } else { $targetGroupName = Get-NCIntuneAppBasedGroupName -AppName $target.AppName -GroupPrefix $GroupPrefix -GroupSuffix $GroupSuffix $targetGroupDescription = "Devices with $($target.AppName) installed (Created via Nebula.Core)" } $targetInfos.Add([pscustomobject]@{ UniqueDeviceIds = $uniqueDeviceIds GroupName = $targetGroupName GroupDescription = $targetGroupDescription }) } # Existing groups are looked up for all targets in Graph batches $groupLookup = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) # Failed lookups (name -> error): the group may exist, so the target is skipped rather than created $groupLookupFailed = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) if (-not $DryRun.IsPresent) { $lookupNames = @($targetInfos | Where-Object { $_ } | ForEach-Object { $_.GroupName } | Select-Object -Unique) if ($lookupNames.Count -gt 0 -and -not $batchNoticeWritten) { $batchNoticeWritten = Write-NCGraphBatchNotice -Count ($lookupNames.Count) -Noun 'group(s)' -PassThru } if ($lookupNames.Count -gt 0) { $groupRequests = @(for ($i = 0; $i -lt $lookupNames.Count; $i++) { $groupFilter = "displayName eq '$($lookupNames[$i].Replace("'", "''"))'" @{ Id = "g$i"; Method = 'GET'; Url = "/groups?`$filter=$([uri]::EscapeDataString($groupFilter))&`$select=id,displayName" } }) $groupResponses = @(Invoke-NCGraphBatchCollection -Requests $groupRequests -ApiVersion 'v1.0' -Activity 'Looking up existing groups') for ($i = 0; $i -lt $lookupNames.Count; $i++) { if ($groupResponses[$i].Success) { $groupLookup[$lookupNames[$i]] = @($groupResponses[$i].Items) | Select-Object -First 1 } else { $groupLookupFailed[$lookupNames[$i]] = [string]$groupResponses[$i].ErrorMessage } } } } # Membership writes (one POST/DELETE per device inside the batch, per-device outcome) $memberStats = @{ Added = 0; Removed = 0; Failed = 0 } $addGroupMembers = { param([string]$GroupId, [string]$GroupLabel, [object[]]$Members, [string]$FailureFormat) $addRequests = @(for ($i = 0; $i -lt $Members.Count; $i++) { @{ Id = "m$i"; Method = 'POST'; Url = "/groups/$([uri]::EscapeDataString($GroupId))/members/`$ref"; Body = @{ '@odata.id' = (Get-NCGraphDirectoryObjectUri -Id ([string]$Members[$i].EntraDeviceId)) } } }) $addResponses = @(Invoke-NCGraphBatch -Requests $addRequests -Activity "Adding devices to $GroupLabel") for ($i = 0; $i -lt $Members.Count; $i++) { $memberLabel = $Members[$i].DeviceName if ($addResponses[$i].Success) { $memberStats.Added++ Write-Verbose "Added device '$memberLabel' to group '$GroupLabel'" } elseif ($addResponses[$i].ErrorMessage -match 'added object references already exist') { Write-NCMessage "Device '$memberLabel' is already a member of '$GroupLabel'" -Level WARNING } else { Write-NCMessage ($FailureFormat -f $memberLabel, $addResponses[$i].ErrorMessage) -Level ERROR $memberStats.Failed++ } } } $removeGroupMembers = { param([string]$GroupId, [string]$GroupLabel, [object[]]$MemberIds, [hashtable]$MemberNames) $removeRequests = @(for ($i = 0; $i -lt $MemberIds.Count; $i++) { @{ Id = "r$i"; Method = 'DELETE'; Url = "/groups/$([uri]::EscapeDataString($GroupId))/members/$([uri]::EscapeDataString([string]$MemberIds[$i]))/`$ref" } }) $removeResponses = @(Invoke-NCGraphBatch -Requests $removeRequests -Activity "Removing members from $GroupLabel") for ($i = 0; $i -lt $MemberIds.Count; $i++) { if ($removeResponses[$i].Success) { $memberStats.Removed++ } elseif ($removeResponses[$i].Status -eq 404) { Write-Verbose "Member '$($MemberIds[$i])' is no longer a member of '$GroupLabel'" } else { $removeLabel = if ($MemberNames.ContainsKey([string]$MemberIds[$i])) { $MemberNames[[string]$MemberIds[$i]] } else { [string]$MemberIds[$i] } Write-NCMessage "Failed to remove device '$removeLabel' from '$GroupLabel': $($removeResponses[$i].ErrorMessage)" -Level ERROR } } } $processedApps = 0 for ($targetIndex = 0; $targetIndex -lt $groupTargets.Count; $targetIndex++) { $target = $groupTargets[$targetIndex] $processedApps++ $appName = $target.AppName $appInfo = $target $targetInfo = $targetInfos[$targetIndex] if (-not $targetInfo) { continue } $uniqueDeviceIds = $targetInfo.UniqueDeviceIds $deviceCount = $uniqueDeviceIds.Count $groupName = $targetInfo.GroupName $groupDescription = $targetInfo.GroupDescription $Percentage = ($processedApps / [Math]::Max($groupTargets.Count, 1)) * 100 Write-Progress -Activity 'Processing App Groups' -Status "$appName / $groupName - $processedApps of $($groupTargets.Count) groups - $Percentage%" -PercentComplete $Percentage if ($DryRun.IsPresent) { Write-NCMessage "[DRY RUN] Would create/update group: $groupName" -Level INFO Write-NCMessage "Total devices with app matches: $deviceCount" -Level INFO Write-Verbose "Devices to be added:" foreach ($device in $appInfo.Devices) { Write-Verbose " - $($device.DeviceName) ($($device.Platform))" } if ($appInfo.Versions.Count -gt 0) { Write-NCMessage "Versions found: $($appInfo.Versions.Keys -join ', ')" -Level INFO } $totalDevicesProcessed += $deviceCount continue } $existingGroup = $null if ($groupLookupFailed.ContainsKey($groupName)) { Write-NCMessage "Unable to look up existing group '$groupName', skipping it to avoid creating a duplicate: $($groupLookupFailed[$groupName])" -Level ERROR continue } elseif ($groupLookup.ContainsKey($groupName)) { $existingGroup = $groupLookup[$groupName] } if ($existingGroup -and -not $UpdateExisting.IsPresent) { Write-NCMessage "Group '$groupName' already exists. Use -UpdateExisting to update it." -Level WARNING continue } $entraDevices = @() # Removals are only safe when every matching device was resolved to an Entra device $resolutionIncomplete = $false $seenEntraDeviceIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) try { Write-Progress -Activity 'Resolving Entra Devices' -Status "$groupName - $deviceCount devices" -PercentComplete 0 foreach ($resolved in @(Resolve-NCIntuneManagedDeviceEntraMembers -ManagedDevices $devices -DeviceIds @($uniqueDeviceIds))) { if ($resolved.LookupFailed) { $resolutionIncomplete = $true } # Two Intune devices can map to the same Entra device: add it once if ($resolved.Resolution -and $seenEntraDeviceIds.Add([string]$resolved.Resolution.EntraDeviceId)) { $entraDevices += @{ IntuneDeviceId = $resolved.Resolution.IntuneDeviceId EntraDeviceId = $resolved.Resolution.EntraDeviceId DeviceName = $resolved.Resolution.DeviceName } } } } catch { Write-NCMessage "Error looking up Entra ID devices for group $($groupName): $($_.Exception.Message)" -Level ERROR $resolutionIncomplete = $true } Write-Progress -Activity 'Resolving Entra Devices' -Completed if ($existingGroup -and $UpdateExisting.IsPresent) { if ($PSCmdlet.ShouldProcess($groupName, 'Update group members')) { try { $currentMembersUri = "v1.0/groups/$($existingGroup.id)/members" $currentMembers = @(Invoke-NCGraphAllPagesCore -Uri $currentMembersUri) $currentMemberIds = $currentMembers | ForEach-Object { $_.id } $currentMemberNames = @{} foreach ($currentMember in $currentMembers) { if ($currentMember.id -and $currentMember.displayName) { $currentMemberNames[[string]$currentMember.id] = [string]$currentMember.displayName } } $entraDeviceIds = $entraDevices | ForEach-Object { $_.EntraDeviceId } $deviceIdsToAdd = @($entraDeviceIds | Where-Object { $_ -notin $currentMemberIds }) $deviceIdsToRemove = @($currentMemberIds | Where-Object { $_ -notin $entraDeviceIds }) if ($resolutionIncomplete -and $deviceIdsToRemove.Count -gt 0) { Write-NCMessage "Entra device resolution for group '$groupName' is incomplete. No members will be removed from it in this run." -Level WARNING $deviceIdsToRemove = @() } $devicesToAdd = @($entraDevices | Where-Object { $_.EntraDeviceId -in $deviceIdsToAdd }) $memberStats.Added = 0 $memberStats.Removed = 0 $memberStats.Failed = 0 if ($devicesToAdd.Count -gt 0) { & $addGroupMembers -GroupId ([string]$existingGroup.id) -GroupLabel $groupName -Members $devicesToAdd -FailureFormat "Failed to add device '{0}' to '$($groupName.Replace('{', '{{').Replace('}', '}}'))': {1}" } # Removing the old members after a failed addition could leave the group empty or incomplete if ($memberStats.Failed -gt 0 -and $deviceIdsToRemove.Count -gt 0) { Write-NCMessage "$($memberStats.Failed) device(s) could not be added to '$groupName'. No members will be removed from it in this run." -Level WARNING $deviceIdsToRemove = @() } if ($deviceIdsToRemove.Count -gt 0) { & $removeGroupMembers -GroupId ([string]$existingGroup.id) -GroupLabel $groupName -MemberIds $deviceIdsToRemove -MemberNames $currentMemberNames } Write-NCMessage "Updated group: $groupName (Added: $($memberStats.Added), Removed: $($memberStats.Removed))" -Level SUCCESS if ($deviceIdsToAdd.Count -gt 0) { Write-Verbose "Added devices:" foreach ($deviceId in $deviceIdsToAdd) { $deviceInfo = $entraDevices | Where-Object { $_.EntraDeviceId -eq $deviceId } | Select-Object -First 1 if ($deviceInfo) { Write-Verbose " - $($deviceInfo.DeviceName)" } } } $groupsUpdated++ } catch { Write-NCMessage "Failed to update group: $($_.Exception.Message)" -Level ERROR } } } else { if ($PSCmdlet.ShouldProcess($groupName, 'Create new group')) { try { $groupBody = @{ displayName = $groupName mailEnabled = $false mailNickname = ($groupName -replace '[^a-zA-Z0-9]', '') securityEnabled = $true description = $groupDescription } | ConvertTo-Json -Depth 10 $newGroup = Invoke-MgGraphRequest -Uri 'v1.0/groups' -Method POST -Body $groupBody -ContentType 'application/json' Write-NCMessage "Created group: $groupName $($newGroup.id)" -Level SUCCESS $groupLookup[$groupName] = [pscustomobject]@{ id = $newGroup.id; displayName = $groupName } $null = $groupLookupFailed.Remove($groupName) if ($entraDevices.Count -gt 0) { try { $memberStats.Added = 0 & $addGroupMembers -GroupId ([string]$newGroup.id) -GroupLabel $groupName -Members $entraDevices -FailureFormat "Group created but failed to add device '{0}': {1}" if ($memberStats.Added -gt 0) { $groupDeviceLabel = if ($memberStats.Added -eq 1) { 'device' } else { 'devices' } Write-NCMessage "Added $($memberStats.Added) $groupDeviceLabel to group" -Level SUCCESS Write-Verbose "Added devices:" foreach ($device in $entraDevices) { Write-Verbose " - $($device.DeviceName)" } } } catch { Write-NCMessage "Group created but failed to add members: $($_.Exception.Message)" -Level ERROR } } $groupsCreated++ } catch { Write-NCMessage "Failed to create group: $($_.Exception.Message)" -Level ERROR Write-Verbose "Group body: $groupBody" } } } $totalDevicesProcessed += $deviceCount } Write-Progress -Activity 'Processing App Groups' -Completed # Add-EmptyLine # Write-NCMessage " - Applications matched: $($appDeviceMap.Count)" -Level INFO # Write-NCMessage " - Total devices processed: $totalDevicesProcessed" -Level INFO # Write-NCMessage " - Groups created: $groupsCreated" -Level INFO # Write-NCMessage " - Groups updated: $groupsUpdated" -Level INFO if ($DryRun.IsPresent) { Write-NCMessage "[DRY RUN] No changes were made" -Level INFO } if ($appDeviceMap.Count -gt 0) { Add-EmptyLine Write-NCMessage "Top Applications by Device Count:" -Level INFO $appDeviceMap.GetEnumerator() | Sort-Object { if ($_.Value.DeviceIds) { $_.Value.DeviceIds.Count } else { $_.Value.Devices.Count } } -Descending | Select-Object -First 10 | ForEach-Object { $deviceCount = if ($_.Value.DeviceIds) { $_.Value.DeviceIds.Count } else { ($_.Value.Devices | Select-Object -Property DeviceId -Unique).Count } $deviceLabel = if ($deviceCount -eq 1) { 'device' } else { 'devices' } Write-NCMessage " - $($_.Key): $deviceCount $deviceLabel" -Level INFO } } Add-EmptyLine Write-NCMessage "App-based group creation completed successfully." -Level SUCCESS } catch { Write-NCMessage "Script execution failed: $($_.Exception.Message)" -Level ERROR return } } } |