Create App-Based Entra ID Groups
This script queries Intune-managed devices to identify which applications are installed, then creates or updates Entra ID groups containing devices with specific applications. It supports multiple detection methods including detected apps and deployment status, handles all app types (Win32, Store, LOB, Web apps), and provides flexible group creation options. Perfect for dynamic device targeting based on installed software.
New to runbook deployment? Follow the step-by-step guide from the Deploy to Azure button to the first scheduled run, including granting Graph permissions to the managed identity.
// QUALITY CHECKS
Validation status
Quality checks
All checks pass- ParsePass
- LintPass
- MetadataPass
- Runbook-readyPass
- Module depsPass
Tests run automatically on every change. What does each check mean?
// REQUIRED PERMISSIONS
Microsoft Graph scopes
DeviceManagementManagedDevices.Read.AllAllows the app to read the properties of devices managed by Microsoft Intune, without a signed-in user.
DeviceManagementApps.Read.AllAllows the app to read the properties, group assignments and status of apps, app configurations and app protection policies managed by Microsoft Intune, without a signed-in user.
Group.ReadWrite.AllDirectory.Read.AllRunning this as an Azure Automation runbook? These scopes must be granted to the account's managed identity, which has no portal UI. The deployment walkthrough shows the exact Cloud Shell commands.
// CHANGELOG
Version history
Entry · 01
1.4 - Added Azure Automation contract validation, portal-safe boolean parameters, beta Graph endpoints, and terminating paging errors
Entry · 02
1.3 - Azure Automation now records script progress, outcomes, and summaries in job history
Entry · 03
1.2 - Apply -MinimumVersion to report-based rows, suppress progress bars in runbooks, flag apps with incomplete report data, use hashtable device lookup, and limit list calls with select
Entry · 04
1.1 - Local runs now use MgGraphCommunity for WAM-free interactive sign-in (auto-installed if missing); app install status now read via deviceManagement/reports (mobileApps deviceStatuses was retired from the Graph service)
Entry · 05
1.0 - Initial release
// CODE
Source
<#
.TITLE
Create App-Based Entra ID Groups
.SYNOPSIS
Creates Entra ID groups based on applications installed on Intune-managed devices.
.DESCRIPTION
This script queries Intune-managed devices to identify which applications are installed,
then creates or updates Entra ID groups containing devices with specific applications.
It supports multiple detection methods including detected apps and deployment status,
handles all app types (Win32, Store, LOB, Web apps), and provides flexible group
creation options. Perfect for dynamic device targeting based on installed software.
.TAGS
Devices
.MINROLE
Intune Administrator
.PERMISSIONS
DeviceManagementManagedDevices.Read.All,DeviceManagementApps.Read.All,Group.ReadWrite.All,Directory.Read.All
.AUTHOR
Ugur Koc
.VERSION
1.4
.CHANGELOG
1.4 - Added Azure Automation contract validation, portal-safe boolean parameters, beta Graph endpoints, and terminating paging errors
1.3 - Azure Automation now records script progress, outcomes, and summaries in job history
1.2 - Apply -MinimumVersion to report-based rows, suppress progress bars in runbooks, flag apps with incomplete report data, use hashtable device lookup, and limit list calls with select
1.1 - Local runs now use MgGraphCommunity for WAM-free interactive sign-in (auto-installed if missing); app install status now read via deviceManagement/reports (mobileApps deviceStatuses was retired from the Graph service)
1.0 - Initial release
.LASTUPDATE
2026-07-30
.EXAMPLE
.\create-app-based-groups.ps1 -ApplicationName "TeamViewer"
Creates a group named "Devices-With-TeamViewer" containing all devices with TeamViewer installed
.EXAMPLE
.\create-app-based-groups.ps1 -ApplicationName "Microsoft*" -GroupPrefix "SW-" -GroupSuffix "-Installed"
Creates groups for all Microsoft apps with custom naming (e.g., "SW-Microsoft Teams-Installed")
.EXAMPLE
.\create-app-based-groups.ps1 -ApplicationName "Chrome" -MinimumVersion "120.0" -UpdateExisting "true"
Creates/updates a group with devices having Chrome version 120.0 or higher
.EXAMPLE
.\create-app-based-groups.ps1 -ApplicationName "*" -FilterByType "Win32" -DryRun "true"
Preview groups that would be created for all Win32 applications
.NOTES
- Requires Microsoft.Graph.Authentication module
- Supports wildcards in application names
- Can create multiple groups in a single run
- Uses both detected apps and deployment status for comprehensive coverage
- Groups are created as security groups by default
- Device limit per group is 100,000 (Entra ID limitation)
- Local interactive sign-in uses the MgGraphCommunity module to avoid the Graph SDK's mandatory WAM broker on Windows
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory = $true, HelpMessage = "Application name or pattern (supports wildcards)")]
[string]$ApplicationName,
[Parameter(Mandatory = $false, HelpMessage = "Prefix for group names")]
[string]$GroupPrefix = "Devices-With-",
[Parameter(Mandatory = $false, HelpMessage = "Suffix for group names")]
[string]$GroupSuffix = "",
[Parameter(Mandatory = $false, HelpMessage = "Update existing groups instead of creating new")]
[ValidateSet("true", "false", "1", "0", '$true', '$false')]
[string]$UpdateExisting,
[Parameter(Mandatory = $false, HelpMessage = "Minimum application version")]
[string]$MinimumVersion,
[Parameter(Mandatory = $false, HelpMessage = "Filter by app type (Win32, Store, LOB, Web, etc.)")]
[ValidateSet("Win32", "Store", "LOB", "Web", "iOS", "Android", "macOS", "All")]
[string]$FilterByType = "All",
[Parameter(Mandatory = $false, HelpMessage = "Filter by device platform")]
[ValidateSet("Windows", "iOS", "Android", "macOS", "All")]
[string]$FilterByPlatform = "All",
[Parameter(Mandatory = $false, HelpMessage = "Only include devices with successful installations")]
[ValidateSet("true", "false", "1", "0", '$true', '$false')]
[string]$OnlySuccessfulInstalls,
[Parameter(Mandatory = $false, HelpMessage = "Preview changes without creating groups")]
[ValidateSet("true", "false", "1", "0", '$true', '$false')]
[string]$DryRun,
[Parameter(Mandatory = $false, HelpMessage = "Maximum devices to process (0 = all)")]
[int]$MaxDevices = 0,
[Parameter(Mandatory = $false, HelpMessage = "Force module installation without prompting")]
[ValidateSet("true", "false", "1", "0", '$true', '$false')]
[string]$ForceModuleInstall
)
# Normalize the local module-install override for Azure Automation parameter binding.
$forceModuleInstallRaw = [string]$ForceModuleInstall
Remove-Variable -Name ForceModuleInstall
if ([string]::IsNullOrWhiteSpace($forceModuleInstallRaw)) {
$ForceModuleInstall = $false
}
elseif ($forceModuleInstallRaw.Trim().ToLowerInvariant() -in @("true", "1", '$true')) {
$ForceModuleInstall = $true
}
elseif ($forceModuleInstallRaw.Trim().ToLowerInvariant() -in @("false", "0", '$false')) {
$ForceModuleInstall = $false
}
else {
throw "Parameter 'ForceModuleInstall' accepts only true, false, 1, 0, $true, or $false."
}
# Azure Automation supplies portal parameter values as strings. Normalize the
# public boolean parameters once so local and runbook execution use real booleans.
foreach ($runbookBooleanParameter in @('UpdateExisting', 'OnlySuccessfulInstalls', 'DryRun')) {
$runbookBooleanRaw = [string](Get-Variable -Name $runbookBooleanParameter -ValueOnly)
Remove-Variable -Name $runbookBooleanParameter
if ([string]::IsNullOrWhiteSpace($runbookBooleanRaw)) {
Set-Variable -Name $runbookBooleanParameter -Value $false
continue
}
switch ($runbookBooleanRaw.Trim().ToLowerInvariant()) {
{ $_ -in @("true", "1", '$true') } {
Set-Variable -Name $runbookBooleanParameter -Value $true
}
{ $_ -in @("false", "0", '$false') } {
Set-Variable -Name $runbookBooleanParameter -Value $false
}
default {
throw "Parameter '$runbookBooleanParameter' accepts only true, false, 1, 0, $true, or $false."
}
}
}
# ============================================================================
# ENVIRONMENT DETECTION AND SETUP
# ============================================================================
function Initialize-RequiredModule {
param(
[string[]]$ModuleNames,
[bool]$IsAutomationEnvironment,
[bool]$ForceInstall = $false
)
foreach ($ModuleName in $ModuleNames) {
Write-Verbose "Checking module: $ModuleName"
$module = Get-Module -ListAvailable -Name $ModuleName | Select-Object -First 1
if (-not $module) {
if ($IsAutomationEnvironment) {
throw "Module '$ModuleName' is not available in Azure Automation"
}
else {
Write-Information "Module '$ModuleName' not found. Installing..." -InformationAction Continue
if (-not $ForceInstall) {
$response = Read-Host "Install module '$ModuleName'? (Y/N)"
if ($response -notmatch '^[Yy]') {
throw "Module '$ModuleName' is required but installation was declined."
}
}
try {
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")
$scope = if ($isAdmin) { "AllUsers" } else { "CurrentUser" }
Install-Module -Name $ModuleName -Scope $scope -Force -AllowClobber -Repository PSGallery
Write-Information "✓ Successfully installed '$ModuleName'" -InformationAction Continue
}
catch {
throw "Failed to install module '$ModuleName': $($_.Exception.Message)"
}
}
}
Import-Module -Name $ModuleName -Force -ErrorAction Stop
}
}
# Detect execution environment
$IsAzureAutomation = $null -ne $PSPrivateMetadata.JobId.Guid
# Initialize required modules
$RequiredModules = @("Microsoft.Graph.Authentication")
# MgGraphCommunity gives WAM-free interactive sign-in for local runs
if (-not $IsAzureAutomation) {
$RequiredModules += "MgGraphCommunity"
}
try {
Initialize-RequiredModule -ModuleNames $RequiredModules -IsAutomationEnvironment $IsAzureAutomation -ForceInstall $ForceModuleInstall
Write-Verbose "✓ All required modules are available"
}
catch {
Write-Error "Module initialization failed: $_"
exit 1
}
# ============================================================================
# AUTHENTICATION
# ============================================================================
try {
if ($IsAzureAutomation) {
Write-Output "Connecting to Microsoft Graph using Managed Identity..."
Connect-MgGraph -Identity -NoWelcome -ErrorAction Stop
}
else {
Write-Output "Connecting to Microsoft Graph..."
$Scopes = @(
"DeviceManagementManagedDevices.Read.All",
"DeviceManagementApps.Read.All",
"Group.ReadWrite.All",
"Directory.Read.All"
)
Connect-MgGraphCommunity -Scopes $Scopes -NoWelcome -ErrorAction Stop
}
Write-Output "✓ Successfully connected to Microsoft Graph"
}
catch {
Write-Error "Failed to connect to Microsoft Graph: $($_.Exception.Message)"
exit 1
}
# ============================================================================
# HELPER FUNCTIONS
# ============================================================================
function Get-MgGraphAllPage {
param(
[string]$Uri,
[int]$DelayMs = 100
)
$allResults = @()
$nextLink = $Uri
$requestCount = 0
do {
try {
if ($requestCount -gt 0) {
Start-Sleep -Milliseconds $DelayMs
}
$response = Invoke-MgGraphRequest -Uri $nextLink -Method GET
$requestCount++
if ($null -ne $response.value) {
$allResults += $response.value
}
else {
$allResults += $response
}
$nextLink = $response.'@odata.nextLink'
if ($requestCount % 10 -eq 0) {
Write-Information "." -InformationAction Continue
}
}
catch {
if ($_.Exception.Message -like "*429*") {
Write-Information "`nRate limit hit, waiting 60 seconds..." -InformationAction Continue
Start-Sleep -Seconds 60
continue
}
throw "Error fetching data: $($_.Exception.Message)"
}
} while ($nextLink)
return $allResults
}
# Retrieves app installation status rows from the reports endpoint with paging
# (the mobileApps deviceStatuses endpoint was retired from the Graph service)
function Get-AppInstallStatusReportRow {
param(
[string]$AppId,
[int]$PageSize = 500,
[int]$DelayMs = 100
)
$reportUri = "https://graph.microsoft.com/beta/deviceManagement/reports/retrieveDeviceAppInstallationStatusReport"
$allRows = @()
$skip = 0
# MaxValue sentinel keeps the loop alive if the very first page hits a 429
# (a zero sentinel would end the do/while before any retry could happen)
$totalRows = [int]::MaxValue
do {
try {
if ($skip -gt 0) {
Start-Sleep -Milliseconds $DelayMs
}
$body = @{
filter = "(ApplicationId eq '$AppId')"
top = $PageSize
skip = $skip
select = @("DeviceId", "DeviceName", "UserPrincipalName", "Platform", "AppVersion", "InstallState", "InstallStateDetail", "ErrorCode", "LastModifiedDateTime")
} | ConvertTo-Json -Depth 5
$response = Invoke-MgGraphRequest -Uri $reportUri -Method POST -Body $body -ContentType "application/json"
# The report returns columnar JSON - map Schema columns to row indexes,
# column order is declared by Schema, not by the request
$columnIndex = @{}
for ($i = 0; $i -lt $response['Schema'].Count; $i++) {
$columnIndex[$response['Schema'][$i].Column] = $i
}
foreach ($row in $response['Values']) {
$allRows += [PSCustomObject]@{
DeviceId = $row[$columnIndex['DeviceId']]
DeviceName = $row[$columnIndex['DeviceName']]
UserPrincipalName = $row[$columnIndex['UserPrincipalName']]
Platform = $row[$columnIndex['Platform']]
AppVersion = $row[$columnIndex['AppVersion']]
InstallState = $row[$columnIndex['InstallState']]
InstallStateDetail = $row[$columnIndex['InstallStateDetail']]
ErrorCode = $row[$columnIndex['ErrorCode']]
LastModifiedDateTime = $row[$columnIndex['LastModifiedDateTime']]
}
}
$totalRows = $response['TotalRowCount']
$skip += $PageSize
}
catch {
if ($_.Exception.Message -like "*429*") {
Write-Information "`nRate limit hit, waiting 60 seconds..." -InformationAction Continue
Start-Sleep -Seconds 60
continue
}
Write-Warning "Error fetching install status report for app $AppId : $($_.Exception.Message)"
$script:incompleteReportApps[$AppId] = $true
break
}
} while ($skip -lt $totalRows)
# Comma preserves a single-row result as an array so .Count is correct
return , $allRows
}
# Converts report InstallState values to install state names
# Values per the resultantAppState enum (Microsoft Graph beta)
function Convert-InstallStateValue {
param($StateValue)
switch ([int]$StateValue) {
1 { return "installed" }
2 { return "failed" }
3 { return "notInstalled" }
4 { return "uninstallFailed" }
5 { return "pendingInstall" }
99 { return "unknown" }
-1 { return "notApplicable" }
default { return "unknown" }
}
}
function Get-AppTypeFromODataType {
param([string]$ODataType)
switch ($ODataType) {
"#microsoft.graph.win32LobApp" { return "Win32" }
"#microsoft.graph.microsoftStoreForBusinessApp" { return "Store" }
"#microsoft.graph.webApp" { return "Web" }
"#microsoft.graph.officeSuiteApp" { return "Office" }
"#microsoft.graph.winGetApp" { return "WinGet" }
"#microsoft.graph.iosLobApp" { return "iOS" }
"#microsoft.graph.iosStoreApp" { return "iOS" }
"#microsoft.graph.androidManagedStoreApp" { return "Android" }
"#microsoft.graph.androidLobApp" { return "Android" }
"#microsoft.graph.macOSLobApp" { return "macOS" }
"#microsoft.graph.macOSOfficeSuiteApp" { return "macOS" }
default { return "Other" }
}
}
function Compare-Version {
param(
[string]$Version1,
[string]$Version2
)
try {
$v1 = [Version]$Version1
$v2 = [Version]$Version2
return $v1 -ge $v2
}
catch {
# Fallback to string comparison if version parsing fails
return $Version1 -ge $Version2
}
}
function Get-SanitizedGroupName {
param([string]$AppName)
# Remove invalid characters for group names
$sanitized = $AppName -replace '[^\w\s-]', ''
$sanitized = $sanitized -replace '\s+', '-'
$sanitized = $sanitized -replace '-+', '-'
$sanitized = $sanitized.Trim('-')
# Ensure the name is not too long (max 256 chars for Entra ID)
$maxLength = 256 - $GroupPrefix.Length - $GroupSuffix.Length
if ($sanitized.Length -gt $maxLength) {
$sanitized = $sanitized.Substring(0, $maxLength)
}
return "${GroupPrefix}${sanitized}${GroupSuffix}"
}
# ============================================================================
# MAIN SCRIPT LOGIC
# ============================================================================
try {
Write-Output "Starting app-based group creation process..."
# Get all managed devices
Write-Output "Retrieving managed devices..."
$devicesUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$select=id,deviceName,operatingSystem,userPrincipalName,azureADDeviceId"
if ($MaxDevices -gt 0) {
$devicesUri += "&`$top=$MaxDevices"
}
$devices = Get-MgGraphAllPage -Uri $devicesUri
# Filter by platform if specified
if ($FilterByPlatform -ne "All") {
$devices = $devices | Where-Object {
$_.operatingSystem -like "$FilterByPlatform*"
}
}
Write-Output "`n✓ Found $($devices.Count) managed devices"
# Hashtable for fast device lookup by Intune device id
$deviceById = @{}
foreach ($device in $devices) {
$deviceById[$device.id] = $device
}
# Dictionary to store app->devices mapping
$appDeviceMap = @{}
$processedDevices = 0
# Tracks apps whose install status report could not be fully retrieved
$script:incompleteReportApps = @{}
# Process devices to get detected apps
Write-Output "Processing device applications..."
foreach ($device in $devices) {
$processedDevices++
if (-not $IsAzureAutomation) {
Write-Progress -Activity "Processing Devices" -Status "$processedDevices of $($devices.Count)" -PercentComplete (($processedDevices / $devices.Count) * 100)
}
try {
# Get detected apps for the device
$deviceAppsUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($device.id)?`$expand=detectedApps"
$deviceWithApps = Invoke-MgGraphRequest -Uri $deviceAppsUri -Method GET
if ($deviceWithApps.detectedApps) {
foreach ($app in $deviceWithApps.detectedApps) {
# Check if app matches the filter
if ($app.displayName -like $ApplicationName) {
# Check version if specified
if ($MinimumVersion -and $app.version) {
if (-not (Compare-Version -Version1 $app.version -Version2 $MinimumVersion)) {
continue
}
}
# Add device to app mapping
$appKey = $app.displayName
if (-not $appDeviceMap.ContainsKey($appKey)) {
$appDeviceMap[$appKey] = @{
Devices = @()
Versions = @{}
Publishers = @{}
}
}
$appDeviceMap[$appKey].Devices += @{
DeviceId = $device.id
DeviceName = $device.deviceName
Platform = $device.operatingSystem
User = $device.userPrincipalName
Version = $app.version
Publisher = $app.publisher
}
# Track versions and publishers
if ($app.version) {
$currentVersionCount = $appDeviceMap[$appKey].Versions[$app.version]
if ($null -eq $currentVersionCount) { $currentVersionCount = 0 }
$appDeviceMap[$appKey].Versions[$app.version] = $currentVersionCount + 1
}
if ($app.publisher) {
$currentPublisherCount = $appDeviceMap[$appKey].Publishers[$app.publisher]
if ($null -eq $currentPublisherCount) { $currentPublisherCount = 0 }
$appDeviceMap[$appKey].Publishers[$app.publisher] = $currentPublisherCount + 1
}
}
}
}
Start-Sleep -Milliseconds 50
}
catch {
if ($_.Exception.Message -like "*429*") {
Write-Output "`nRate limit hit, waiting 60 seconds..."
Start-Sleep -Seconds 60
$processedDevices--
continue
}
Write-Warning "Error processing device $($device.deviceName): $($_.Exception.Message)"
}
}
if (-not $IsAzureAutomation) {
Write-Progress -Activity "Processing Devices" -Completed
}
# Get deployed apps if we need additional coverage
if ($FilterByType -ne "All" -or $OnlySuccessfulInstalls) {
Write-Output "Retrieving deployed application data..."
$appsUri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps?`$select=id,displayName"
$deployedApps = Get-MgGraphAllPage -Uri $appsUri
foreach ($app in $deployedApps) {
if ($app.displayName -like $ApplicationName) {
$appType = Get-AppTypeFromODataType -ODataType $app.'@odata.type'
# Filter by type if specified
if ($FilterByType -ne "All" -and $appType -ne $FilterByType) {
continue
}
# Get device installation status via the reports endpoint
$deviceStatuses = Get-AppInstallStatusReportRow -AppId $app.id
foreach ($status in $deviceStatuses) {
# Filter by installation status if specified (InstallState 1 = installed)
if ($OnlySuccessfulInstalls -and $status.InstallState -ne 1) {
continue
}
# Check version if specified
if ($MinimumVersion) {
if ([string]::IsNullOrWhiteSpace($status.AppVersion)) {
Write-Verbose "Skipping device $($status.DeviceName) for '$($app.displayName)': report row has no AppVersion to compare"
continue
}
if (-not (Compare-Version -Version1 $status.AppVersion -Version2 $MinimumVersion)) {
continue
}
}
# Find matching device
$matchingDevice = $deviceById[$status.DeviceId]
if ($matchingDevice) {
$appKey = $app.displayName
if (-not $appDeviceMap.ContainsKey($appKey)) {
$appDeviceMap[$appKey] = @{
Devices = @()
Versions = @{}
Publishers = @{}
AppType = $appType
}
}
# Check if device already added
$existingDevice = $appDeviceMap[$appKey].Devices | Where-Object { $_.DeviceId -eq $matchingDevice.id }
if (-not $existingDevice) {
$appDeviceMap[$appKey].Devices += @{
DeviceId = $matchingDevice.id
DeviceName = $matchingDevice.deviceName
Platform = $matchingDevice.operatingSystem
User = $matchingDevice.userPrincipalName
InstallState = Convert-InstallStateValue -StateValue $status.InstallState
AppType = $appType
}
}
}
}
}
}
}
# Create or update groups
Write-Output "`nProcessing groups for $($appDeviceMap.Count) applications..."
$groupsCreated = 0
$groupsUpdated = 0
$totalDevicesProcessed = 0
foreach ($appName in $appDeviceMap.Keys) {
$appInfo = $appDeviceMap[$appName]
$uniqueDevices = $appInfo.Devices | Select-Object -Property DeviceId -Unique
$deviceCount = $uniqueDevices.Count
if ($deviceCount -eq 0) {
continue
}
$groupName = Get-SanitizedGroupName -AppName $appName
Write-Output "`nProcessing: $appName ($deviceCount devices in Intune)"
if ($DryRun) {
Write-Output " [DRY RUN] Would create/update group: $groupName"
Write-Output " Total devices with app: $deviceCount"
# Show device names
Write-Output " Devices to be added:"
foreach ($device in $appInfo.Devices) {
Write-Output " • $($device.DeviceName) ($($device.Platform))"
}
if ($appInfo.Versions.Count -gt 0) {
Write-Output " Versions found: $($appInfo.Versions.Keys -join ', ')"
}
$totalDevicesProcessed += $deviceCount
continue
}
# Check if group exists
$existingGroup = $null
try {
$groupFilter = "displayName eq '$groupName'"
$existingGroups = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/groups?`$filter=$groupFilter" -Method GET
$existingGroup = $existingGroups.value | Select-Object -First 1
}
catch {
Write-Verbose "No existing group found with name: $groupName"
}
if ($existingGroup -and -not $UpdateExisting) {
Write-Warning " Group '$groupName' already exists. Use -UpdateExisting to update it."
continue
}
# Prepare member list - need to get Entra ID device object IDs
$memberIds = @()
$entraDevices = @()
if ($uniqueDevices.Count -gt 0) {
Write-Verbose "Looking up Entra ID device objects for $($uniqueDevices.Count) devices..."
foreach ($device in $uniqueDevices) {
try {
# Get the Intune device details first
$intuneDevice = $devices | Where-Object { $_.id -eq $device.DeviceId } | Select-Object -First 1
if ($intuneDevice -and $intuneDevice.azureADDeviceId) {
# Look up the device in Entra ID by Entra ID Device ID
$filter = "deviceId eq '$($intuneDevice.azureADDeviceId)'"
$entraDeviceUri = "https://graph.microsoft.com/beta/devices?`$filter=$filter"
$entraDeviceResponse = Invoke-MgGraphRequest -Uri $entraDeviceUri -Method GET
if ($entraDeviceResponse.value -and $entraDeviceResponse.value.Count -gt 0) {
$entraDevice = $entraDeviceResponse.value[0]
$memberIds += "https://graph.microsoft.com/beta/directoryObjects/$($entraDevice.id)"
$entraDevices += @{
IntuneDeviceId = $device.DeviceId
EntraDeviceId = $entraDevice.id
DeviceName = $intuneDevice.deviceName
}
Write-Verbose "Found Entra ID device: $($intuneDevice.deviceName) -> $($entraDevice.id)"
}
else {
Write-Warning "Device not found in Entra ID: $($intuneDevice.deviceName) (Entra ID Device ID: $($intuneDevice.azureADDeviceId))"
}
}
else {
Write-Warning "No Entra ID Device ID for: $($intuneDevice.deviceName)"
}
}
catch {
Write-Warning "Error looking up Entra ID device for $($intuneDevice.deviceName): $($_.Exception.Message)"
}
}
Write-Verbose "Found $($memberIds.Count) devices in Entra ID out of $($uniqueDevices.Count) Intune devices"
}
if ($existingGroup -and $UpdateExisting) {
# Update existing group
if ($PSCmdlet.ShouldProcess($groupName, "Update group members")) {
try {
# Get current members
$currentMembersUri = "https://graph.microsoft.com/beta/groups/$($existingGroup.id)/members"
$currentMembers = Get-MgGraphAllPage -Uri $currentMembersUri
$currentMemberIds = $currentMembers | ForEach-Object { $_.id }
# Calculate additions and removals - use Entra device IDs
$entraDeviceIds = $entraDevices | ForEach-Object { $_.EntraDeviceId }
$deviceIdsToAdd = $entraDeviceIds | Where-Object { $_ -notin $currentMemberIds }
$deviceIdsToRemove = $currentMemberIds | Where-Object { $_ -notin $entraDeviceIds }
# Add new members
if ($deviceIdsToAdd.Count -gt 0) {
# Add members in batches
$batchSize = 20
for ($i = 0; $i -lt $deviceIdsToAdd.Count; $i += $batchSize) {
$batch = $deviceIdsToAdd[$i..([Math]::Min($i + $batchSize - 1, $deviceIdsToAdd.Count - 1))]
$addBody = @{
"members@odata.bind" = $batch | ForEach-Object {
"https://graph.microsoft.com/beta/directoryObjects/$_"
}
} | ConvertTo-Json -Depth 10
Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/groups/$($existingGroup.id)" -Method PATCH -Body $addBody -ContentType "application/json"
Write-Verbose "Added batch of $($batch.Count) members"
}
}
# Remove old members
foreach ($memberId in $deviceIdsToRemove) {
$removeUri = "https://graph.microsoft.com/beta/groups/$($existingGroup.id)/members/$memberId/`$ref"
Invoke-MgGraphRequest -Uri $removeUri -Method DELETE
}
Write-Output " ✓ Updated group: $groupName (Added: $($deviceIdsToAdd.Count), Removed: $($deviceIdsToRemove.Count))"
# Display added devices
if ($deviceIdsToAdd.Count -gt 0) {
Write-Output " Added devices:"
foreach ($deviceId in $deviceIdsToAdd) {
$deviceInfo = $entraDevices | Where-Object { $_.EntraDeviceId -eq $deviceId } | Select-Object -First 1
if ($deviceInfo) {
Write-Output " • $($deviceInfo.DeviceName)"
}
}
}
$groupsUpdated++
}
catch {
Write-Error " ✗ Failed to update group: $($_.Exception.Message)"
}
}
}
else {
# Create new group
if ($PSCmdlet.ShouldProcess($groupName, "Create new group")) {
try {
# Create group without members first
$groupBody = @{
displayName = $groupName
mailEnabled = $false
mailNickname = $groupName -replace '[^a-zA-Z0-9]', ''
securityEnabled = $true
description = "Devices with $appName installed (Created by Intune Automation)"
} | ConvertTo-Json -Depth 10
$newGroup = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/groups" -Method POST -Body $groupBody -ContentType "application/json"
Write-Output " ✓ Created group: $groupName"
Write-Output " Group ID: $($newGroup.id)"
# Add members to the group if any
if ($memberIds.Count -gt 0) {
try {
# Add members in batches of 20 (Graph API limitation)
$batchSize = 20
for ($i = 0; $i -lt $memberIds.Count; $i += $batchSize) {
$batch = $memberIds[$i..([Math]::Min($i + $batchSize - 1, $memberIds.Count - 1))]
$addMembersBody = @{
"members@odata.bind" = $batch
} | ConvertTo-Json -Depth 10
Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/groups/$($newGroup.id)" -Method PATCH -Body $addMembersBody -ContentType "application/json"
Write-Verbose "Added batch of $($batch.Count) members"
}
Write-Output " ✓ Added $($memberIds.Count) devices to group"
# Display added devices
Write-Output " Added devices:"
foreach ($device in $entraDevices) {
Write-Output " • $($device.DeviceName)"
}
}
catch {
Write-Warning " Group created but failed to add members: $($_.Exception.Message)"
}
}
$groupsCreated++
}
catch {
Write-Error " ✗ Failed to create group: $($_.Exception.Message)"
Write-Verbose "Group body: $groupBody"
}
}
}
$totalDevicesProcessed += $deviceCount
}
# Display summary
Write-Output "`n📊 APP-BASED GROUP CREATION SUMMARY"
Write-Output "==================================="
Write-Output "Applications matched: $($appDeviceMap.Count)"
Write-Output "Total devices processed: $totalDevicesProcessed"
Write-Output "Groups created: $groupsCreated"
Write-Output "Groups updated: $groupsUpdated"
if ($script:incompleteReportApps.Count -gt 0) {
Write-Warning "$($script:incompleteReportApps.Count) apps had incomplete report data - their device lists may be missing rows"
}
if ($DryRun) {
Write-Output "`n[DRY RUN] No changes were made"
}
# Display top apps by device count
if ($appDeviceMap.Count -gt 0) {
Write-Output "`nTop Applications by Device Count:"
$appDeviceMap.GetEnumerator() |
Sort-Object { $_.Value.Devices.Count } -Descending |
Select-Object -First 10 |
ForEach-Object {
$deviceCount = ($_.Value.Devices | Select-Object -Property DeviceId -Unique).Count
Write-Output " • $($_.Key): $deviceCount devices"
}
}
Write-Output "`n🎉 App-based group creation completed successfully!"
}
catch {
Write-Error "Script execution failed: $($_.Exception.Message)"
exit 1
}
finally {
try {
Disconnect-MgGraph | Out-Null
Write-Output "✓ Disconnected from Microsoft Graph"
}
catch {
Write-Verbose "Graph disconnection completed"
}
}
// NOTES
Author notes
- Requires Microsoft.Graph.Authentication module - Supports wildcards in application names - Can create multiple groups in a single run - Uses both detected apps and deployment status for comprehensive coverage - Groups are created as security groups by default - Device limit per group is 100,000 (Entra ID limitation) - Local interactive sign-in uses the MgGraphCommunity module to avoid the Graph SDK's mandatory WAM broker on Windows
// RELATED
Scripts that travel together.
Picked by shared tags, category, and script type — nothing magic, just metadata overlap.
Device Compliance Report
This script connects to Microsoft Graph, retrieves managed devices and their compliance status, and generates a detailed report in both CSV and HTML formats. The report includes device details, compliance status, and summary statistics.
DevicesNon-Compliant Devices with Reasons Report
This script connects to Microsoft Graph, retrieves every managed device whose compliance state is non-compliant (and optionally error / in grace period), then drills into each device's compliance policy states and the underlying setting states to surface the exact reason a device is non-compliant. For every failing setting it reports the owning policy, the setting name, the reported state, the current value, and any error code/description. Results are exported to both CSV (one row per failing setting) and a summary HTML report.
DevicesAdd Devices to Entra ID Groups from CSV
This script reads a CSV file containing device identifiers and group names, then adds the specified devices to their corresponding Entra ID groups. It supports multiple device identifiers (Device Name, Serial Number, Entra ID Device ID) for flexible device matching and can add devices to multiple groups. The script validates that devices exist in Intune before processing, checks for existing group memberships to avoid duplicates, and can create new groups with user confirmation. A dry-run mode allows previewing changes before execution.
Devices