#requires -Version 5.1 <# .SYNOPSIS Headless bulk app assignment tool for Intune — cross-platform TUI version. .DESCRIPTION Assign multiple Intune apps to multiple Azure AD groups (or All Users / All Devices) in a single operation. Runs on macOS, Linux, and Windows. Uses fzf for multi-select when available; falls back to numbered menus. Integrates with the IntuneManagement headless auth stack. .EXAMPLE ./Scripts/Bulk-AppAssignment.ps1 -TenantId "contoso.onmicrosoft.com" -AppId "..." -Secret "..." #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$TenantId, [string]$AppId, [string]$Secret, [string]$Certificate, [ValidateSet("AppOnly","Browser","DeviceCode")] [string]$AuthMode = "AppOnly", [string]$RedirectUri, [string]$SettingsFile ) $ErrorActionPreference = "Stop" #region Helper functions function Test-FzfAvailable { return [bool](Get-Command fzf -ErrorAction SilentlyContinue) } function Show-FzfMenu { param( [Parameter(Mandatory)] [string[]]$Items, [string]$Header = "Select one", [switch]$Multi ) $argsList = @("--header=$Header") if($Multi) { $argsList += "--multi" } $selected = $Items | fzf @argsList --bind=space:toggle if(-not $selected) { return $null } if($Multi) { return @($selected -split "`r?`n" | Where-Object { $_ }) } return $selected } function Show-NumberedMenu { param( [Parameter(Mandatory)] [string[]]$Items, [string]$Header = "Select one or more", [switch]$Multi ) Write-Host "`n$Header" -ForegroundColor Cyan for($i=0; $i -lt $Items.Count; $i++) { Write-Host " $($i+1). $($Items[$i])" } if($Multi) { $prompt = "Enter numbers separated by commas (e.g. 1,3,5) or 'all'" } else { $prompt = "Enter a number" } $choice = Read-Host $prompt if($choice -eq "all" -and $Multi) { return $Items } $indices = $choice -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ -match "^\d+$" } | ForEach-Object { [int]$_ - 1 } | Where-Object { $_ -ge 0 -and $_ -lt $Items.Count } if($Multi) { return $Items[$indices] | Select-Object -Unique } else { if($indices.Count -eq 0) { return $null } return $Items[$indices[0]] } } function Select-MenuItem { param( [Parameter(Mandatory)] [string[]]$Items, [string]$Header = "Select one", [switch]$Multi ) if(Test-FzfAvailable) { return Show-FzfMenu -Items $Items -Header $Header -Multi:$Multi } return Show-NumberedMenu -Items $Items -Header $Header -Multi:$Multi } function Read-YesNo { param( [string]$Prompt, [bool]$Default = $false ) $defaultChar = if($Default) { "Y" } else { "N" } $response = Read-Host "$Prompt [Y/n] (default: $defaultChar)" if([string]::IsNullOrWhiteSpace($response)) { return $Default } return $response -match "^\s*y" } function Get-DefaultSettingsPath { if($IsWindows -or $env:OS -eq "Windows_NT") { if($env:LOCALAPPDATA) { return (Join-Path $env:LOCALAPPDATA "macOS_IntuneManagement\Settings.json") } return (Join-Path $env:USERPROFILE "AppData\Local\macOS_IntuneManagement\Settings.json") } if($IsMacOS) { return (Join-Path $HOME "Library/Application Support/macOS_IntuneManagement/Settings.json") } return (Join-Path $HOME ".local/share/macOS_IntuneManagement/Settings.json") } #endregion #region Initialize Runtime $projectRoot = Split-Path -Parent $PSScriptRoot $runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1" if(-not (Test-Path $runtimeModule)) { throw "Could not find IntuneManagement.Runtime.psd1 in $projectRoot" } $settingsPath = $SettingsFile if(-not $settingsPath) { $settingsPath = Get-DefaultSettingsPath } # Pre-load auth from settings if($AuthMode -eq "AppOnly" -and (Test-Path $settingsPath) -and (-not $AppId -or (-not $Secret -and -not $Certificate))) { try { $raw = Get-Content -Path $settingsPath -Raw -ErrorAction Stop $settingsObj = ConvertFrom-Json $raw -AsHashtable -ErrorAction Stop if($settingsObj -and $settingsObj.ContainsKey($TenantId)) { $tenantNode = $settingsObj[$TenantId] if(-not $AppId -and $tenantNode.ContainsKey("GraphAzureAppId")) { $AppId = $tenantNode["GraphAzureAppId"] } if(-not $Secret -and $tenantNode.ContainsKey("GraphAzureAppSecret")) { $Secret = $tenantNode["GraphAzureAppSecret"] } if(-not $Certificate -and $tenantNode.ContainsKey("GraphAzureAppCert")) { $Certificate = $tenantNode["GraphAzureAppCert"] } } if(-not $Secret -and $IsMacOS -and $AppId) { try { $keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" -w 2>$null if($keychainSecret) { $Secret = $keychainSecret } } catch { } } } catch { } } $invokeParams = @{ Silent = $true JSonSettings = $true JSonFile = $settingsPath TenantId = $TenantId AppId = $AppId AuthMode = $AuthMode } if($RedirectUri) { $invokeParams.RedirectUri = $RedirectUri } if($AuthMode -eq "AppOnly" -and $Secret) { $invokeParams.Secret = $Secret } elseif($AuthMode -eq "AppOnly") { $invokeParams.Certificate = $Certificate } Import-Module $runtimeModule -Force Initialize-IntuneManagementRuntime -View "IntuneGraphAPI" @invokeParams #endregion #region Ensure Graph connectivity if(-not (Get-Command Invoke-GraphRequest -ErrorAction SilentlyContinue)) { throw "Graph runtime did not load Invoke-GraphRequest. Aborting." } Write-Host "`nConnecting to Microsoft Graph..." -ForegroundColor Cyan try { $org = Invoke-GraphRequest "/organization" Write-Host "Connected to tenant: $($org.value[0].displayName) ($($org.value[0].id))" -ForegroundColor Green } catch { throw "Failed to connect to Graph. Ensure auth parameters are correct. Error: $_" } #endregion #region Load Apps Write-Host "`nLoading applications from Intune..." -ForegroundColor Cyan $appUrl = "/deviceAppManagement/mobileApps?`$select=id,displayName,publisher&`$filter=(microsoft.graph.managedApp/appAvailability%20eq%20null%20or%20microsoft.graph.managedApp/appAvailability%20eq%20'lineOfBusiness'%20or%20isAssigned%20eq%20true)&`$orderby=displayName" $appsResponse = Invoke-GraphRequest $appUrl -AllPages $apps = $appsResponse.value | Where-Object { $_.displayName } | Sort-Object displayName Write-Host "Found $($apps.Count) applications." -ForegroundColor Green $appFilter = Read-Host "`nFilter apps by name (optional, press Enter to skip)" if(-not [string]::IsNullOrWhiteSpace($appFilter)) { $apps = $apps | Where-Object { $_.displayName -like "*$appFilter*" } Write-Host "Filtered to $($apps.Count) applications." -ForegroundColor Green } if($apps.Count -eq 0) { Write-Host "No apps found. Exiting." -ForegroundColor Yellow exit 0 } $appDisplayNames = $apps | ForEach-Object { "$($_.displayName) [$($_.id)]" } $selectedAppDisplays = Select-MenuItem -Items $appDisplayNames -Header "Select apps to assign (multi-select)" -Multi if(-not $selectedAppDisplays) { Write-Host "No apps selected. Exiting." -ForegroundColor Yellow exit 0 } $selectedApps = @() foreach($disp in $selectedAppDisplays) { $id = $disp -replace '.*\[(.*?)\]$', '$1' $app = $apps | Where-Object { $_.id -eq $id } | Select-Object -First 1 if($app) { $selectedApps += $app } } Write-Host "Selected $($selectedApps.Count) apps." -ForegroundColor Green #endregion #region Load Groups Write-Host "`nLoading Azure AD groups..." -ForegroundColor Cyan $groupsResponse = Invoke-GraphRequest "/groups?`$select=id,displayName&`$orderby=displayName" $groups = $groupsResponse.value | Where-Object { $_.displayName } | Sort-Object displayName Write-Host "Found $($groups.Count) groups." -ForegroundColor Green $groupDisplayNames = $groups | ForEach-Object { "$($_.displayName) [$($_.id)]" } $selectedGroupDisplays = Select-MenuItem -Items $groupDisplayNames -Header "Select target groups (multi-select)" -Multi $selectedGroups = @() if($selectedGroupDisplays) { foreach($disp in $selectedGroupDisplays) { $id = $disp -replace '.*\[(.*?)\]$', '$1' $grp = $groups | Where-Object { $_.id -eq $id } | Select-Object -First 1 if($grp) { $selectedGroups += $grp } } } #endregion #region Special Targets & Intent $intent = Select-MenuItem -Items @("required","available","uninstall") -Header "Select assignment intent" if(-not $intent) { $intent = "required" } $allUsers = Read-YesNo -Prompt "Target All Users?" -Default $false $allDevices = $false if($intent -ne "available") { $allDevices = Read-YesNo -Prompt "Target All Devices?" -Default $false } else { Write-Host "All Devices is not supported with Available intent." -ForegroundColor DarkGray } if(($selectedGroups.Count -eq 0) -and -not $allUsers -and -not $allDevices) { Write-Host "No targets selected. Exiting." -ForegroundColor Yellow exit 0 } #endregion #region Review Clear-Host Write-Host "Review bulk assignment:" -ForegroundColor Green Write-Host " Intent : $intent" Write-Host " Apps : $($selectedApps.Count)" foreach($a in $selectedApps) { Write-Host " - $($a.displayName)" } Write-Host " Groups : $($selectedGroups.Count)" foreach($g in $selectedGroups) { Write-Host " - $($g.displayName)" } Write-Host " All Users : $allUsers" Write-Host " All Devices : $allDevices" $confirm = Read-Host "`nProceed? [Y/n]" if(-not ([string]::IsNullOrWhiteSpace($confirm) -or $confirm -match "^\s*y")) { Write-Host "Cancelled." -ForegroundColor Yellow exit 0 } #endregion #region Execute Assignments $success = 0 $skipped = 0 $failed = 0 foreach($app in $selectedApps) { Write-Host "`nProcessing: $($app.displayName)" -ForegroundColor Cyan # Load existing assignments try { $existing = Invoke-GraphRequest "/deviceAppManagement/mobileApps/$($app.id)/assignments" $existingTargets = $existing.value } catch { Write-Host " ERROR: Could not load existing assignments for $($app.displayName)" -ForegroundColor Red $failed++ continue } # Helper to check if assignment already exists function Test-AssignmentExists { param($targetType, $groupId, $intentValue) foreach($ea in $existingTargets) { if($ea.intent -ne $intentValue) { continue } $t = $ea.target if($targetType -eq "group" -and $t."@odata.type" -eq "#microsoft.graph.groupAssignmentTarget" -and $t.groupId -eq $groupId) { return $true } if($targetType -eq "allUsers" -and $t."@odata.type" -eq "#microsoft.graph.allLicensedUsersAssignmentTarget") { return $true } if($targetType -eq "allDevices" -and $t."@odata.type" -eq "#microsoft.graph.allDevicesAssignmentTarget") { return $true } } return $false } # Build payloads $payloads = @() foreach($grp in $selectedGroups) { if(Test-AssignmentExists -targetType "group" -groupId $grp.id -intentValue $intent) { Write-Host " SKIP: $($grp.displayName) (already assigned)" -ForegroundColor DarkYellow $skipped++ continue } $payloads += @{ "@odata.type" = "#microsoft.graph.mobileAppAssignment" intent = $intent target = @{ "@odata.type" = "#microsoft.graph.groupAssignmentTarget" groupId = $grp.id } } Write-Host " QUEUE: Group -> $($grp.displayName)" -ForegroundColor Gray } if($allUsers) { if(Test-AssignmentExists -targetType "allUsers" -intentValue $intent) { Write-Host " SKIP: All Users (already assigned)" -ForegroundColor DarkYellow $skipped++ } else { $payloads += @{ "@odata.type" = "#microsoft.graph.mobileAppAssignment" intent = $intent target = @{ "@odata.type" = "#microsoft.graph.allLicensedUsersAssignmentTarget" } } Write-Host " QUEUE: All Users" -ForegroundColor Gray } } if($allDevices) { if(Test-AssignmentExists -targetType "allDevices" -intentValue $intent) { Write-Host " SKIP: All Devices (already assigned)" -ForegroundColor DarkYellow $skipped++ } else { $payloads += @{ "@odata.type" = "#microsoft.graph.mobileAppAssignment" intent = $intent target = @{ "@odata.type" = "#microsoft.graph.allDevicesAssignmentTarget" } } Write-Host " QUEUE: All Devices" -ForegroundColor Gray } } # Post assignments foreach($payload in $payloads) { try { $body = $payload | ConvertTo-Json -Depth 10 -Compress $null = Invoke-GraphRequest "/deviceAppManagement/mobileApps/$($app.id)/assignments" -HttpMethod POST -Content $body Write-Host " OK: Assigned target" -ForegroundColor Green $success++ } catch { Write-Host " ERROR: Failed to assign target. $($_.Exception.Message)" -ForegroundColor Red $failed++ } } } Write-Host "`n========================================" -ForegroundColor Cyan Write-Host " Bulk Assignment Complete" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan Write-Host " Success : $success" Write-Host " Skipped : $skipped" Write-Host " Failed : $failed" #endregion