feat(toolkit): complete macOS Intune Toolkit v1
Core enhancements: - Expanded default export/import scope to ~45 object types including DeviceManagementIntents - Added -AllPages pagination support across Graph queries for large tenants - Invoke-GraphRequest now throws on 4xx/5xx instead of silently returning null - Added macOS Keychain fallback for secret retrieval in headless auth flow - Added NameSearchPattern/NameReplacePattern mutation support through export/import forms New toolkit scripts: - Bulk-AppAssignment.ps1: bulk-assign apps to groups/All Users/All Devices - Bulk-AssignmentManager.ps1: add/remove assignments for any policy type with correct @odata.type - Backup-Restore-Assignments.ps1: JSON backup with cross-tenant group resolution - Export-AssignmentsToCsv.ps1: CSV/Markdown documentation output - Bulk-RenamePolicies.ps1: regex search/replace and prefix mutations - Bulk-DeviceOperations.ps1: delete/retire/wipe/lock/sync with -WhatIf safeguards - Start-IntuneManagementTui.ps1: interactive terminal UI for headless operations - Create-IntuneManagementApp.ps1: helper for app registration setup Updated existing scripts: - Export-Policies.ps1 / Import-Policies.ps1: wired mutation params through - Start-HeadlessIntune.ps1: integrated TUI and new parameter forwarding
This commit is contained in:
682
Scripts/Bulk-AssignmentManager.ps1
Normal file
682
Scripts/Bulk-AssignmentManager.ps1
Normal file
@@ -0,0 +1,682 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Cross-platform bulk assignment manager for Intune policies and apps.
|
||||
.DESCRIPTION
|
||||
Add or remove assignments across multiple Intune object types
|
||||
(Device Configuration, Compliance, Settings Catalog, Apps, Scripts, etc.)
|
||||
in a single operation. Uses fzf when available; falls back to numbered menus.
|
||||
Integrates with the IntuneManagement headless auth stack.
|
||||
.EXAMPLE
|
||||
./Scripts/Bulk-AssignmentManager.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 Object type registry (assignable types)
|
||||
$assignableTypes = @(
|
||||
[PSCustomObject]@{ Title = "Applications"; API = "/deviceAppManagement/mobileApps"; AssignmentsType = "mobileAppAssignments"; AssignmentODataType = "#microsoft.graph.mobileAppAssignment"; HasIntent = $true; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Device Configuration"; API = "/deviceManagement/deviceConfigurations"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceConfigurationAssignment"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Settings Catalog"; API = "/deviceManagement/configurationPolicies"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceManagementConfigurationPolicyAssignment"; HasIntent = $false; NameProp = "name" },
|
||||
[PSCustomObject]@{ Title = "Compliance Policies"; API = "/deviceManagement/deviceCompliancePolicies"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceCompliancePolicyAssignment"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Administrative Templates"; API = "/deviceManagement/groupPolicyConfigurations"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.groupPolicyConfigurationAssignment"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Endpoint Security"; API = "/deviceManagement/intents"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceManagementIntentAssignment"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "App Protection"; API = "/deviceAppManagement/managedAppPolicies"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.targetedManagedAppPolicyAssignment"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "App Configuration (Device)"; API = "/deviceAppManagement/mobileAppConfigurations"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.managedDeviceMobileAppConfigurationAssignment"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Platform Scripts"; API = "/deviceManagement/deviceManagementScripts"; AssignmentsType = "deviceManagementScriptAssignments"; AssignmentODataType = "#microsoft.graph.deviceManagementScriptAssignment"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "macOS Scripts"; API = "/deviceManagement/deviceShellScripts"; AssignmentsType = "deviceManagementScriptAssignments"; AssignmentODataType = "#microsoft.graph.deviceManagementScriptAssignment"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Device Health Scripts"; API = "/deviceManagement/deviceHealthScripts"; AssignmentsType = "deviceHealthScriptAssignments"; AssignmentODataType = "#microsoft.graph.deviceHealthScriptAssignment"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "macOS Custom Attributes"; API = "/deviceManagement/deviceCustomAttributeShellScripts"; AssignmentsType = "deviceManagementScriptAssignments"; AssignmentODataType = "#microsoft.graph.deviceManagementScriptAssignment"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Enrollment Restrictions"; API = "/deviceManagement/deviceEnrollmentConfigurations"; AssignmentsType = "enrollmentConfigurationAssignments"; AssignmentODataType = "#microsoft.graph.enrollmentConfigurationAssignment"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Enrollment Status Page"; API = "/deviceManagement/deviceEnrollmentConfigurations"; AssignmentsType = "enrollmentConfigurationAssignments"; AssignmentODataType = "#microsoft.graph.enrollmentConfigurationAssignment"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Autopilot"; API = "/deviceManagement/windowsAutopilotDeploymentProfiles"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.windowsAutopilotDeploymentProfileAssignment"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Terms and Conditions"; API = "/deviceManagement/termsAndConditions"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.termsAndConditionsAssignment"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Policy Sets"; API = "/deviceAppManagement/policySets"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.policySetAssignment"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Update Policies"; API = "/deviceManagement/windowsUpdateForBusinessConfigurations"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.windowsUpdateForBusinessConfigurationAssignment"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Feature Updates"; API = "/deviceManagement/windowsFeatureUpdateProfiles"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.windowsFeatureUpdateProfileAssignment"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Quality Updates"; API = "/deviceManagement/windowsQualityUpdateProfiles"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.windowsQualityUpdateProfileAssignment"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Device Management Intents"; API = "/deviceManagement/intents"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceManagementIntentAssignment"; HasIntent = $false; NameProp = "displayName" }
|
||||
)
|
||||
#endregion
|
||||
|
||||
#region Action selection
|
||||
Clear-Host
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Intune Bulk Assignment Manager" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
|
||||
$action = Select-MenuItem -Items @("Add assignments","Remove assignments") -Header "Select action"
|
||||
if(-not $action) { Write-Host "Cancelled." -ForegroundColor Yellow; exit 0 }
|
||||
#endregion
|
||||
|
||||
#region Select object type
|
||||
$typeTitles = $assignableTypes | ForEach-Object { $_.Title }
|
||||
$selectedTypeTitle = Select-MenuItem -Items $typeTitles -Header "Select object type"
|
||||
if(-not $selectedTypeTitle) { Write-Host "Cancelled." -ForegroundColor Yellow; exit 0 }
|
||||
$objectType = $assignableTypes | Where-Object { $_.Title -eq $selectedTypeTitle } | Select-Object -First 1
|
||||
#endregion
|
||||
|
||||
#region Load objects
|
||||
Write-Host "`nLoading $($objectType.Title) objects..." -ForegroundColor Cyan
|
||||
$api = "$($objectType.API)?`$select=id,$($objectType.NameProp)&`$orderby=$($objectType.NameProp)"
|
||||
$objectsResponse = Invoke-GraphRequest $api -AllPages
|
||||
$objects = $objectsResponse.value | Where-Object { $_ } | Sort-Object $objectType.NameProp
|
||||
Write-Host "Found $($objects.Count) objects." -ForegroundColor Green
|
||||
|
||||
$filter = Read-Host "`nFilter by name (optional, press Enter to skip)"
|
||||
if(-not [string]::IsNullOrWhiteSpace($filter))
|
||||
{
|
||||
$objects = $objects | Where-Object { $_."$($objectType.NameProp)" -like "*$filter*" }
|
||||
Write-Host "Filtered to $($objects.Count) objects." -ForegroundColor Green
|
||||
}
|
||||
|
||||
if($objects.Count -eq 0)
|
||||
{
|
||||
Write-Host "No objects found. Exiting." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
$objectDisplays = $objects | ForEach-Object { "$($_."$($objectType.NameProp)") [$($_.id)]" }
|
||||
$selectedDisplays = Select-MenuItem -Items $objectDisplays -Header "Select objects (multi-select)" -Multi
|
||||
if(-not $selectedDisplays)
|
||||
{
|
||||
Write-Host "No objects selected. Exiting." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
$selectedObjects = @()
|
||||
foreach($disp in $selectedDisplays)
|
||||
{
|
||||
$id = $disp -replace '.*\[(.*?)\]$', '$1'
|
||||
$obj = $objects | Where-Object { $_.id -eq $id } | Select-Object -First 1
|
||||
if($obj) { $selectedObjects += $obj }
|
||||
}
|
||||
Write-Host "Selected $($selectedObjects.Count) objects." -ForegroundColor Green
|
||||
#endregion
|
||||
|
||||
#region Load groups & filters
|
||||
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
|
||||
|
||||
Write-Host "`nLoading assignment filters..." -ForegroundColor Cyan
|
||||
$filtersResponse = Invoke-GraphRequest "/deviceManagement/assignmentFilters?`$select=id,displayName&`$orderby=displayName"
|
||||
$assignmentFilters = $filtersResponse.value | Where-Object { $_.displayName } | Sort-Object displayName
|
||||
Write-Host "Found $($assignmentFilters.Count) filters." -ForegroundColor Green
|
||||
#endregion
|
||||
|
||||
#region Add assignments flow
|
||||
if($action -eq "Add assignments")
|
||||
{
|
||||
$groupDisplays = $groups | ForEach-Object { "$($_.displayName) [$($_.id)]" }
|
||||
$selectedGroupDisplays = Select-MenuItem -Items $groupDisplays -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 }
|
||||
}
|
||||
}
|
||||
|
||||
$allUsers = Read-YesNo -Prompt "Target All Users?" -Default $false
|
||||
$allDevices = Read-YesNo -Prompt "Target All Devices?" -Default $false
|
||||
|
||||
if(($selectedGroups.Count -eq 0) -and -not $allUsers -and -not $allDevices)
|
||||
{
|
||||
Write-Host "No targets selected. Exiting." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
$intent = $null
|
||||
if($objectType.HasIntent)
|
||||
{
|
||||
$intent = Select-MenuItem -Items @("required","available","uninstall") -Header "Select assignment intent"
|
||||
if(-not $intent) { $intent = "required" }
|
||||
if($intent -eq "available")
|
||||
{
|
||||
Write-Host "Note: All Devices cannot be targeted with Available intent." -ForegroundColor DarkGray
|
||||
$allDevices = $false
|
||||
}
|
||||
}
|
||||
|
||||
$includeExclude = "include"
|
||||
if($selectedGroups.Count -gt 0)
|
||||
{
|
||||
$includeExclude = Select-MenuItem -Items @("include","exclude") -Header "Group target mode"
|
||||
if(-not $includeExclude) { $includeExclude = "include" }
|
||||
}
|
||||
|
||||
$filterDisplay = "(none)"
|
||||
if($assignmentFilters.Count -gt 0)
|
||||
{
|
||||
$filterDisplays = @("(none)") + ($assignmentFilters | ForEach-Object { "$($_.displayName) [$($_.id)]" })
|
||||
$filterSelection = Select-MenuItem -Items $filterDisplays -Header "Select assignment filter (optional)"
|
||||
if($filterSelection -and $filterSelection -ne "(none)")
|
||||
{
|
||||
$filterId = $filterSelection -replace '.*\[(.*?)\]$', '$1'
|
||||
$filterObj = $assignmentFilters | Where-Object { $_.id -eq $filterId } | Select-Object -First 1
|
||||
if($filterObj) { $filterDisplay = $filterObj.displayName }
|
||||
}
|
||||
}
|
||||
|
||||
# Review
|
||||
Clear-Host
|
||||
Write-Host "Review add-assignment operation:" -ForegroundColor Green
|
||||
Write-Host " Object Type : $($objectType.Title)"
|
||||
Write-Host " Objects : $($selectedObjects.Count)"
|
||||
Write-Host " Groups : $($selectedGroups.Count)"
|
||||
Write-Host " All Users : $allUsers"
|
||||
Write-Host " All Devices : $allDevices"
|
||||
if($intent) { Write-Host " Intent : $intent" }
|
||||
Write-Host " Mode : $includeExclude"
|
||||
Write-Host " Filter : $filterDisplay"
|
||||
$confirm = Read-Host "`nProceed? [Y/n]"
|
||||
if(-not ([string]::IsNullOrWhiteSpace($confirm) -or $confirm -match "^\s*y"))
|
||||
{
|
||||
Write-Host "Cancelled." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Execute
|
||||
$success = 0
|
||||
$skipped = 0
|
||||
$failed = 0
|
||||
|
||||
foreach($obj in $selectedObjects)
|
||||
{
|
||||
Write-Host "`nProcessing: $($obj."$($objectType.NameProp)")" -ForegroundColor Cyan
|
||||
|
||||
try
|
||||
{
|
||||
$existing = Invoke-GraphRequest "$($objectType.API)/$($obj.id)/assignments"
|
||||
$existingTargets = $existing.value
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Host " ERROR: Could not load existing assignments" -ForegroundColor Red
|
||||
$failed++
|
||||
continue
|
||||
}
|
||||
|
||||
function Test-AssignmentExists
|
||||
{
|
||||
param($targetType, $groupId)
|
||||
foreach($ea in $existingTargets)
|
||||
{
|
||||
$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 }
|
||||
if($targetType -eq "excludeGroup" -and $t."@odata.type" -eq "#microsoft.graph.exclusionGroupAssignmentTarget" -and $t.groupId -eq $groupId) { return $true }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
$payloads = @()
|
||||
|
||||
foreach($grp in $selectedGroups)
|
||||
{
|
||||
$targetTypeName = if($includeExclude -eq "exclude") { "excludeGroup" } else { "group" }
|
||||
$odataType = if($includeExclude -eq "exclude") { "#microsoft.graph.exclusionGroupAssignmentTarget" } else { "#microsoft.graph.groupAssignmentTarget" }
|
||||
|
||||
if(Test-AssignmentExists -targetType $targetTypeName -groupId $grp.id)
|
||||
{
|
||||
Write-Host " SKIP: $($grp.displayName) ($includeExclude) already assigned" -ForegroundColor DarkYellow
|
||||
$skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
$targetPayload = @{
|
||||
"@odata.type" = $odataType
|
||||
groupId = $grp.id
|
||||
}
|
||||
if($filterObj)
|
||||
{
|
||||
$targetPayload["deviceAndAppManagementAssignmentFilterId"] = $filterObj.id
|
||||
$targetPayload["deviceAndAppManagementAssignmentFilterType"] = "include"
|
||||
}
|
||||
|
||||
$assignmentPayload = @{
|
||||
"@odata.type" = $objectType.AssignmentODataType
|
||||
target = $targetPayload
|
||||
}
|
||||
if($objectType.HasIntent -and $intent)
|
||||
{
|
||||
$assignmentPayload.intent = $intent
|
||||
}
|
||||
|
||||
$payloads += $assignmentPayload
|
||||
Write-Host " QUEUE: Group -> $($grp.displayName) ($includeExclude)" -ForegroundColor Gray
|
||||
}
|
||||
|
||||
if($allUsers)
|
||||
{
|
||||
if(Test-AssignmentExists -targetType "allUsers")
|
||||
{
|
||||
Write-Host " SKIP: All Users already assigned" -ForegroundColor DarkYellow
|
||||
$skipped++
|
||||
}
|
||||
else
|
||||
{
|
||||
$targetPayload = @{
|
||||
"@odata.type" = "#microsoft.graph.allLicensedUsersAssignmentTarget"
|
||||
}
|
||||
if($filterObj)
|
||||
{
|
||||
$targetPayload["deviceAndAppManagementAssignmentFilterId"] = $filterObj.id
|
||||
$targetPayload["deviceAndAppManagementAssignmentFilterType"] = "include"
|
||||
}
|
||||
$assignmentPayload = @{
|
||||
"@odata.type" = $objectType.AssignmentODataType
|
||||
target = $targetPayload
|
||||
}
|
||||
if($objectType.HasIntent -and $intent)
|
||||
{
|
||||
$assignmentPayload.intent = $intent
|
||||
}
|
||||
$payloads += $assignmentPayload
|
||||
Write-Host " QUEUE: All Users" -ForegroundColor Gray
|
||||
}
|
||||
}
|
||||
|
||||
if($allDevices)
|
||||
{
|
||||
if(Test-AssignmentExists -targetType "allDevices")
|
||||
{
|
||||
Write-Host " SKIP: All Devices already assigned" -ForegroundColor DarkYellow
|
||||
$skipped++
|
||||
}
|
||||
else
|
||||
{
|
||||
$targetPayload = @{
|
||||
"@odata.type" = "#microsoft.graph.allDevicesAssignmentTarget"
|
||||
}
|
||||
if($filterObj)
|
||||
{
|
||||
$targetPayload["deviceAndAppManagementAssignmentFilterId"] = $filterObj.id
|
||||
$targetPayload["deviceAndAppManagementAssignmentFilterType"] = "include"
|
||||
}
|
||||
$assignmentPayload = @{
|
||||
"@odata.type" = $objectType.AssignmentODataType
|
||||
target = $targetPayload
|
||||
}
|
||||
if($objectType.HasIntent -and $intent)
|
||||
{
|
||||
$assignmentPayload.intent = $intent
|
||||
}
|
||||
$payloads += $assignmentPayload
|
||||
Write-Host " QUEUE: All Devices" -ForegroundColor Gray
|
||||
}
|
||||
}
|
||||
|
||||
if($payloads.Count -eq 0)
|
||||
{
|
||||
continue
|
||||
}
|
||||
|
||||
# Merge existing + new assignments and POST to /assign (the standard Intune bulk endpoint)
|
||||
try
|
||||
{
|
||||
$allAssignments = @()
|
||||
|
||||
# Clean existing assignments (remove id/source, preserve structure)
|
||||
foreach($ea in $existingTargets)
|
||||
{
|
||||
$clean = $ea | ConvertTo-Json -Depth 50 | ConvertFrom-Json
|
||||
if($clean.PSObject.Properties["id"]) { $clean.PSObject.Properties.Remove("id") }
|
||||
if($clean.PSObject.Properties["source"]) { $clean.PSObject.Properties.Remove("source") }
|
||||
if(-not $clean."@odata.type")
|
||||
{
|
||||
$clean | Add-Member -NotePropertyName "@odata.type" -NotePropertyValue $objectType.AssignmentODataType -Force
|
||||
}
|
||||
$allAssignments += $clean
|
||||
}
|
||||
|
||||
foreach($p in $payloads)
|
||||
{
|
||||
$allAssignments += $p
|
||||
}
|
||||
|
||||
$assignPayload = @{
|
||||
$objectType.AssignmentsType = $allAssignments
|
||||
} | ConvertTo-Json -Depth 50 -Compress
|
||||
|
||||
$null = Invoke-GraphRequest "$($objectType.API)/$($obj.id)/assign" -HttpMethod POST -Content $assignPayload
|
||||
Write-Host " OK: Assigned $($payloads.Count) new target(s)" -ForegroundColor Green
|
||||
$success += $payloads.Count
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Host " ERROR: Failed to assign. $($_.Exception.Message)" -ForegroundColor Red
|
||||
$failed += $payloads.Count
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "`n========================================" -ForegroundColor Cyan
|
||||
Write-Host " Add Assignments Complete" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Success : $success"
|
||||
Write-Host " Skipped : $skipped"
|
||||
Write-Host " Failed : $failed"
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Remove assignments flow
|
||||
elseif($action -eq "Remove assignments")
|
||||
{
|
||||
# Gather all existing assignments across selected objects
|
||||
Write-Host "`nLoading existing assignments..." -ForegroundColor Cyan
|
||||
$allAssignments = @()
|
||||
foreach($obj in $selectedObjects)
|
||||
{
|
||||
try
|
||||
{
|
||||
$existing = Invoke-GraphRequest "$($objectType.API)/$($obj.id)/assignments"
|
||||
foreach($ass in $existing.value)
|
||||
{
|
||||
$targetDesc = "Unknown"
|
||||
$targetType = $ass.target."@odata.type"
|
||||
if($targetType -eq "#microsoft.graph.groupAssignmentTarget")
|
||||
{
|
||||
$grp = $groups | Where-Object { $_.id -eq $ass.target.groupId } | Select-Object -First 1
|
||||
$targetDesc = "Include: $(if($grp){$grp.displayName}else{$ass.target.groupId})"
|
||||
}
|
||||
elseif($targetType -eq "#microsoft.graph.exclusionGroupAssignmentTarget")
|
||||
{
|
||||
$grp = $groups | Where-Object { $_.id -eq $ass.target.groupId } | Select-Object -First 1
|
||||
$targetDesc = "Exclude: $(if($grp){$grp.displayName}else{$ass.target.groupId})"
|
||||
}
|
||||
elseif($targetType -eq "#microsoft.graph.allLicensedUsersAssignmentTarget")
|
||||
{
|
||||
$targetDesc = "All Users"
|
||||
}
|
||||
elseif($targetType -eq "#microsoft.graph.allDevicesAssignmentTarget")
|
||||
{
|
||||
$targetDesc = "All Devices"
|
||||
}
|
||||
|
||||
$allAssignments += [PSCustomObject]@{
|
||||
ObjectId = $obj.id
|
||||
ObjectName = $obj."$($objectType.NameProp)"
|
||||
AssignmentId = $ass.id
|
||||
TargetDesc = $targetDesc
|
||||
TargetType = $targetType
|
||||
GroupId = $ass.target.groupId
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Host " WARNING: Could not load assignments for $($obj."$($objectType.NameProp)")" -ForegroundColor DarkYellow
|
||||
}
|
||||
}
|
||||
|
||||
if($allAssignments.Count -eq 0)
|
||||
{
|
||||
Write-Host "No assignments found to remove. Exiting." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Deduplicate by target description for selection
|
||||
$uniqueTargets = $allAssignments | Select-Object -Property TargetDesc, TargetType, GroupId -Unique
|
||||
$targetDisplays = $uniqueTargets | ForEach-Object { $_.TargetDesc }
|
||||
$selectedTargetDisplays = Select-MenuItem -Items $targetDisplays -Header "Select assignments to remove (multi-select)" -Multi
|
||||
if(-not $selectedTargetDisplays)
|
||||
{
|
||||
Write-Host "No targets selected. Exiting." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Review
|
||||
Clear-Host
|
||||
Write-Host "Review remove-assignment operation:" -ForegroundColor Green
|
||||
Write-Host " Object Type : $($objectType.Title)"
|
||||
Write-Host " Objects : $($selectedObjects.Count)"
|
||||
Write-Host " Targets to remove:" -ForegroundColor Yellow
|
||||
foreach($td in $selectedTargetDisplays)
|
||||
{
|
||||
$count = ($allAssignments | Where-Object { $_.TargetDesc -eq $td } | Measure-Object).Count
|
||||
Write-Host " - $td ($count occurrence$(if($count -ne 1){'s'}))"
|
||||
}
|
||||
$confirm = Read-Host "`nProceed? [Y/n]"
|
||||
if(-not ([string]::IsNullOrWhiteSpace($confirm) -or $confirm -match "^\s*y"))
|
||||
{
|
||||
Write-Host "Cancelled." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Execute
|
||||
$success = 0
|
||||
$failed = 0
|
||||
foreach($obj in $selectedObjects)
|
||||
{
|
||||
$objAssignments = $allAssignments | Where-Object { $_.ObjectId -eq $obj.id -and $_.TargetDesc -in $selectedTargetDisplays }
|
||||
if($objAssignments.Count -eq 0) { continue }
|
||||
|
||||
Write-Host "`nProcessing: $($obj."$($objectType.NameProp)")" -ForegroundColor Cyan
|
||||
foreach($ass in $objAssignments)
|
||||
{
|
||||
try
|
||||
{
|
||||
$null = Invoke-GraphRequest "$($objectType.API)/$($obj.id)/assignments/$($ass.AssignmentId)" -HttpMethod DELETE
|
||||
Write-Host " OK: Removed $($ass.TargetDesc)" -ForegroundColor Green
|
||||
$success++
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Host " ERROR: Failed to remove $($ass.TargetDesc). $($_.Exception.Message)" -ForegroundColor Red
|
||||
$failed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "`n========================================" -ForegroundColor Cyan
|
||||
Write-Host " Remove Assignments Complete" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Success : $success"
|
||||
Write-Host " Failed : $failed"
|
||||
}
|
||||
#endregion
|
||||
Reference in New Issue
Block a user