Invoke-GraphRequest with -AllPages returns the full response object with accumulated items in .value, not a flat array.
447 lines
14 KiB
PowerShell
447 lines
14 KiB
PowerShell
#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" -AllPages
|
|
$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
|