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:
411
Scripts/Bulk-DeviceOperations.ps1
Normal file
411
Scripts/Bulk-DeviceOperations.ps1
Normal file
@@ -0,0 +1,411 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bulk device operations for Intune with enterprise-grade safeguards.
|
||||
.DESCRIPTION
|
||||
Retire, wipe, delete, or sync devices in bulk with filtering, dry-run mode,
|
||||
and exclusions for hybrid-joined devices. Uses fzf when available.
|
||||
.EXAMPLE
|
||||
./Scripts/Bulk-DeviceOperations.ps1 -TenantId "contoso.onmicrosoft.com" -WhatIf
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TenantId,
|
||||
|
||||
[string]$AppId,
|
||||
|
||||
[string]$Secret,
|
||||
|
||||
[string]$Certificate,
|
||||
|
||||
[ValidateSet("AppOnly","Browser","DeviceCode")]
|
||||
[string]$AuthMode = "AppOnly",
|
||||
|
||||
[string]$RedirectUri,
|
||||
|
||||
[string]$SettingsFile,
|
||||
|
||||
[switch]$WhatIf
|
||||
)
|
||||
|
||||
$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
|
||||
|
||||
Clear-Host
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Intune Bulk Device Operations" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
|
||||
if($WhatIf)
|
||||
{
|
||||
Write-Host "`n*** DRY-RUN MODE ENABLED ***" -ForegroundColor Magenta
|
||||
Write-Host "No destructive actions will be performed." -ForegroundColor Magenta
|
||||
}
|
||||
|
||||
#region Action selection
|
||||
$action = Select-MenuItem -Items @("Delete","Retire","Wipe (Factory Reset)","Remote Lock","Sync") -Header "Select device operation"
|
||||
if(-not $action) { Write-Host "Cancelled." -ForegroundColor Yellow; exit 0 }
|
||||
|
||||
$actionValue = switch($action)
|
||||
{
|
||||
"Delete" { "delete" }
|
||||
"Retire" { "retire" }
|
||||
"Wipe (Factory Reset)" { "wipe" }
|
||||
"Remote Lock" { "remoteLock" }
|
||||
"Sync" { "syncDevice" }
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Load devices with filtering
|
||||
Write-Host "`nLoading managed devices..." -ForegroundColor Cyan
|
||||
$deviceUrl = "/deviceManagement/managedDevices?`$select=id,deviceName,operatingSystem,complianceState,lastSyncDateTime,azureADDeviceId,azureADRegistered,isEncrypted,userPrincipalName,ownerType,managementState&`$orderby=deviceName"
|
||||
$devicesResponse = Invoke-GraphRequest $deviceUrl
|
||||
$devices = $devicesResponse.value | Where-Object { $_.deviceName } | Sort-Object deviceName
|
||||
Write-Host "Found $($devices.Count) devices." -ForegroundColor Green
|
||||
|
||||
# Filters
|
||||
Write-Host "`n--- Apply Filters ---" -ForegroundColor Cyan
|
||||
$osFilter = Read-Host "Filter by OS (Windows, iOS, macOS, Android — or press Enter for all)"
|
||||
if(-not [string]::IsNullOrWhiteSpace($osFilter))
|
||||
{
|
||||
$devices = $devices | Where-Object { $_.operatingSystem -like "*$osFilter*" }
|
||||
}
|
||||
|
||||
$complianceFilter = Select-MenuItem -Items @("(all)","compliant","noncompliant","unknown","notApplicable","remediated","error","conflict") -Header "Filter by compliance state"
|
||||
if($complianceFilter -and $complianceFilter -ne "(all)")
|
||||
{
|
||||
$devices = $devices | Where-Object { $_.complianceState -eq $complianceFilter }
|
||||
}
|
||||
|
||||
$daysInactive = Read-Host "Only show devices inactive for more than N days (press Enter to skip)"
|
||||
if(-not [string]::IsNullOrWhiteSpace($daysInactive) -and $daysInactive -match "^\d+$")
|
||||
{
|
||||
$cutoff = (Get-Date).AddDays(-[int]$daysInactive)
|
||||
$devices = $devices | Where-Object { [datetime]$_.lastSyncDateTime -lt $cutoff }
|
||||
}
|
||||
|
||||
$nameFilter = Read-Host "Filter by device name (partial match, press Enter to skip)"
|
||||
if(-not [string]::IsNullOrWhiteSpace($nameFilter))
|
||||
{
|
||||
$devices = $devices | Where-Object { $_.deviceName -like "*$nameFilter*" }
|
||||
}
|
||||
|
||||
Write-Host "`nFiltered to $($devices.Count) devices." -ForegroundColor Green
|
||||
|
||||
if($devices.Count -eq 0)
|
||||
{
|
||||
Write-Host "No devices match filters. Exiting." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
$deviceDisplays = $devices | ForEach-Object { "$($_.deviceName) | $($_.operatingSystem) | $($_.complianceState) | $($_.userPrincipalName) [$($_.id)]" }
|
||||
$selectedDisplays = Select-MenuItem -Items $deviceDisplays -Header "Select devices (multi-select)" -Multi
|
||||
if(-not $selectedDisplays)
|
||||
{
|
||||
Write-Host "No devices selected. Exiting." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
$selectedDevices = @()
|
||||
foreach($disp in $selectedDisplays)
|
||||
{
|
||||
$id = $disp -replace '.*\[(.*?)\]$', '$1'
|
||||
$dev = $devices | Where-Object { $_.id -eq $id } | Select-Object -First 1
|
||||
if($dev) { $selectedDevices += $dev }
|
||||
}
|
||||
Write-Host "Selected $($selectedDevices.Count) devices." -ForegroundColor Green
|
||||
#endregion
|
||||
|
||||
#region Safeguards
|
||||
$excludeHybrid = Read-YesNo -Prompt "Exclude hybrid Azure AD joined devices?" -Default $true
|
||||
if($excludeHybrid)
|
||||
{
|
||||
$preCount = $selectedDevices.Count
|
||||
# We need ownerType or join type info. managedDevices doesn't always expose hybrid directly,
|
||||
# but azureADRegistered + ownerType can help. We'll check azureADDeviceId against devices endpoint for joinType.
|
||||
Write-Host "`nChecking device join types..." -ForegroundColor Cyan
|
||||
$aadDeviceIds = $selectedDevices | Where-Object { $_.azureADDeviceId } | Select-Object -ExpandProperty azureADDeviceId -Unique
|
||||
$hybridIds = @{}
|
||||
foreach($aadId in $aadDeviceIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
$aadDevice = Invoke-GraphRequest "/devices?`$filter=deviceId eq '$aadId'&`$select=id,displayName,joinType"
|
||||
if($aadDevice.value -and $aadDevice.value[0].joinType -eq "hybridAzureADJoin")
|
||||
{
|
||||
$hybridIds[$aadId] = $true
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
$selectedDevices = $selectedDevices | Where-Object { -not $hybridIds[$_.azureADDeviceId] }
|
||||
$excluded = $preCount - $selectedDevices.Count
|
||||
if($excluded -gt 0)
|
||||
{
|
||||
Write-Host "Excluded $excluded hybrid-joined device(s)." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
if($selectedDevices.Count -eq 0)
|
||||
{
|
||||
Write-Host "No devices remaining after safeguards. Exiting." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Review
|
||||
Clear-Host
|
||||
Write-Host "Review operation:" -ForegroundColor Green
|
||||
Write-Host " Action : $action"
|
||||
Write-Host " Devices : $($selectedDevices.Count)"
|
||||
foreach($d in $selectedDevices)
|
||||
{
|
||||
Write-Host " - $($d.deviceName) ($($d.operatingSystem)) | $($d.userPrincipalName)"
|
||||
}
|
||||
|
||||
$confirmText = switch($actionValue)
|
||||
{
|
||||
"delete" { "PERMANENTLY DELETE" }
|
||||
"wipe" { "FACTORY RESET" }
|
||||
default { $action.ToUpper() }
|
||||
}
|
||||
|
||||
$confirm = Read-Host "`nType '$confirmText' to confirm, or press Enter to cancel"
|
||||
if($confirm -ne $confirmText)
|
||||
{
|
||||
Write-Host "Cancelled." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Execute
|
||||
$success = 0
|
||||
$failed = 0
|
||||
|
||||
foreach($dev in $selectedDevices)
|
||||
{
|
||||
Write-Host "`nProcessing: $($dev.deviceName)" -ForegroundColor Cyan -NoNewline
|
||||
try
|
||||
{
|
||||
if($WhatIf)
|
||||
{
|
||||
Write-Host " [WHATIF: $actionValue]" -ForegroundColor Magenta
|
||||
$success++
|
||||
continue
|
||||
}
|
||||
|
||||
if($actionValue -in @("delete","retire","remoteLock","syncDevice"))
|
||||
{
|
||||
$url = "/deviceManagement/managedDevices/$($dev.id)/$actionValue"
|
||||
$null = Invoke-GraphRequest $url -HttpMethod POST
|
||||
}
|
||||
elseif($actionValue -eq "wipe")
|
||||
{
|
||||
# Wipe supports keepEnrollmentData / keepUserData flags
|
||||
$keepEnrollment = Read-YesNo -Prompt "Keep enrollment data for $($dev.deviceName)?" -Default $false
|
||||
$keepUserData = Read-YesNo -Prompt "Keep user data for $($dev.deviceName)?" -Default $false
|
||||
$body = @{
|
||||
keepEnrollmentData = $keepEnrollment
|
||||
keepUserData = $keepUserData
|
||||
macOsUnlockCode = ""
|
||||
} | ConvertTo-Json -Compress
|
||||
$null = Invoke-GraphRequest "/deviceManagement/managedDevices/$($dev.id)/wipe" -HttpMethod POST -Content $body
|
||||
}
|
||||
|
||||
Write-Host " -> OK" -ForegroundColor Green
|
||||
$success++
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Host " -> ERROR: $($_.Exception.Message)" -ForegroundColor Red
|
||||
$failed++
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "`n========================================" -ForegroundColor Cyan
|
||||
Write-Host " Bulk Device Operations Complete" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Success : $success"
|
||||
Write-Host " Failed : $failed"
|
||||
#endregion
|
||||
Reference in New Issue
Block a user