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:
84
Core.psm1
84
Core.psm1
@@ -6,6 +6,13 @@ Headless runtime helpers for macOS Intune Management.
|
||||
This module provides the non-UI runtime used by the CLI entrypoints.
|
||||
#>
|
||||
|
||||
# Microsoft.Graph.Authentication registers an alias Invoke-GraphRequest -> Invoke-MgGraphRequest.
|
||||
# Remove it so our local function in MSGraph.psm1 is used instead.
|
||||
if (Get-Alias Invoke-GraphRequest -ErrorAction SilentlyContinue)
|
||||
{
|
||||
Remove-Item Alias:\Invoke-GraphRequest -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Get-ModuleVersion
|
||||
{
|
||||
'4.0.0'
|
||||
@@ -20,6 +27,42 @@ function Invoke-AppDoEvents
|
||||
{
|
||||
}
|
||||
|
||||
function Expand-FileName
|
||||
{
|
||||
param([string]$Path)
|
||||
if(-not $Path) { return $Path }
|
||||
$expanded = [Environment]::ExpandEnvironmentVariables($Path)
|
||||
if($expanded -like "~/*" -or $expanded -eq "~")
|
||||
{
|
||||
$expanded = $expanded -replace "^~", $HOME
|
||||
}
|
||||
return $expanded
|
||||
}
|
||||
|
||||
function Remove-InvalidFileNameChars
|
||||
{
|
||||
param([string]$Name)
|
||||
if([string]::IsNullOrEmpty($Name)) { return $Name }
|
||||
$invalid = [IO.Path]::GetInvalidFileNameChars()
|
||||
foreach($char in $invalid)
|
||||
{
|
||||
$Name = $Name.Replace($char, '_')
|
||||
}
|
||||
# Also replace path separator if present (relevant on Unix)
|
||||
$Name = $Name.Replace('/', '_')
|
||||
$Name
|
||||
}
|
||||
|
||||
function Remove-Property
|
||||
{
|
||||
param($Object, [string]$PropertyName)
|
||||
if(-not $Object -or [string]::IsNullOrEmpty($PropertyName)) { return }
|
||||
if($Object.PSObject.Properties[$PropertyName])
|
||||
{
|
||||
$Object.PSObject.Properties.Remove($PropertyName)
|
||||
}
|
||||
}
|
||||
|
||||
function Start-CoreApp
|
||||
{
|
||||
param($View)
|
||||
@@ -388,25 +431,44 @@ function New-HeadlessControl
|
||||
[string]$Type = "TextBox"
|
||||
)
|
||||
|
||||
[PSCustomObject]@{
|
||||
$control = [PSCustomObject]@{
|
||||
Name = $Name
|
||||
Type = $Type
|
||||
Focusable = ($Type -ne "DataGrid")
|
||||
Visibility = "Visible"
|
||||
IsEnabled = $true
|
||||
Text = ""
|
||||
IsChecked = $false
|
||||
SelectedValue = $null
|
||||
SelectedIndex = -1
|
||||
SelectedItem = $null
|
||||
SelectedItems = @()
|
||||
ItemsSource = @()
|
||||
Items = @()
|
||||
Columns = @()
|
||||
Content = ""
|
||||
Parent = $null
|
||||
DataContext = $null
|
||||
}
|
||||
|
||||
switch($Type)
|
||||
{
|
||||
"TextBox" {
|
||||
$control | Add-Member -NotePropertyName Text -NotePropertyValue ""
|
||||
}
|
||||
"CheckBox" {
|
||||
$control | Add-Member -NotePropertyName IsChecked -NotePropertyValue $false
|
||||
}
|
||||
"ComboBox" {
|
||||
$control | Add-Member -NotePropertyName SelectedValue -NotePropertyValue $null
|
||||
$control | Add-Member -NotePropertyName SelectedIndex -NotePropertyValue -1
|
||||
$control | Add-Member -NotePropertyName SelectedItem -NotePropertyValue $null
|
||||
$control | Add-Member -NotePropertyName SelectedItems -NotePropertyValue @()
|
||||
$control | Add-Member -NotePropertyName ItemsSource -NotePropertyValue @()
|
||||
$control | Add-Member -NotePropertyName Items -NotePropertyValue @()
|
||||
}
|
||||
"Label" {
|
||||
$control | Add-Member -NotePropertyName Content -NotePropertyValue ""
|
||||
}
|
||||
"DataGrid" {
|
||||
$control | Add-Member -NotePropertyName ItemsSource -NotePropertyValue @()
|
||||
$control | Add-Member -NotePropertyName Columns -NotePropertyValue @()
|
||||
$control | Add-Member -NotePropertyName SelectedItems -NotePropertyValue @()
|
||||
$control | Add-Member -NotePropertyName SelectedItem -NotePropertyValue $null
|
||||
}
|
||||
}
|
||||
|
||||
$control
|
||||
}
|
||||
|
||||
function New-HeadlessForm
|
||||
|
||||
@@ -206,6 +206,25 @@ function Invoke-InitializeModule
|
||||
GroupId = "EndpointSecurity"
|
||||
})
|
||||
|
||||
Add-ViewItem (New-Object PSObject -Property @{
|
||||
Title = "Device Management Intents"
|
||||
Id = "DeviceManagementIntents"
|
||||
ViewID = "IntuneGraphAPI"
|
||||
API = "/deviceManagement/intents"
|
||||
PropertiesToRemove = @('Settings','@OData.Type')
|
||||
PreImportCommand = { Start-PreImportEndpointSecurity @args }
|
||||
PostListCommand = { Start-PostListEndpointSecurity @args }
|
||||
PostExportCommand = { Start-PostExportEndpointSecurity @args }
|
||||
PostFileImportCommand = { Start-PostFileImportEndpointSecurity @args }
|
||||
PostGetCommand = { Start-PostGetEndpointSecurity @args }
|
||||
PostCopyCommand = { Start-PostCopyEndpointSecurity @args }
|
||||
PreUpdateCommand = { Start-PreUpdateEndpointSecurity @args }
|
||||
Permissons=@("DeviceManagementConfiguration.ReadWrite.All")
|
||||
Dependencies = @("ReusableSettings")
|
||||
GroupId = "EndpointSecurity"
|
||||
ImportOrder = 70
|
||||
})
|
||||
|
||||
Add-ViewItem (New-Object PSObject -Property @{
|
||||
Title = "Compliance Policies"
|
||||
Id = "CompliancePolicies"
|
||||
@@ -1221,7 +1240,7 @@ function Start-PostExportEndpointSecurity
|
||||
|
||||
$settings = Invoke-GraphRequest -Url "$($objectType.API)/$($obj.id)/settings"
|
||||
$settingsJson = "{ `"settings`": $((ConvertTo-Json $settings.value -Depth 20 ))`n}"
|
||||
$fileName = "$path\$((Remove-InvalidFileNameChars $fileName))_Settings.json"
|
||||
$fileName = Join-Path $path "$((Remove-InvalidFileNameChars $fileName))_Settings.json"
|
||||
Save-GraphObjectToFile $settingsJson $fileName
|
||||
}
|
||||
|
||||
@@ -1545,7 +1564,7 @@ function Start-PostExportIntuneBranding
|
||||
{
|
||||
if($obj.$imgType.Value)
|
||||
{
|
||||
$fileName = "$path\$((Get-GraphObjectName $obj $objectType))_$imgType.jpg"
|
||||
$fileName = Join-Path $path "$((Get-GraphObjectName $obj $objectType))_$imgType.jpg"
|
||||
[IO.File]::WriteAllBytes($fileName, [System.Convert]::FromBase64String($obj.$imgType.Value))
|
||||
}
|
||||
}
|
||||
@@ -2237,7 +2256,7 @@ function Start-PostExportAppConfiguration
|
||||
$fileName = ($fileName + "_" + $obj.Id)
|
||||
}
|
||||
$tmpObj = $null
|
||||
$fileName = "$path\$((Remove-InvalidFileNameChars $fileName)).json"
|
||||
$fileName = Join-Path $path "$((Remove-InvalidFileNameChars $fileName)).json"
|
||||
if([IO.File]::Exists($fileName))
|
||||
{
|
||||
$tmpObj = Get-GraphObjectFromFile $fileName
|
||||
@@ -2704,7 +2723,7 @@ function Start-PostExportApplications
|
||||
if($global:chkExportScript.IsChecked)
|
||||
{
|
||||
$fileName = Get-GraphObjectFile $obj $objectType
|
||||
$fi = [IO.FileInfo]"$path\$fileName"
|
||||
$fi = [IO.FileInfo](Join-Path $path $fileName)
|
||||
|
||||
try
|
||||
{
|
||||
@@ -2712,7 +2731,7 @@ function Start-PostExportApplications
|
||||
{
|
||||
if($rule.ScriptContent)
|
||||
{
|
||||
[IO.File]::WriteAllBytes(("$path\$($fi.BaseName)_DetectionScript.ps1"), ([System.Convert]::FromBase64String($rule.ScriptContent)))
|
||||
[IO.File]::WriteAllBytes((Join-Path $path "$($fi.BaseName)_DetectionScript.ps1"), ([System.Convert]::FromBase64String($rule.ScriptContent)))
|
||||
|
||||
}
|
||||
}
|
||||
@@ -2723,7 +2742,7 @@ function Start-PostExportApplications
|
||||
{
|
||||
if($rule.ScriptContent)
|
||||
{
|
||||
[IO.File]::WriteAllBytes(("$path\$($fi.BaseName)_RequirementScript.ps1"), ([System.Convert]::FromBase64String($rule.ScriptContent)))
|
||||
[IO.File]::WriteAllBytes((Join-Path $path "$($fi.BaseName)_RequirementScript.ps1"), ([System.Convert]::FromBase64String($rule.ScriptContent)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3188,7 +3207,7 @@ function Start-PostExportAdministrativeTemplate
|
||||
$settings = Get-GPOObjectSettings $obj
|
||||
}
|
||||
|
||||
$fileName = "$path\$((Remove-InvalidFileNameChars $fileName))_Settings.json"
|
||||
$fileName = Join-Path $path "$((Remove-InvalidFileNameChars $fileName))_Settings.json"
|
||||
Save-GraphObjectToFile $settings $fileName
|
||||
}
|
||||
|
||||
@@ -3443,7 +3462,7 @@ function Start-PostExportRoleDefinitions
|
||||
$fileName = ($fileName + "_" + $obj.Id)
|
||||
}
|
||||
$tmpObj = $null
|
||||
$fileName = "$path\$((Remove-InvalidFileNameChars $fileName)).json"
|
||||
$fileName = Join-Path $path "$((Remove-InvalidFileNameChars $fileName)).json"
|
||||
if([IO.File]::Exists($fileName))
|
||||
{
|
||||
$tmpObj = Get-GraphObjectFromFile $fileName
|
||||
@@ -3910,18 +3929,18 @@ function Start-PostExportDeviceHealthScripts
|
||||
if($global:chkExportScript.IsChecked)
|
||||
{
|
||||
$fileName = Get-GraphObjectFile $obj $objectType
|
||||
$fi = [IO.FileInfo]"$path\$fileName"
|
||||
$fi = [IO.FileInfo](Join-Path $path $fileName)
|
||||
|
||||
try
|
||||
{
|
||||
if($obj.detectionScriptContent)
|
||||
{
|
||||
[IO.File]::WriteAllBytes(("$path\$($fi.BaseName)_DetectionScript.ps1"), ([System.Convert]::FromBase64String($obj.detectionScriptContent)))
|
||||
[IO.File]::WriteAllBytes((Join-Path $path "$($fi.BaseName)_DetectionScript.ps1"), ([System.Convert]::FromBase64String($obj.detectionScriptContent)))
|
||||
}
|
||||
|
||||
if($obj.remediationScriptContent)
|
||||
{
|
||||
[IO.File]::WriteAllBytes(("$path\$($fi.BaseName)_RemediationScript.ps1"), ([System.Convert]::FromBase64String($obj.remediationScriptContent)))
|
||||
[IO.File]::WriteAllBytes((Join-Path $path "$($fi.BaseName)_RemediationScript.ps1"), ([System.Convert]::FromBase64String($obj.remediationScriptContent)))
|
||||
}
|
||||
}
|
||||
catch
|
||||
@@ -3947,13 +3966,13 @@ function Save-EMDefaultPolicy
|
||||
|
||||
if($fileName)
|
||||
{
|
||||
$oldFile = "$path\$((Get-GraphObjectName $obj $objectType)).json"
|
||||
$oldFile = Join-Path $path "$((Get-GraphObjectName $obj $objectType)).json"
|
||||
if([IO.File]::Exists($oldFile))
|
||||
{
|
||||
# Clean up from old version of the script that used the wrong name for Default policies
|
||||
try { [IO.File]::Delete($oldFile) | Out-Null } Catch {}
|
||||
}
|
||||
Save-GraphObjectToFile $obj "$path\$((Remove-InvalidFileNameChars $fileName)).json"
|
||||
Save-GraphObjectToFile $obj (Join-Path $path "$((Remove-InvalidFileNameChars $fileName)).json")
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
@@ -4013,7 +4032,7 @@ function Add-EMAssignmentsToExportFile
|
||||
{
|
||||
$fileName = ($fileName + "_" + $obj.Id)
|
||||
}
|
||||
$fileName = "$path\$((Remove-InvalidFileNameChars $fileName)).json"
|
||||
$fileName = Join-Path $path "$((Remove-InvalidFileNameChars $fileName)).json"
|
||||
if([IO.File]::Exists($fileName) -eq $false)
|
||||
{
|
||||
Write-Log "File not found: $fileName. Could not add assignments to file" 3
|
||||
@@ -4288,7 +4307,7 @@ function Start-PostExportTermsOfUse
|
||||
if($data)
|
||||
{
|
||||
Write-Log "Save file $($file.FileName)"
|
||||
$fileName = "$path\$($file.FileName)"
|
||||
$fileName = Join-Path $path $file.FileName
|
||||
[IO.File]::WriteAllBytes($fileName, [System.Convert]::FromBase64String($data))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1069,11 +1069,11 @@ function Connect-MSALUser
|
||||
{
|
||||
$headlessAuthMode = ?? $global:HeadlessAuthMode "AppOnly"
|
||||
|
||||
if($headlessAuthMode -eq "Browser")
|
||||
if($headlessAuthMode -eq "Browser" -or $headlessAuthMode -eq "DeviceCode")
|
||||
{
|
||||
if(-not $global:AzureAppId -or -not $global:TenantId)
|
||||
{
|
||||
Write-Log "Azure AppId and Tenant Id must be specified for browser auth" 3
|
||||
Write-Log "Azure AppId and Tenant Id must be specified for $headlessAuthMode auth" 3
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1315,8 +1315,74 @@ function Connect-MSALUser
|
||||
Write-LogError "Failed to perform silent login" $_.Exception
|
||||
}
|
||||
|
||||
if($global:hideUI -eq $true -and $global:HeadlessAuthMode -eq "DeviceCode" -and ((-not $authResult -and $Silent -ne $true) -or $prompConsent))
|
||||
{
|
||||
#########################################################################################################
|
||||
### Device Code Login
|
||||
#########################################################################################################
|
||||
Write-Log "Initiate device code logon"
|
||||
|
||||
if($useDefaultPermissions -eq $false)
|
||||
{
|
||||
[string[]]$Scopes = Get-MSALRequiredScopes
|
||||
}
|
||||
|
||||
Write-Log "Scopes: $(($Scopes -join ","))"
|
||||
|
||||
$msalDllPath = Join-Path (Split-Path -Parent $PSScriptRoot) "Bin/Microsoft.Identity.Client.dll"
|
||||
$consoleDllPath = [System.Console].Assembly.Location
|
||||
if (-not ("DeviceCodeHelper" -as [type]))
|
||||
{
|
||||
Add-Type -Path $msalDllPath -ErrorAction SilentlyContinue
|
||||
Add-Type -TypeDefinition @"
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Identity.Client;
|
||||
|
||||
public static class DeviceCodeHelper
|
||||
{
|
||||
public static Task ShowDeviceCode(DeviceCodeResult result)
|
||||
{
|
||||
Console.WriteLine("");
|
||||
Console.WriteLine("To sign in, use a web browser to open the page " + result.VerificationUrl + " and enter the code " + result.UserCode + " to authenticate.");
|
||||
Console.WriteLine("");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
"@ -ReferencedAssemblies @($msalDllPath, $consoleDllPath)
|
||||
}
|
||||
|
||||
$method = [DeviceCodeHelper].GetMethod("ShowDeviceCode")
|
||||
$delegateType = [System.Func[Microsoft.Identity.Client.DeviceCodeResult, System.Threading.Tasks.Task]]
|
||||
$callback = [System.Delegate]::CreateDelegate($delegateType, $method)
|
||||
$aquireTokenObj = $global:MSALApp.AcquireTokenWithDeviceCode($Scopes, $callback)
|
||||
|
||||
if ($tenantId)
|
||||
{
|
||||
Write-Log "Tenant id: $tenantId"
|
||||
[void]$aquireTokenObj.WithAuthority("https://$((Get-MSALAppAuthority))/$tenantId/")
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Log "Authority: $($global:MSALApp.Authority)"
|
||||
[void]$aquireTokenObj.WithAuthority($global:MSALApp.Authority)
|
||||
}
|
||||
|
||||
if($script:authenticationFailure.Claims)
|
||||
{
|
||||
Write-Log "Login claims: $($script:authenticationFailure.Claims))"
|
||||
[void]$AquireTokenObj.WithClaims($script:authenticationFailure.Claims)
|
||||
}
|
||||
|
||||
$authResult = Get-MsalAuthenticationToken $aquireTokenObj
|
||||
if($authResult)
|
||||
{
|
||||
Write-Log "$($authResult.Account.UserName) authenticated successfully (Device Code). CorrelationId: $($authResult.CorrelationId)"
|
||||
}
|
||||
}
|
||||
|
||||
# Interactive login is only allowed once the app has started. Skip if silent login failed during startup
|
||||
if($global:MainAppStarted -and ((-not $authResult -and $Silent -ne $true) -or $prompConsent))
|
||||
if($global:MainAppStarted -and $global:HeadlessAuthMode -ne "DeviceCode" -and ((-not $authResult -and $Silent -ne $true) -or $prompConsent))
|
||||
{
|
||||
#########################################################################################################
|
||||
### Interactive Login
|
||||
|
||||
@@ -592,6 +592,7 @@ function Invoke-GraphRequest
|
||||
catch{}
|
||||
|
||||
Write-LogError "Failed to invoke MS Graph with URL $Url (Request ID: $requestId). Status code: $($_.Exception.Response.StatusCode)$extMessage" $_.Exception
|
||||
throw $_.Exception
|
||||
}
|
||||
}
|
||||
} while($retryRequest -eq $true)
|
||||
@@ -1329,6 +1330,21 @@ function Invoke-InitSilentBatchJob
|
||||
{
|
||||
$global:ClientSecret = Get-SettingValue "GraphAzureAppSecret" -TenantID $global:TenantId
|
||||
$global:ClientCert = Get-SettingValue "GraphAzureAppCert" -TenantID $global:TenantId
|
||||
|
||||
# macOS Keychain fallback for client secret
|
||||
if(-not $global:ClientSecret -and $IsMacOS -and $global:AzureAppId)
|
||||
{
|
||||
try
|
||||
{
|
||||
$keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$($global:AzureAppId)" -w 2>$null
|
||||
if($keychainSecret)
|
||||
{
|
||||
$global:ClientSecret = $keychainSecret
|
||||
Write-Log "Retrieved client secret from macOS Keychain"
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1423,6 +1439,8 @@ function New-GraphSilentExportForm
|
||||
$controls = @(
|
||||
(New-HeadlessControl -Name "txtExportPath" -Type "TextBox"),
|
||||
(New-HeadlessControl -Name "txtExportNameFilter" -Type "TextBox"),
|
||||
(New-HeadlessControl -Name "txtExportNameSearchPattern" -Type "TextBox"),
|
||||
(New-HeadlessControl -Name "txtExportNameReplacePattern" -Type "TextBox"),
|
||||
(New-HeadlessControl -Name "chkAddObjectType" -Type "CheckBox"),
|
||||
(New-HeadlessControl -Name "chkExportAssignments" -Type "CheckBox"),
|
||||
(New-HeadlessControl -Name "chkAddCompanyName" -Type "CheckBox"),
|
||||
@@ -1449,6 +1467,8 @@ function New-GraphSilentImportForm
|
||||
$controls = @(
|
||||
(New-HeadlessControl -Name "txtImportPath" -Type "TextBox"),
|
||||
(New-HeadlessControl -Name "txtImportNameFilter" -Type "TextBox"),
|
||||
(New-HeadlessControl -Name "txtImportNameSearchPattern" -Type "TextBox"),
|
||||
(New-HeadlessControl -Name "txtImportNameReplacePattern" -Type "TextBox"),
|
||||
(New-HeadlessControl -Name "lblMigrationTableInfo" -Type "Label"),
|
||||
(New-HeadlessControl -Name "chkAddObjectType" -Type "CheckBox"),
|
||||
(New-HeadlessControl -Name "chkImportScopes" -Type "CheckBox"),
|
||||
@@ -1631,6 +1651,13 @@ function Start-GraphObjectExport
|
||||
Save-Setting "" "ExportNameFilter" $txtNameFilter
|
||||
|
||||
if($txtNameFilter) { Write-Log "Name filter: $txtNameFilter" }
|
||||
|
||||
$txtSearchPattern = ""
|
||||
if($global:txtExportNameSearchPattern -ne $null) { $txtSearchPattern = $global:txtExportNameSearchPattern.Text.Trim() }
|
||||
$txtReplacePattern = ""
|
||||
if($global:txtExportNameReplacePattern -ne $null) { $txtReplacePattern = $global:txtExportNameReplacePattern.Text.Trim() }
|
||||
if($txtSearchPattern) { Write-Log "Name mutation: replace '$txtSearchPattern' with '$txtReplacePattern'" }
|
||||
|
||||
try
|
||||
{
|
||||
$folder = Get-GraphObjectFolder $item.ObjectType $script:exportRoot (Get-XamlProperty $script:exportForm "chkAddObjectType" "IsChecked") (Get-XamlProperty $script:exportForm "chkAddCompanyName" "IsChecked")
|
||||
@@ -1650,6 +1677,11 @@ function Start-GraphObjectExport
|
||||
{
|
||||
if(-not $batchResult.Object) { continue }
|
||||
$objName = Get-GraphObjectName $batchResult.Object $batchResult.ObjectType
|
||||
if($txtSearchPattern -and $objName -match $txtSearchPattern)
|
||||
{
|
||||
$objName = $objName -replace $txtSearchPattern, $txtReplacePattern
|
||||
Set-GraphObjectName $batchResult.Object $batchResult.ObjectType $objName
|
||||
}
|
||||
Write-Status "Export $($item.Title): $objName ($($i)/$($total))" -Force
|
||||
Export-GraphObject $batchResult.Object $batchResult.ObjectType $folder -IsFullObject
|
||||
$i++
|
||||
@@ -1667,6 +1699,12 @@ function Start-GraphObjectExport
|
||||
continue
|
||||
}
|
||||
|
||||
if($txtSearchPattern -and $objName -match $txtSearchPattern)
|
||||
{
|
||||
$objName = $objName -replace $txtSearchPattern, $txtReplacePattern
|
||||
Set-GraphObjectName $obj.Object $obj.ObjectType $objName
|
||||
}
|
||||
|
||||
Write-Status "Export $($item.Title): $objName" -Force
|
||||
Export-GraphObject $obj.Object $item.ObjectType $folder
|
||||
}
|
||||
@@ -2130,6 +2168,12 @@ function Start-GraphObjectImport
|
||||
Save-Setting "" "ImportNameFilter" $txtNameFilter
|
||||
if($txtNameFilter) { Write-Log "Name filter: $txtNameFilter" }
|
||||
|
||||
$txtSearchPattern = ""
|
||||
if($global:txtImportNameSearchPattern -ne $null) { $txtSearchPattern = $global:txtImportNameSearchPattern.Text.Trim() }
|
||||
$txtReplacePattern = ""
|
||||
if($global:txtImportNameReplacePattern -ne $null) { $txtReplacePattern = $global:txtImportNameReplacePattern.Text.Trim() }
|
||||
if($txtSearchPattern) { Write-Log "Name mutation: replace '$txtSearchPattern' with '$txtReplacePattern'" }
|
||||
|
||||
$allowUpdate = $true
|
||||
|
||||
foreach($item in ($script:importObjects | where Selected -eq $true | sort-object -property @{e={$_.ObjectType.ImportOrder}}))
|
||||
@@ -2176,6 +2220,12 @@ function Start-GraphObjectImport
|
||||
continue
|
||||
}
|
||||
|
||||
if($txtSearchPattern -and $objName -match $txtSearchPattern)
|
||||
{
|
||||
$objName = $objName -replace $txtSearchPattern, $txtReplacePattern
|
||||
Set-GraphObjectName $fileObj.Object $item.ObjectType $objName
|
||||
}
|
||||
|
||||
if($allowUpdate -and $global:cbImportType.SelectedValue -ne "alwaysImport" -and $graphObjects -and (Reset-GraphObject $fileObj $graphObjects))
|
||||
{
|
||||
$importedObjects++
|
||||
@@ -2928,7 +2978,7 @@ function Add-GroupMigrationObject
|
||||
{
|
||||
# Export group info to json file for possible import
|
||||
$grouspPath = Join-Path $path "Groups"
|
||||
if(-not (Test-Path $grouspPath)) { mkdir -Path $grouspPath -Force -ErrorAction SilentlyContinue | Out-Null }
|
||||
if(-not (Test-Path $grouspPath)) { New-Item -ItemType Directory -Path $grouspPath -Force -ErrorAction SilentlyContinue | Out-Null }
|
||||
$fileName = Join-Path $grouspPath "$((Remove-InvalidFileNameChars $groupObj.displayName)).json"
|
||||
Save-GraphObjectToFile $groupObj $fileName
|
||||
}
|
||||
@@ -2971,7 +3021,7 @@ function Add-GraphMigrationObject
|
||||
{
|
||||
# Export group info to json file for possible import
|
||||
$grouspPath = Join-Path $path "Groups"
|
||||
if(-not (Test-Path $grouspPath)) { mkdir -Path $grouspPath -Force -ErrorAction SilentlyContinue | Out-Null }
|
||||
if(-not (Test-Path $grouspPath)) { New-Item -ItemType Directory -Path $grouspPath -Force -ErrorAction SilentlyContinue | Out-Null }
|
||||
$fileName = Join-Path $grouspPath "$((Remove-InvalidFileNameChars $graphObj.displayName)).json"
|
||||
Save-GraphObjectToFile $graphObj $fileName
|
||||
}
|
||||
@@ -3045,7 +3095,7 @@ function Add-GraphMigrationObjectToFile
|
||||
Type = $objType
|
||||
})
|
||||
|
||||
if(-not (Test-Path $path)) { mkdir -Path $path -Force -ErrorAction SilentlyContinue | Out-Null }
|
||||
if(-not (Test-Path $path)) { New-Item -ItemType Directory -Path $path -Force -ErrorAction SilentlyContinue | Out-Null }
|
||||
ConvertTo-Json $global:migFileObj -Depth 50 | Out-File $migFileName -Force
|
||||
|
||||
$true # New object was added
|
||||
@@ -3099,6 +3149,11 @@ function Get-GraphMigrationObjectsFromFile
|
||||
|
||||
Write-Status "Loading migration objects"
|
||||
|
||||
$txtSearchPattern = ""
|
||||
if($global:txtImportNameSearchPattern -ne $null) { $txtSearchPattern = $global:txtImportNameSearchPattern.Text.Trim() }
|
||||
$txtReplacePattern = ""
|
||||
if($global:txtImportNameReplacePattern -ne $null) { $txtReplacePattern = $global:txtImportNameReplacePattern.Text.Trim() }
|
||||
|
||||
if($global:chkImportAssignments.IsChecked -eq $true)
|
||||
{
|
||||
# Only check groups if Assignments are imported
|
||||
@@ -3108,6 +3163,11 @@ function Get-GraphMigrationObjectsFromFile
|
||||
if($migObj.Type -like "*group*")
|
||||
{
|
||||
$migTableGroupName = $migObj.DisplayName.Trim()
|
||||
$originalGroupName = $migTableGroupName
|
||||
if($txtSearchPattern -and $migTableGroupName -match $txtSearchPattern)
|
||||
{
|
||||
$migTableGroupName = $migTableGroupName -replace $txtSearchPattern, $txtReplacePattern
|
||||
}
|
||||
$obj = (Invoke-GraphRequest "/groups?`$filter=displayName eq '$($migTableGroupName)'").Value
|
||||
if(-not $obj)
|
||||
{
|
||||
@@ -3115,7 +3175,7 @@ function Get-GraphMigrationObjectsFromFile
|
||||
if($global:GraphMigrationTable)
|
||||
{
|
||||
$fi = [IO.FileInfo]$global:GraphMigrationTable
|
||||
$groupFi = [IO.FileInfo](Join-Path (Join-Path $fi.DirectoryName "Groups") "$((Remove-InvalidFileNameChars $migTableGroupName)).json")
|
||||
$groupFi = [IO.FileInfo](Join-Path (Join-Path $fi.DirectoryName "Groups") "$((Remove-InvalidFileNameChars $originalGroupName)).json")
|
||||
}
|
||||
|
||||
if($groupFi.Exists -eq $true)
|
||||
@@ -3133,6 +3193,10 @@ function Get-GraphMigrationObjectsFromFile
|
||||
Remove-Property $groupObj $prop.Name
|
||||
}
|
||||
$groupObj.displayName = $groupObj.displayName.Trim()
|
||||
if($txtSearchPattern -and $groupObj.displayName -match $txtSearchPattern)
|
||||
{
|
||||
$groupObj.displayName = $groupObj.displayName -replace $txtSearchPattern, $txtReplacePattern
|
||||
}
|
||||
$groupJson = ConvertTo-Json $groupObj -Depth 50
|
||||
}
|
||||
else
|
||||
@@ -4632,7 +4696,7 @@ function Get-GraphObjectFile
|
||||
$fileName = "$((Remove-InvalidFileNameChars $fileName)).json"
|
||||
if($path)
|
||||
{
|
||||
$fileName = "$path\$fileName"
|
||||
$fileName = Join-Path $path $fileName
|
||||
}
|
||||
|
||||
$fileName
|
||||
|
||||
@@ -1,11 +1,57 @@
|
||||
$script:coreModulePath = Join-Path (Split-Path -Parent $PSScriptRoot) "Core.psm1"
|
||||
if (Test-Path $script:coreModulePath)
|
||||
{
|
||||
Import-Module $script:coreModulePath -Force
|
||||
}
|
||||
|
||||
function Get-DefaultIntunePolicyObjectTypes
|
||||
{
|
||||
@(
|
||||
"ScopeTags",
|
||||
"AssignmentFilters",
|
||||
"ReusableSettings",
|
||||
"RoleDefinitions",
|
||||
"Notifications",
|
||||
"DeviceHealthScripts",
|
||||
"ComplianceScripts",
|
||||
"PowerShellScripts",
|
||||
"MacScripts",
|
||||
"MacCustomAttributes",
|
||||
"ADMXFiles",
|
||||
"IntuneBranding",
|
||||
"AzureBranding",
|
||||
"TermsAndConditions",
|
||||
"TermsOfUse",
|
||||
"EnrollmentStatusPage",
|
||||
"EnrollmentRestrictions",
|
||||
"AppleEnrollmentTypes",
|
||||
"AutoPilot",
|
||||
"AndroidOEMConfig",
|
||||
"DeviceCategories",
|
||||
"AuthenticationStrengths",
|
||||
"AuthenticationContext",
|
||||
"NamedLocations",
|
||||
"ConditionalAccess",
|
||||
"CoManagementSettings",
|
||||
"Applications",
|
||||
"AppProtection",
|
||||
"AppConfigurationManagedApp",
|
||||
"AppConfigurationManagedDevice",
|
||||
"UpdatePolicies",
|
||||
"FeatureUpdates",
|
||||
"QualityUpdates",
|
||||
"DriverUpdateProfiles",
|
||||
"HardwareConfigurations",
|
||||
"InventoryPolicies",
|
||||
"W365ProvisioningPolicies",
|
||||
"W365UserSettings",
|
||||
"AdministrativeTemplates",
|
||||
"DeviceConfiguration",
|
||||
"SettingsCatalog",
|
||||
"AdministrativeTemplates",
|
||||
"CompliancePolicies",
|
||||
"CompliancePoliciesV2",
|
||||
"EndpointSecurity",
|
||||
"DeviceManagementIntents",
|
||||
"PolicySets"
|
||||
)
|
||||
}
|
||||
@@ -29,7 +75,8 @@ function Resolve-HeadlessSettingsPath
|
||||
return $SettingsFile
|
||||
}
|
||||
|
||||
Join-Path ([IO.Path]::GetTempPath()) "IntuneManagement.Settings.json"
|
||||
# Default to the persistent data folder (same location used by Initialize-IntuneAuth)
|
||||
Join-Path (Get-CloudApiDataFolder) "Settings.json"
|
||||
}
|
||||
|
||||
function New-TemporaryBatchFile
|
||||
@@ -48,7 +95,7 @@ function Test-AuthParameters
|
||||
[string]$Certificate
|
||||
)
|
||||
|
||||
if($AuthMode -eq "Browser")
|
||||
if($AuthMode -eq "Browser" -or $AuthMode -eq "DeviceCode")
|
||||
{
|
||||
return
|
||||
}
|
||||
@@ -77,7 +124,7 @@ function Invoke-IntuneHeadlessBatch
|
||||
|
||||
[string]$Certificate,
|
||||
|
||||
[ValidateSet("AppOnly","Browser")]
|
||||
[ValidateSet("AppOnly","Browser","DeviceCode")]
|
||||
[string]$AuthMode = "AppOnly",
|
||||
|
||||
[string]$RedirectUri,
|
||||
@@ -90,11 +137,50 @@ function Invoke-IntuneHeadlessBatch
|
||||
[string]$BatchFile
|
||||
)
|
||||
|
||||
if($AuthMode -eq "Browser" -and -not $AppId)
|
||||
if(($AuthMode -eq "Browser" -or $AuthMode -eq "DeviceCode") -and -not $AppId)
|
||||
{
|
||||
$AppId = Get-DefaultBrowserAppId
|
||||
}
|
||||
|
||||
# Pre-load settings to fill missing AppId/Secret before auth validation
|
||||
$settingsPath = Resolve-HeadlessSettingsPath $SettingsFile
|
||||
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"]
|
||||
}
|
||||
}
|
||||
|
||||
# macOS Keychain fallback for secret
|
||||
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 { }
|
||||
}
|
||||
|
||||
Test-AuthParameters -AuthMode $AuthMode -AppId $AppId -Secret $Secret -Certificate $Certificate
|
||||
|
||||
$projectRoot = Get-IntuneManagementProjectRoot
|
||||
@@ -105,8 +191,6 @@ function Invoke-IntuneHeadlessBatch
|
||||
throw "Could not find IntuneManagement.Runtime.psd1 in $projectRoot"
|
||||
}
|
||||
|
||||
$settingsPath = Resolve-HeadlessSettingsPath $SettingsFile
|
||||
|
||||
$deleteBatchFile = $false
|
||||
if(-not $BatchFile)
|
||||
{
|
||||
@@ -167,7 +251,7 @@ function Export-IntunePolicies
|
||||
|
||||
[string]$Certificate,
|
||||
|
||||
[ValidateSet("AppOnly","Browser")]
|
||||
[ValidateSet("AppOnly","Browser","DeviceCode")]
|
||||
[string]$AuthMode = "AppOnly",
|
||||
|
||||
[string]$RedirectUri,
|
||||
@@ -181,6 +265,10 @@ function Export-IntunePolicies
|
||||
|
||||
[string]$NameFilter = "",
|
||||
|
||||
[string]$NameSearchPattern = "",
|
||||
|
||||
[string]$NameReplacePattern = "",
|
||||
|
||||
[string[]]$ObjectTypes = (Get-DefaultIntunePolicyObjectTypes),
|
||||
|
||||
[switch]$IncludeAssignments,
|
||||
@@ -192,6 +280,8 @@ function Export-IntunePolicies
|
||||
BulkExport = @(
|
||||
[PSCustomObject]@{ Name = "txtExportPath"; Value = $ExportPath },
|
||||
[PSCustomObject]@{ Name = "txtExportNameFilter"; Value = $NameFilter },
|
||||
[PSCustomObject]@{ Name = "txtExportNameSearchPattern"; Value = $NameSearchPattern },
|
||||
[PSCustomObject]@{ Name = "txtExportNameReplacePattern"; Value = $NameReplacePattern },
|
||||
[PSCustomObject]@{ Name = "chkAddObjectType"; Value = $true },
|
||||
[PSCustomObject]@{ Name = "chkExportAssignments"; Value = $IncludeAssignments.IsPresent },
|
||||
[PSCustomObject]@{ Name = "chkAddCompanyName"; Value = $AddCompanyName.IsPresent },
|
||||
@@ -224,7 +314,7 @@ function Import-IntunePolicies
|
||||
|
||||
[string]$Certificate,
|
||||
|
||||
[ValidateSet("AppOnly","Browser")]
|
||||
[ValidateSet("AppOnly","Browser","DeviceCode")]
|
||||
[string]$AuthMode = "AppOnly",
|
||||
|
||||
[string]$RedirectUri,
|
||||
@@ -238,6 +328,10 @@ function Import-IntunePolicies
|
||||
|
||||
[string]$NameFilter = "",
|
||||
|
||||
[string]$NameSearchPattern = "",
|
||||
|
||||
[string]$NameReplacePattern = "",
|
||||
|
||||
[ValidateSet("alwaysImport","skipIfExist","replace","replace_with_assignments","update")]
|
||||
[string]$ImportType = "alwaysImport",
|
||||
|
||||
@@ -254,6 +348,8 @@ function Import-IntunePolicies
|
||||
BulkImport = @(
|
||||
[PSCustomObject]@{ Name = "txtImportPath"; Value = $ImportPath },
|
||||
[PSCustomObject]@{ Name = "txtImportNameFilter"; Value = $NameFilter },
|
||||
[PSCustomObject]@{ Name = "txtImportNameSearchPattern"; Value = $NameSearchPattern },
|
||||
[PSCustomObject]@{ Name = "txtImportNameReplacePattern"; Value = $NameReplacePattern },
|
||||
[PSCustomObject]@{ Name = "chkAddObjectType"; Value = $true },
|
||||
[PSCustomObject]@{ Name = "chkImportScopes"; Value = $IncludeScopeTags.IsPresent },
|
||||
[PSCustomObject]@{ Name = "chkImportAssignments"; Value = $IncludeAssignments.IsPresent },
|
||||
@@ -292,7 +388,7 @@ function Invoke-IntunePolicyAction
|
||||
|
||||
[string]$Certificate,
|
||||
|
||||
[ValidateSet("AppOnly","Browser")]
|
||||
[ValidateSet("AppOnly","Browser","DeviceCode")]
|
||||
[string]$AuthMode = "AppOnly",
|
||||
|
||||
[string]$RedirectUri,
|
||||
@@ -303,6 +399,10 @@ function Invoke-IntunePolicyAction
|
||||
|
||||
[string]$NameFilter = "",
|
||||
|
||||
[string]$NameSearchPattern = "",
|
||||
|
||||
[string]$NameReplacePattern = "",
|
||||
|
||||
[string[]]$ObjectTypes = (Get-DefaultIntunePolicyObjectTypes),
|
||||
|
||||
[string]$ExportPath,
|
||||
@@ -337,6 +437,8 @@ function Invoke-IntunePolicyAction
|
||||
-SettingsFile $SettingsFile `
|
||||
-BatchFile $BatchFile `
|
||||
-NameFilter $NameFilter `
|
||||
-NameSearchPattern $NameSearchPattern `
|
||||
-NameReplacePattern $NameReplacePattern `
|
||||
-ObjectTypes $ObjectTypes `
|
||||
-IncludeAssignments:$IncludeAssignments `
|
||||
-AddCompanyName:$AddCompanyName
|
||||
@@ -355,6 +457,8 @@ function Invoke-IntunePolicyAction
|
||||
-SettingsFile $SettingsFile `
|
||||
-BatchFile $BatchFile `
|
||||
-NameFilter $NameFilter `
|
||||
-NameSearchPattern $NameSearchPattern `
|
||||
-NameReplacePattern $NameReplacePattern `
|
||||
-ImportType $ImportType `
|
||||
-ObjectTypes $ObjectTypes `
|
||||
-IncludeAssignments:$IncludeAssignments `
|
||||
|
||||
@@ -5,7 +5,7 @@ GUID = 'c7aa4c71-d00d-44bc-9c09-b4741e7435ab'
|
||||
Author = 'Mikael Karlsson'
|
||||
Copyright = '(c) 2026 Mikael Karlsson. Software released under MIT License.'
|
||||
Description = 'Headless Intune policy export and import runtime'
|
||||
FunctionsToExport = @('Initialize-IntuneManagementRuntime', 'Test-IsWindowsPlatform')
|
||||
FunctionsToExport = @('Initialize-IntuneManagementRuntime', 'Test-IsWindowsPlatform', 'Expand-FileName')
|
||||
AliasesToExport = @()
|
||||
ModuleList = @('IntuneManagement.Runtime.psm1')
|
||||
PrivateData = @{
|
||||
|
||||
@@ -3,6 +3,18 @@ function Test-IsWindowsPlatform
|
||||
[Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT
|
||||
}
|
||||
|
||||
function Expand-FileName
|
||||
{
|
||||
param([string]$Path)
|
||||
if(-not $Path) { return $Path }
|
||||
$expanded = [Environment]::ExpandEnvironmentVariables($Path)
|
||||
if($expanded -like "~/*" -or $expanded -eq "~")
|
||||
{
|
||||
$expanded = $expanded -replace "^~", $HOME
|
||||
}
|
||||
return $expanded
|
||||
}
|
||||
|
||||
function Initialize-IntuneManagementRuntime
|
||||
{
|
||||
[CmdletBinding()]
|
||||
@@ -17,7 +29,7 @@ function Initialize-IntuneManagementRuntime
|
||||
[string]$AppId,
|
||||
[string]$Secret,
|
||||
[string]$Certificate,
|
||||
[ValidateSet("AppOnly","Browser")]
|
||||
[ValidateSet("AppOnly","Browser","DeviceCode")]
|
||||
[string]$AuthMode = "AppOnly",
|
||||
[string]$RedirectUri,
|
||||
[string]$GraphEnvironment,
|
||||
@@ -77,6 +89,10 @@ function Initialize-IntuneManagementRuntime
|
||||
{
|
||||
Write-Host "Using browser authentication"
|
||||
}
|
||||
elseif($global:HeadlessAuthMode -eq "DeviceCode")
|
||||
{
|
||||
Write-Host "Using device code authentication"
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Warning "Azure App Secret or Certificate is missing. Use -Secret <Secret> or -Certificate <Certificate>."
|
||||
@@ -91,4 +107,4 @@ function Initialize-IntuneManagementRuntime
|
||||
Start-CoreApp $View
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Initialize-IntuneManagementRuntime, Test-IsWindowsPlatform
|
||||
Export-ModuleMember -Function Initialize-IntuneManagementRuntime, Test-IsWindowsPlatform, Expand-FileName
|
||||
|
||||
523
Scripts/Backup-Restore-Assignments.ps1
Normal file
523
Scripts/Backup-Restore-Assignments.ps1
Normal file
@@ -0,0 +1,523 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Backup and restore Intune policy/app assignments.
|
||||
.DESCRIPTION
|
||||
Backs up assignments for selected object types to a JSON file,
|
||||
or restores assignments from a previously created backup.
|
||||
Works cross-platform (macOS/Linux/Windows) using the headless auth stack.
|
||||
.EXAMPLE
|
||||
# Backup
|
||||
./Scripts/Backup-Restore-Assignments.ps1 -TenantId "..." -Mode Backup -OutputPath ./backups/assignments-backup.json
|
||||
|
||||
# Restore
|
||||
./Scripts/Backup-Restore-Assignments.ps1 -TenantId "..." -Mode Restore -InputPath ./backups/assignments-backup.json
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TenantId,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet("Backup","Restore")]
|
||||
[string]$Mode,
|
||||
|
||||
[string]$OutputPath,
|
||||
|
||||
[string]$InputPath,
|
||||
|
||||
[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 Validate paths
|
||||
if($Mode -eq "Backup" -and -not $OutputPath)
|
||||
{
|
||||
throw "Backup mode requires -OutputPath."
|
||||
}
|
||||
if($Mode -eq "Restore" -and -not $InputPath)
|
||||
{
|
||||
throw "Restore mode requires -InputPath."
|
||||
}
|
||||
if($Mode -eq "Restore" -and -not (Test-Path $InputPath))
|
||||
{
|
||||
throw "Input file not found: $InputPath"
|
||||
}
|
||||
#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
|
||||
$assignableTypes = @(
|
||||
[PSCustomObject]@{ Title = "Applications"; API = "/deviceAppManagement/mobileApps"; AssignmentsType = "mobileAppAssignments"; HasIntent = $true; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Device Configuration"; API = "/deviceManagement/deviceConfigurations"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Settings Catalog"; API = "/deviceManagement/configurationPolicies"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "name" },
|
||||
[PSCustomObject]@{ Title = "Compliance Policies"; API = "/deviceManagement/deviceCompliancePolicies"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Administrative Templates"; API = "/deviceManagement/groupPolicyConfigurations"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Endpoint Security"; API = "/deviceManagement/intents"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "App Protection"; API = "/deviceAppManagement/managedAppPolicies"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "App Configuration (Device)"; API = "/deviceAppManagement/mobileAppConfigurations"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Platform Scripts"; API = "/deviceManagement/deviceManagementScripts"; AssignmentsType = "deviceManagementScriptAssignments"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "macOS Scripts"; API = "/deviceManagement/deviceShellScripts"; AssignmentsType = "deviceManagementScriptAssignments"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Device Health Scripts"; API = "/deviceManagement/deviceHealthScripts"; AssignmentsType = "deviceHealthScriptAssignments"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "macOS Custom Attributes"; API = "/deviceManagement/deviceCustomAttributeShellScripts"; AssignmentsType = "deviceManagementScriptAssignments"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Enrollment Restrictions"; API = "/deviceManagement/deviceEnrollmentConfigurations"; AssignmentsType = "enrollmentConfigurationAssignments"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Enrollment Status Page"; API = "/deviceManagement/deviceEnrollmentConfigurations"; AssignmentsType = "enrollmentConfigurationAssignments"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Autopilot"; API = "/deviceManagement/windowsAutopilotDeploymentProfiles"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Terms and Conditions"; API = "/deviceManagement/termsAndConditions"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Policy Sets"; API = "/deviceAppManagement/policySets"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Update Policies"; API = "/deviceManagement/windowsUpdateForBusinessConfigurations"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Feature Updates"; API = "/deviceManagement/windowsFeatureUpdateProfiles"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Quality Updates"; API = "/deviceManagement/windowsQualityUpdateProfiles"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Device Management Intents"; API = "/deviceManagement/intents"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" }
|
||||
)
|
||||
#endregion
|
||||
|
||||
#region BACKUP
|
||||
if($Mode -eq "Backup")
|
||||
{
|
||||
$typeTitles = $assignableTypes | ForEach-Object { $_.Title }
|
||||
$selectedTypeTitles = Select-MenuItem -Items $typeTitles -Header "Select object types to back up (multi-select)" -Multi
|
||||
if(-not $selectedTypeTitles)
|
||||
{
|
||||
Write-Host "No types selected. Exiting." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Preload groups for name resolution in backup
|
||||
Write-Host "`nLoading groups for backup resolution..." -ForegroundColor Cyan
|
||||
$backupGroupsResponse = Invoke-GraphRequest "/groups?`$select=id,displayName&`$top=999"
|
||||
$backupGroups = @{}
|
||||
foreach($g in $backupGroupsResponse.value)
|
||||
{
|
||||
$backupGroups[$g.id] = $g.displayName
|
||||
}
|
||||
|
||||
$backupData = @{
|
||||
TenantId = $org.value[0].id
|
||||
TenantName = $org.value[0].displayName
|
||||
Created = (Get-Date -Format "o")
|
||||
Groups = $backupGroups
|
||||
Objects = @()
|
||||
}
|
||||
|
||||
foreach($typeTitle in $selectedTypeTitles)
|
||||
{
|
||||
$objectType = $assignableTypes | Where-Object { $_.Title -eq $typeTitle } | Select-Object -First 1
|
||||
Write-Host "`nBacking up $($objectType.Title) assignments..." -ForegroundColor Cyan
|
||||
|
||||
try
|
||||
{
|
||||
$objectsResponse = Invoke-GraphRequest "$($objectType.API)?`$select=id,$($objectType.NameProp)&`$orderby=$($objectType.NameProp)"
|
||||
$objects = $objectsResponse.value | Where-Object { $_ }
|
||||
Write-Host " Found $($objects.Count) objects" -ForegroundColor Green
|
||||
|
||||
foreach($obj in $objects)
|
||||
{
|
||||
try
|
||||
{
|
||||
$assignmentsResponse = Invoke-GraphRequest "$($objectType.API)/$($obj.id)/assignments"
|
||||
$assignments = $assignmentsResponse.value
|
||||
if($assignments.Count -gt 0)
|
||||
{
|
||||
# Enrich assignments with group display names for cross-tenant restore
|
||||
$enrichedAssignments = $assignments | ConvertTo-Json -Depth 50 | ConvertFrom-Json
|
||||
foreach($ass in $enrichedAssignments)
|
||||
{
|
||||
if($ass.target.groupId -and $backupGroups.ContainsKey($ass.target.groupId))
|
||||
{
|
||||
$ass.target | Add-Member -NotePropertyName "_backupGroupName" -NotePropertyValue $backupGroups[$ass.target.groupId] -Force
|
||||
}
|
||||
}
|
||||
$backupData.Objects += [PSCustomObject]@{
|
||||
ObjectType = $objectType.Title
|
||||
ObjectId = $obj.id
|
||||
ObjectName = if($objectType.NameProp -eq "name") { $obj.name } else { $obj.displayName }
|
||||
NameProp = $objectType.NameProp
|
||||
API = $objectType.API
|
||||
AssignmentsType = $objectType.AssignmentsType
|
||||
Assignments = $enrichedAssignments
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Host " WARNING: Could not backup assignments for $($obj."$($objectType.NameProp)")" -ForegroundColor DarkYellow
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Host " WARNING: Could not load objects for $($objectType.Title)" -ForegroundColor DarkYellow
|
||||
}
|
||||
}
|
||||
|
||||
$backupJson = $backupData | ConvertTo-Json -Depth 50
|
||||
$OutputPath = (Resolve-Path (Split-Path -Parent $OutputPath) -ErrorAction SilentlyContinue).Path + "/" + (Split-Path -Leaf $OutputPath)
|
||||
$backupJson | Out-File -LiteralPath $OutputPath -Encoding utf8 -Force
|
||||
|
||||
$totalAssignments = 0
|
||||
foreach($obj in $backupData.Objects) { $totalAssignments += $obj.Assignments.Count }
|
||||
|
||||
Write-Host "`n========================================" -ForegroundColor Cyan
|
||||
Write-Host " Backup Complete" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " File : $OutputPath"
|
||||
Write-Host " Objects : $($backupData.Objects.Count)"
|
||||
Write-Host " Assignments : $totalAssignments"
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region RESTORE
|
||||
elseif($Mode -eq "Restore")
|
||||
{
|
||||
$backup = Get-Content $InputPath -Raw | ConvertFrom-Json
|
||||
|
||||
Write-Host "`nBackup info:" -ForegroundColor Cyan
|
||||
Write-Host " Tenant : $($backup.TenantName) ($($backup.TenantId))"
|
||||
Write-Host " Created: $($backup.Created)"
|
||||
Write-Host " Objects: $($backup.Objects.Count)"
|
||||
|
||||
$currentTenantId = $org.value[0].id
|
||||
if($backup.TenantId -ne $currentTenantId)
|
||||
{
|
||||
Write-Host "`nWARNING: Backup is from a different tenant!" -ForegroundColor Yellow
|
||||
if(-not (Read-YesNo -Prompt "Continue anyway?" -Default $false))
|
||||
{
|
||||
Write-Host "Cancelled." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
# Resolve group names to IDs in current tenant if needed
|
||||
Write-Host "`nLoading current tenant groups for name resolution..." -ForegroundColor Cyan
|
||||
$currentGroupsResponse = Invoke-GraphRequest "/groups?`$select=id,displayName&`$top=999"
|
||||
$currentGroups = $currentGroupsResponse.value
|
||||
|
||||
$success = 0
|
||||
$skipped = 0
|
||||
$failed = 0
|
||||
|
||||
foreach($entry in $backup.Objects)
|
||||
{
|
||||
Write-Host "`nRestoring: $($entry.ObjectName) ($($entry.ObjectType))" -ForegroundColor Cyan
|
||||
|
||||
# Try to find the object in current tenant by displayName
|
||||
$nameProp = ?? $entry.NameProp "displayName"
|
||||
$searchUrl = "$($entry.API)?`$filter=$nameProp eq '$([uri]::EscapeDataString($entry.ObjectName))'&`$select=id,$nameProp"
|
||||
try
|
||||
{
|
||||
$searchResult = Invoke-GraphRequest $searchUrl
|
||||
$targetObj = $searchResult.value | Select-Object -First 1
|
||||
}
|
||||
catch
|
||||
{
|
||||
$targetObj = $null
|
||||
}
|
||||
|
||||
if(-not $targetObj)
|
||||
{
|
||||
Write-Host " SKIP: Object '$($entry.ObjectName)' not found in current tenant" -ForegroundColor DarkYellow
|
||||
$failed++
|
||||
continue
|
||||
}
|
||||
|
||||
# Load existing assignments to avoid duplicates
|
||||
try
|
||||
{
|
||||
$existing = Invoke-GraphRequest "$($entry.API)/$($targetObj.id)/assignments"
|
||||
$existingTargets = $existing.value
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Host " ERROR: Could not load existing assignments" -ForegroundColor Red
|
||||
$failed++
|
||||
continue
|
||||
}
|
||||
|
||||
function Test-BackupAssignmentExists
|
||||
{
|
||||
param($assignment, $existingList)
|
||||
$t = $assignment.target
|
||||
foreach($ea in $existingList)
|
||||
{
|
||||
$et = $ea.target
|
||||
if($t."@odata.type" -ne $et."@odata.type") { continue }
|
||||
if($t.groupId -and $t.groupId -ne $et.groupId) { continue }
|
||||
# Also match intent for apps
|
||||
if($entry.AssignmentsType -eq "mobileAppAssignments" -and ($assignment.intent -ne $ea.intent)) { continue }
|
||||
return $true
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
foreach($assignment in $entry.Assignments)
|
||||
{
|
||||
# Clone assignment to avoid modifying backup data
|
||||
$restoredAssignment = $assignment | ConvertTo-Json -Depth 50 | ConvertFrom-Json
|
||||
|
||||
# Remove Id
|
||||
if($restoredAssignment.PSObject.Properties["id"])
|
||||
{
|
||||
$restoredAssignment.PSObject.Properties.Remove("id")
|
||||
}
|
||||
|
||||
# Map group IDs if cross-tenant
|
||||
if($backup.TenantId -ne $currentTenantId -and $restoredAssignment.target.groupId)
|
||||
{
|
||||
$originalGroupName = $restoredAssignment.target."_backupGroupName"
|
||||
if($originalGroupName)
|
||||
{
|
||||
$matchedGroup = $currentGroups | Where-Object { $_.displayName -eq $originalGroupName } | Select-Object -First 1
|
||||
if($matchedGroup)
|
||||
{
|
||||
Write-Host " MAPPED: Group '$originalGroupName' -> $($matchedGroup.id)" -ForegroundColor Gray
|
||||
$restoredAssignment.target.groupId = $matchedGroup.id
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Host " SKIP: Could not find group '$originalGroupName' in current tenant" -ForegroundColor DarkYellow
|
||||
$skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Host " SKIP: Cross-tenant restore cannot resolve group without name mapping" -ForegroundColor DarkYellow
|
||||
$skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
# Clean up internal property before sending
|
||||
if($restoredAssignment.target.PSObject.Properties["_backupGroupName"])
|
||||
{
|
||||
$restoredAssignment.target.PSObject.Properties.Remove("_backupGroupName")
|
||||
}
|
||||
|
||||
if(Test-BackupAssignmentExists -assignment $restoredAssignment -existingList $existingTargets)
|
||||
{
|
||||
Write-Host " SKIP: Assignment already exists" -ForegroundColor DarkYellow
|
||||
$skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
# Prepare payload
|
||||
$payload = @{
|
||||
$entry.AssignmentsType = @($restoredAssignment)
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$body = $payload | ConvertTo-Json -Depth 50 -Compress
|
||||
$null = Invoke-GraphRequest "$($entry.API)/$($targetObj.id)/assign" -HttpMethod POST -Content $body
|
||||
Write-Host " OK: Restored assignment" -ForegroundColor Green
|
||||
$success++
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Host " ERROR: Failed to restore assignment. $($_.Exception.Message)" -ForegroundColor Red
|
||||
$failed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "`n========================================" -ForegroundColor Cyan
|
||||
Write-Host " Restore Complete" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Success : $success"
|
||||
Write-Host " Skipped : $skipped"
|
||||
Write-Host " Failed : $failed"
|
||||
}
|
||||
#endregion
|
||||
446
Scripts/Bulk-AppAssignment.ps1
Normal file
446
Scripts/Bulk-AppAssignment.ps1
Normal file
@@ -0,0 +1,446 @@
|
||||
#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
|
||||
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
|
||||
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
|
||||
425
Scripts/Bulk-RenamePolicies.ps1
Normal file
425
Scripts/Bulk-RenamePolicies.ps1
Normal file
@@ -0,0 +1,425 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bulk rename Intune policy/app displayNames and descriptions.
|
||||
.DESCRIPTION
|
||||
Search and replace names or descriptions across multiple Intune object types
|
||||
in a single operation. Supports regex search/replace and prefix add/strip.
|
||||
Integrates with the IntuneManagement headless auth stack.
|
||||
.EXAMPLE
|
||||
./Scripts/Bulk-RenamePolicies.ps1 -TenantId "contoso.onmicrosoft.com"
|
||||
#>
|
||||
[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
|
||||
|
||||
#region Object type registry (editable types)
|
||||
$editableTypes = @(
|
||||
[PSCustomObject]@{ Title = "Applications"; API = "/deviceAppManagement/mobileApps"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Device Configuration"; API = "/deviceManagement/deviceConfigurations"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Settings Catalog"; API = "/deviceManagement/configurationPolicies"; NameProp = "name"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Compliance Policies"; API = "/deviceManagement/deviceCompliancePolicies"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Administrative Templates"; API = "/deviceManagement/groupPolicyConfigurations"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Endpoint Security"; API = "/deviceManagement/intents"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "App Protection"; API = "/deviceAppManagement/managedAppPolicies"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "App Configuration (Device)"; API = "/deviceAppManagement/mobileAppConfigurations"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Platform Scripts"; API = "/deviceManagement/deviceManagementScripts"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "macOS Scripts"; API = "/deviceManagement/deviceShellScripts"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Device Health Scripts"; API = "/deviceManagement/deviceHealthScripts"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "macOS Custom Attributes"; API = "/deviceManagement/deviceCustomAttributeShellScripts"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Enrollment Restrictions"; API = "/deviceManagement/deviceEnrollmentConfigurations"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Enrollment Status Page"; API = "/deviceManagement/deviceEnrollmentConfigurations"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Autopilot"; API = "/deviceManagement/windowsAutopilotDeploymentProfiles"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Terms and Conditions"; API = "/deviceManagement/termsAndConditions"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Policy Sets"; API = "/deviceAppManagement/policySets"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Update Policies"; API = "/deviceManagement/windowsUpdateForBusinessConfigurations"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Feature Updates"; API = "/deviceManagement/windowsFeatureUpdateProfiles"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Quality Updates"; API = "/deviceManagement/windowsQualityUpdateProfiles"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Device Management Intents"; API = "/deviceManagement/intents"; NameProp = "displayName"; DescProp = "description" }
|
||||
)
|
||||
#endregion
|
||||
|
||||
Clear-Host
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Intune Bulk Rename Tool" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
|
||||
#region Select object type
|
||||
$typeTitles = $editableTypes | ForEach-Object { $_.Title }
|
||||
$selectedTypeTitle = Select-MenuItem -Items $typeTitles -Header "Select object type"
|
||||
if(-not $selectedTypeTitle) { Write-Host "Cancelled." -ForegroundColor Yellow; exit 0 }
|
||||
$objectType = $editableTypes | 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),$(if($objectType.DescProp){$objectType.DescProp})&`$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 current 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 to rename (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 Mutation options
|
||||
$fieldToEdit = Select-MenuItem -Items @("displayName","description","both") -Header "Which field to edit?"
|
||||
if(-not $fieldToEdit) { $fieldToEdit = "displayName" }
|
||||
|
||||
$mode = Select-MenuItem -Items @("Search and replace","Add prefix","Strip prefix") -Header "Select rename mode"
|
||||
if(-not $mode) { Write-Host "Cancelled." -ForegroundColor Yellow; exit 0 }
|
||||
|
||||
$searchPattern = ""
|
||||
$replacePattern = ""
|
||||
$prefix = ""
|
||||
|
||||
switch($mode)
|
||||
{
|
||||
"Search and replace"
|
||||
{
|
||||
$searchPattern = Read-Host "Enter search regex"
|
||||
$replacePattern = Read-Host "Enter replacement string"
|
||||
}
|
||||
"Add prefix"
|
||||
{
|
||||
$prefix = Read-Host "Enter prefix to add"
|
||||
}
|
||||
"Strip prefix"
|
||||
{
|
||||
$prefix = Read-Host "Enter prefix to strip (will be removed from start)"
|
||||
}
|
||||
}
|
||||
|
||||
# Preview changes
|
||||
Write-Host "`nPreview of changes:" -ForegroundColor Cyan
|
||||
$changes = @()
|
||||
foreach($obj in $selectedObjects)
|
||||
{
|
||||
$oldName = $obj."$($objectType.NameProp)"
|
||||
$oldDesc = if($objectType.DescProp -and $obj.PSObject.Properties[$objectType.DescProp]) { $obj."$($objectType.DescProp)" } else { "" }
|
||||
$newName = $oldName
|
||||
$newDesc = $oldDesc
|
||||
|
||||
if($fieldToEdit -in @("displayName","both"))
|
||||
{
|
||||
switch($mode)
|
||||
{
|
||||
"Search and replace" { if($oldName -match $searchPattern) { $newName = $oldName -replace $searchPattern, $replacePattern } }
|
||||
"Add prefix" { $newName = "$prefix$oldName" }
|
||||
"Strip prefix" { if($oldName.StartsWith($prefix)) { $newName = $oldName.Substring($prefix.Length) } }
|
||||
}
|
||||
}
|
||||
if($fieldToEdit -in @("description","both") -and $objectType.DescProp)
|
||||
{
|
||||
switch($mode)
|
||||
{
|
||||
"Search and replace" { if($oldDesc -match $searchPattern) { $newDesc = $oldDesc -replace $searchPattern, $replacePattern } }
|
||||
"Add prefix" { $newDesc = "$prefix$oldDesc" }
|
||||
"Strip prefix" { if($oldDesc.StartsWith($prefix)) { $newDesc = $oldDesc.Substring($prefix.Length) } }
|
||||
}
|
||||
}
|
||||
|
||||
if($newName -ne $oldName -or $newDesc -ne $oldDesc)
|
||||
{
|
||||
$changes += [PSCustomObject]@{
|
||||
Object = $obj
|
||||
OldName = $oldName
|
||||
NewName = $newName
|
||||
OldDesc = $oldDesc
|
||||
NewDesc = $newDesc
|
||||
}
|
||||
Write-Host " $($oldName)" -ForegroundColor DarkGray
|
||||
if($newName -ne $oldName) { Write-Host " -> Name: $newName" -ForegroundColor Green }
|
||||
if($newDesc -ne $oldDesc) { Write-Host " -> Desc: $newDesc" -ForegroundColor Green }
|
||||
}
|
||||
}
|
||||
|
||||
if($changes.Count -eq 0)
|
||||
{
|
||||
Write-Host "No objects would be changed. Exiting." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
$confirm = Read-Host "`nProceed with renaming $($changes.Count) objects? [Y/n]"
|
||||
if(-not ([string]::IsNullOrWhiteSpace($confirm) -or $confirm -match "^\s*y"))
|
||||
{
|
||||
Write-Host "Cancelled." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Execute
|
||||
$success = 0
|
||||
$failed = 0
|
||||
|
||||
foreach($change in $changes)
|
||||
{
|
||||
$obj = $change.Object
|
||||
$payload = @{}
|
||||
|
||||
if($fieldToEdit -in @("displayName","both") -and $change.NewName -ne $change.OldName)
|
||||
{
|
||||
$payload[$objectType.NameProp] = $change.NewName
|
||||
}
|
||||
if($fieldToEdit -in @("description","both") -and $objectType.DescProp -and $change.NewDesc -ne $change.OldDesc)
|
||||
{
|
||||
$payload[$objectType.DescProp] = $change.NewDesc
|
||||
}
|
||||
|
||||
if($payload.Count -eq 0) { continue }
|
||||
|
||||
try
|
||||
{
|
||||
if($WhatIf)
|
||||
{
|
||||
Write-Host " WHATIF: Would update $($change.OldName)" -ForegroundColor Magenta
|
||||
$success++
|
||||
}
|
||||
else
|
||||
{
|
||||
$body = $payload | ConvertTo-Json -Depth 10 -Compress
|
||||
$null = Invoke-GraphRequest "$($objectType.API)/$($obj.id)" -HttpMethod PATCH -Content $body
|
||||
Write-Host " OK: Renamed '$($change.OldName)' -> '$($change.NewName)'" -ForegroundColor Green
|
||||
$success++
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Host " ERROR: Failed to rename '$($change.OldName)'. $($_.Exception.Message)" -ForegroundColor Red
|
||||
$failed++
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "`n========================================" -ForegroundColor Cyan
|
||||
Write-Host " Bulk Rename Complete" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Success : $success"
|
||||
Write-Host " Failed : $failed"
|
||||
#endregion
|
||||
112
Scripts/Create-IntuneManagementApp.ps1
Normal file
112
Scripts/Create-IntuneManagementApp.ps1
Normal file
@@ -0,0 +1,112 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates a Microsoft Entra app registration for headless Intune export/import.
|
||||
.DESCRIPTION
|
||||
Uses the Microsoft Graph PowerShell SDK to create an app, add required Graph
|
||||
permissions, generate a client secret, and output the values needed for
|
||||
AppOnly authentication.
|
||||
|
||||
Requires: Microsoft.Graph.Authentication, Microsoft.Graph.Applications
|
||||
Install if missing: Install-Module Microsoft.Graph -Scope CurrentUser
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$DisplayName = "IntuneManagement-Headless",
|
||||
|
||||
[ValidateSet("Export","Import","Both")]
|
||||
[string]$PermissionLevel = "Both"
|
||||
)
|
||||
|
||||
$requiredModules = @("Microsoft.Graph.Authentication", "Microsoft.Graph.Applications")
|
||||
foreach ($mod in $requiredModules) {
|
||||
if (-not (Get-Module $mod -ListAvailable)) {
|
||||
throw "Module '$mod' is not installed. Run: Install-Module Microsoft.Graph -Scope CurrentUser"
|
||||
}
|
||||
}
|
||||
|
||||
Import-Module Microsoft.Graph.Authentication -Force
|
||||
Import-Module Microsoft.Graph.Applications -Force
|
||||
|
||||
Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Cyan
|
||||
Write-Host "A browser window will open for authentication." -ForegroundColor Cyan
|
||||
Connect-MgGraph -Scopes "Application.ReadWrite.All", "AppRoleAssignment.ReadWrite.All" -NoWelcome
|
||||
|
||||
$graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
|
||||
if (-not $graphSp) {
|
||||
throw "Could not retrieve Microsoft Graph service principal."
|
||||
}
|
||||
|
||||
$exportRoles = @(
|
||||
"DeviceManagementApps.Read.All",
|
||||
"DeviceManagementConfiguration.Read.All",
|
||||
"DeviceManagementManagedDevices.Read.All",
|
||||
"DeviceManagementScripts.Read.All",
|
||||
"DeviceManagementServiceConfig.Read.All",
|
||||
"Group.Read.All",
|
||||
"Organization.Read.All"
|
||||
)
|
||||
|
||||
$importRoles = @(
|
||||
"DeviceManagementApps.ReadWrite.All",
|
||||
"DeviceManagementConfiguration.ReadWrite.All",
|
||||
"DeviceManagementManagedDevices.ReadWrite.All",
|
||||
"DeviceManagementScripts.ReadWrite.All",
|
||||
"DeviceManagementServiceConfig.ReadWrite.All",
|
||||
"Group.ReadWrite.All",
|
||||
"Organization.Read.All"
|
||||
)
|
||||
|
||||
$roles = switch ($PermissionLevel) {
|
||||
"Export" { $exportRoles }
|
||||
"Import" { $importRoles }
|
||||
"Both" { ($exportRoles + $importRoles) | Select-Object -Unique }
|
||||
}
|
||||
|
||||
$resourceAccess = @()
|
||||
foreach ($roleName in $roles) {
|
||||
$appRole = $graphSp.AppRoles | Where-Object { $_.Value -eq $roleName } | Select-Object -First 1
|
||||
if (-not $appRole) {
|
||||
Write-Warning "Could not find app role: $roleName"
|
||||
continue
|
||||
}
|
||||
$resourceAccess += @{
|
||||
id = $appRole.Id
|
||||
type = "Role"
|
||||
}
|
||||
}
|
||||
|
||||
$appParams = @{
|
||||
DisplayName = $DisplayName
|
||||
SignInAudience = "AzureADMyOrg"
|
||||
RequiredResourceAccess = @(@{
|
||||
resourceAppId = "00000003-0000-0000-c000-000000000000"
|
||||
resourceAccess = $resourceAccess
|
||||
})
|
||||
}
|
||||
|
||||
Write-Host "Creating application '$DisplayName'..." -ForegroundColor Cyan
|
||||
$app = New-MgApplication @appParams
|
||||
|
||||
Write-Host "Creating service principal..." -ForegroundColor Cyan
|
||||
$sp = New-MgServicePrincipal -AppId $app.AppId
|
||||
|
||||
Write-Host "Adding client secret..." -ForegroundColor Cyan
|
||||
$passwordCred = @{
|
||||
displayName = "IntuneManagementSecret"
|
||||
endDateTime = (Get-Date).AddYears(1)
|
||||
}
|
||||
$secret = Add-MgApplicationPassword -ApplicationId $app.Id -PasswordCredential $passwordCred
|
||||
|
||||
Write-Host "`n=============================================================" -ForegroundColor Green
|
||||
Write-Host "App Registration created successfully!" -ForegroundColor Green
|
||||
Write-Host "=============================================================" -ForegroundColor Green
|
||||
Write-Host "TenantId : $(Get-MgContext | Select-Object -ExpandProperty TenantId)"
|
||||
Write-Host "AppId : $($app.AppId)"
|
||||
Write-Host "Secret : $($secret.SecretText)"
|
||||
Write-Host "=============================================================" -ForegroundColor Green
|
||||
Write-Host "IMPORTANT: Go to the Entra portal > API Permissions and click" -ForegroundColor Yellow
|
||||
Write-Host " 'Grant admin consent for <tenant>' before using" -ForegroundColor Yellow
|
||||
Write-Host " the app for Export or Import." -ForegroundColor Yellow
|
||||
Write-Host "=============================================================" -ForegroundColor Green
|
||||
|
||||
Disconnect-MgGraph | Out-Null
|
||||
368
Scripts/Export-AssignmentsToCsv.ps1
Normal file
368
Scripts/Export-AssignmentsToCsv.ps1
Normal file
@@ -0,0 +1,368 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Export Intune policy/app assignments to CSV or Markdown for documentation.
|
||||
.DESCRIPTION
|
||||
Generates a CSV or Markdown report of assignments for selected object types.
|
||||
Useful for documentation, change tracking, and compliance audits.
|
||||
.EXAMPLE
|
||||
./Scripts/Export-AssignmentsToCsv.ps1 -TenantId "..." -Format Csv -OutputPath ./assignments.csv
|
||||
./Scripts/Export-AssignmentsToCsv.ps1 -TenantId "..." -Format Markdown -OutputPath ./assignments.md
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TenantId,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet("Csv","Markdown")]
|
||||
[string]$Format,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputPath,
|
||||
|
||||
[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 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
|
||||
$assignableTypes = @(
|
||||
[PSCustomObject]@{ Title = "Applications"; API = "/deviceAppManagement/mobileApps"; HasIntent = $true; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Device Configuration"; API = "/deviceManagement/deviceConfigurations"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Settings Catalog"; API = "/deviceManagement/configurationPolicies"; HasIntent = $false; NameProp = "name" },
|
||||
[PSCustomObject]@{ Title = "Compliance Policies"; API = "/deviceManagement/deviceCompliancePolicies"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Administrative Templates"; API = "/deviceManagement/groupPolicyConfigurations"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Endpoint Security"; API = "/deviceManagement/intents"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "App Protection"; API = "/deviceAppManagement/managedAppPolicies"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "App Configuration (Device)"; API = "/deviceAppManagement/mobileAppConfigurations"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Platform Scripts"; API = "/deviceManagement/deviceManagementScripts"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "macOS Scripts"; API = "/deviceManagement/deviceShellScripts"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Device Health Scripts"; API = "/deviceManagement/deviceHealthScripts"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "macOS Custom Attributes"; API = "/deviceManagement/deviceCustomAttributeShellScripts"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Enrollment Restrictions"; API = "/deviceManagement/deviceEnrollmentConfigurations"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Enrollment Status Page"; API = "/deviceManagement/deviceEnrollmentConfigurations"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Autopilot"; API = "/deviceManagement/windowsAutopilotDeploymentProfiles"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Terms and Conditions"; API = "/deviceManagement/termsAndConditions"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Policy Sets"; API = "/deviceAppManagement/policySets"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Update Policies"; API = "/deviceManagement/windowsUpdateForBusinessConfigurations"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Feature Updates"; API = "/deviceManagement/windowsFeatureUpdateProfiles"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Quality Updates"; API = "/deviceManagement/windowsQualityUpdateProfiles"; HasIntent = $false; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Device Management Intents"; API = "/deviceManagement/intents"; HasIntent = $false; NameProp = "displayName" }
|
||||
)
|
||||
#endregion
|
||||
|
||||
#region Select types and gather data
|
||||
$typeTitles = $assignableTypes | ForEach-Object { $_.Title }
|
||||
$selectedTypeTitles = Select-MenuItem -Items $typeTitles -Header "Select object types to export (multi-select)" -Multi
|
||||
if(-not $selectedTypeTitles)
|
||||
{
|
||||
Write-Host "No types selected. Exiting." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "`nLoading groups for name resolution..." -ForegroundColor Cyan
|
||||
$groupsResponse = Invoke-GraphRequest "/groups?`$select=id,displayName&`$top=999"
|
||||
$groups = $groupsResponse.value
|
||||
|
||||
$reportRows = @()
|
||||
|
||||
foreach($typeTitle in $selectedTypeTitles)
|
||||
{
|
||||
$objectType = $assignableTypes | Where-Object { $_.Title -eq $typeTitle } | Select-Object -First 1
|
||||
Write-Host "`nExporting $($objectType.Title) assignments..." -ForegroundColor Cyan
|
||||
|
||||
try
|
||||
{
|
||||
$objectsResponse = Invoke-GraphRequest "$($objectType.API)?`$select=id,$($objectType.NameProp)&`$orderby=$($objectType.NameProp)"
|
||||
$objects = $objectsResponse.value | Where-Object { $_ }
|
||||
|
||||
foreach($obj in $objects)
|
||||
{
|
||||
try
|
||||
{
|
||||
$assignmentsResponse = Invoke-GraphRequest "$($objectType.API)/$($obj.id)/assignments"
|
||||
foreach($ass in $assignmentsResponse.value)
|
||||
{
|
||||
$targetType = $ass.target."@odata.type"
|
||||
$targetName = "Unknown"
|
||||
$groupId = $ass.target.groupId
|
||||
if($targetType -eq "#microsoft.graph.groupAssignmentTarget")
|
||||
{
|
||||
$grp = $groups | Where-Object { $_.id -eq $groupId } | Select-Object -First 1
|
||||
$targetName = if($grp) { $grp.displayName } else { $groupId }
|
||||
}
|
||||
elseif($targetType -eq "#microsoft.graph.exclusionGroupAssignmentTarget")
|
||||
{
|
||||
$grp = $groups | Where-Object { $_.id -eq $groupId } | Select-Object -First 1
|
||||
$targetName = if($grp) { "Exclude: $($grp.displayName)" } else { "Exclude: $groupId" }
|
||||
}
|
||||
elseif($targetType -eq "#microsoft.graph.allLicensedUsersAssignmentTarget")
|
||||
{
|
||||
$targetName = "All Users"
|
||||
}
|
||||
elseif($targetType -eq "#microsoft.graph.allDevicesAssignmentTarget")
|
||||
{
|
||||
$targetName = "All Devices"
|
||||
}
|
||||
|
||||
$filterName = ""
|
||||
if($ass.target.deviceAndAppManagementAssignmentFilterId)
|
||||
{
|
||||
$filterName = $ass.target.deviceAndAppManagementAssignmentFilterId
|
||||
}
|
||||
|
||||
$intent = ""
|
||||
if($objectType.HasIntent -and $ass.intent)
|
||||
{
|
||||
$intent = $ass.intent
|
||||
}
|
||||
|
||||
$reportRows += [PSCustomObject]@{
|
||||
ObjectType = $objectType.Title
|
||||
ObjectName = if($objectType.NameProp -eq "name") { $obj.name } else { $obj.displayName }
|
||||
ObjectId = $obj.id
|
||||
Target = $targetName
|
||||
TargetType = $targetType
|
||||
Intent = $intent
|
||||
Filter = $filterName
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
# suppress per-object errors
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Host " WARNING: Could not load objects for $($objectType.Title)" -ForegroundColor DarkYellow
|
||||
}
|
||||
}
|
||||
|
||||
if($reportRows.Count -eq 0)
|
||||
{
|
||||
Write-Host "No assignments found to export. Exiting." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Export
|
||||
$OutputPath = (Resolve-Path (Split-Path -Parent $OutputPath) -ErrorAction SilentlyContinue).Path + "/" + (Split-Path -Leaf $OutputPath)
|
||||
|
||||
if($Format -eq "Csv")
|
||||
{
|
||||
$reportRows | Export-Csv -LiteralPath $OutputPath -NoTypeInformation -Encoding utf8 -Force
|
||||
Write-Host "`nExported $($reportRows.Count) rows to CSV: $OutputPath" -ForegroundColor Green
|
||||
}
|
||||
elseif($Format -eq "Markdown")
|
||||
{
|
||||
$md = @()
|
||||
$md += "# Intune Assignments Report"
|
||||
$md += ""
|
||||
$md += "**Tenant:** $($org.value[0].displayName) "
|
||||
$md += "**Generated:** $(Get-Date -Format "yyyy-MM-dd HH:mm") "
|
||||
$md += "**Total Rows:** $($reportRows.Count)"
|
||||
$md += ""
|
||||
|
||||
$grouped = $reportRows | Group-Object -Property ObjectType
|
||||
foreach($g in $grouped)
|
||||
{
|
||||
$md += "## $($g.Name)"
|
||||
$md += ""
|
||||
$md += "| Object | Target | Intent | Filter |"
|
||||
$md += "|--------|--------|--------|--------|"
|
||||
foreach($row in ($g.Group | Sort-Object ObjectName, Target))
|
||||
{
|
||||
$intentCol = if($row.Intent) { $row.Intent } else { "-" }
|
||||
$filterCol = if($row.Filter) { $row.Filter } else { "-" }
|
||||
$md += "| $($row.ObjectName) | $($row.Target) | $intentCol | $filterCol |"
|
||||
}
|
||||
$md += ""
|
||||
}
|
||||
|
||||
$md | Out-File -LiteralPath $OutputPath -Encoding utf8 -Force
|
||||
Write-Host "`nExported $($reportRows.Count) rows to Markdown: $OutputPath" -ForegroundColor Green
|
||||
}
|
||||
#endregion
|
||||
@@ -13,7 +13,7 @@ param(
|
||||
|
||||
[string]$Certificate,
|
||||
|
||||
[ValidateSet("AppOnly","Browser")]
|
||||
[ValidateSet("AppOnly","Browser","DeviceCode")]
|
||||
[string]$AuthMode = "AppOnly",
|
||||
|
||||
[string]$RedirectUri,
|
||||
@@ -27,14 +27,11 @@ param(
|
||||
|
||||
[string]$NameFilter = "",
|
||||
|
||||
[string[]]$ObjectTypes = @(
|
||||
"DeviceConfiguration",
|
||||
"SettingsCatalog",
|
||||
"AdministrativeTemplates",
|
||||
"CompliancePolicies",
|
||||
"EndpointSecurity",
|
||||
"PolicySets"
|
||||
),
|
||||
[string]$NameSearchPattern = "",
|
||||
|
||||
[string]$NameReplacePattern = "",
|
||||
|
||||
[string[]]$ObjectTypes = (Get-DefaultIntunePolicyObjectTypes),
|
||||
|
||||
[switch]$IncludeAssignments,
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ param(
|
||||
|
||||
[string]$Certificate,
|
||||
|
||||
[ValidateSet("AppOnly","Browser")]
|
||||
[ValidateSet("AppOnly","Browser","DeviceCode")]
|
||||
[string]$AuthMode = "AppOnly",
|
||||
|
||||
[string]$RedirectUri,
|
||||
@@ -27,17 +27,14 @@ param(
|
||||
|
||||
[string]$NameFilter = "",
|
||||
|
||||
[string]$NameSearchPattern = "",
|
||||
|
||||
[string]$NameReplacePattern = "",
|
||||
|
||||
[ValidateSet("alwaysImport","skipIfExist","replace","replace_with_assignments","update")]
|
||||
[string]$ImportType = "alwaysImport",
|
||||
|
||||
[string[]]$ObjectTypes = @(
|
||||
"DeviceConfiguration",
|
||||
"SettingsCatalog",
|
||||
"AdministrativeTemplates",
|
||||
"CompliancePolicies",
|
||||
"EndpointSecurity",
|
||||
"PolicySets"
|
||||
),
|
||||
[string[]]$ObjectTypes = (Get-DefaultIntunePolicyObjectTypes),
|
||||
|
||||
[switch]$IncludeAssignments,
|
||||
|
||||
|
||||
245
Scripts/Start-IntuneManagementTui.ps1
Normal file
245
Scripts/Start-IntuneManagementTui.ps1
Normal file
@@ -0,0 +1,245 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Interactive terminal UI for IntuneManagement headless export/import.
|
||||
.DESCRIPTION
|
||||
Prompts for action, tenant, paths, filters, object types, and toggles.
|
||||
Returns a PSCustomObject that Start-HeadlessIntune.ps1 consumes.
|
||||
Uses fzf on macOS/Linux when available; falls back to numbered menus.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$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 Load defaults
|
||||
$modulePath = Join-Path (Split-Path -Parent $PSScriptRoot) "Headless/IntuneManagement.Headless.psd1"
|
||||
Import-Module $modulePath -Force
|
||||
|
||||
$defaultTypes = Get-DefaultIntunePolicyObjectTypes
|
||||
$settingsPath = Get-DefaultSettingsPath
|
||||
$preloadedTenantId = $null
|
||||
if(Test-Path $settingsPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
$settings = Get-Content $settingsPath -Raw | ConvertFrom-Json
|
||||
if($settings.TenantId) { $preloadedTenantId = $settings.TenantId }
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
#endregion
|
||||
|
||||
while($true)
|
||||
{
|
||||
Clear-Host
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " IntuneManagement Terminal UI" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Press Esc to go back, Space to select" -ForegroundColor DarkGray
|
||||
|
||||
# 1. Action
|
||||
$action = Select-MenuItem -Items @("Export","Import") -Header "Select action"
|
||||
if(-not $action) { continue }
|
||||
|
||||
# 2. TenantId
|
||||
$tenantPrompt = "Enter Tenant ID"
|
||||
if($preloadedTenantId) { $tenantPrompt += " (default: $preloadedTenantId)" }
|
||||
$tenantId = Read-Host $tenantPrompt
|
||||
if([string]::IsNullOrWhiteSpace($tenantId)) { $tenantId = $preloadedTenantId }
|
||||
if([string]::IsNullOrWhiteSpace($tenantId)) { Write-Host "Tenant ID is required." -ForegroundColor Red; continue }
|
||||
|
||||
# 3. Object Types
|
||||
Write-Host "`nObject type selection..." -ForegroundColor Cyan
|
||||
$typeSelection = Select-MenuItem -Items $defaultTypes -Header "Select object types to include (Space to multi-select)" -Multi
|
||||
if(-not $typeSelection) { continue }
|
||||
|
||||
# 4. Path
|
||||
$pathPrompt = if($action -eq "Export") { "Enter export root folder path" } else { "Enter import root folder path" }
|
||||
$path = Read-Host $pathPrompt
|
||||
if([string]::IsNullOrWhiteSpace($path)) { Write-Host "Path is required." -ForegroundColor Red; return $null }
|
||||
|
||||
# 5. Name Filter
|
||||
$nameFilter = Read-Host "Name filter regex (optional, e.g. '^Win-OIB-')"
|
||||
|
||||
# 6. Name Mutation
|
||||
$nameSearchPattern = Read-Host "Name search regex for mutation (optional, e.g. '^Win-OIB-')"
|
||||
$nameReplacePattern =
|
||||
if(-not [string]::IsNullOrWhiteSpace($nameSearchPattern))
|
||||
{
|
||||
$nameReplacePattern = Read-Host "Replacement string (e.g. 'Win-TEST-')"
|
||||
}
|
||||
|
||||
# 7. Import-specific options
|
||||
$importType = $null
|
||||
$includeScopeTags = $false
|
||||
$replaceDependencyIds = $false
|
||||
if($action -eq "Import")
|
||||
{
|
||||
$importType = Select-MenuItem -Items @("alwaysImport","skipIfExist","replace","replace_with_assignments","update") -Header "Select import behavior"
|
||||
if(-not $importType) { $importType = "alwaysImport" }
|
||||
$includeScopeTags = Read-YesNo -Prompt "Import scope tags?" -Default $false
|
||||
$replaceDependencyIds = Read-YesNo -Prompt "Replace dependency IDs?" -Default $false
|
||||
}
|
||||
|
||||
# 8. Common toggles
|
||||
$includeAssignments = Read-YesNo -Prompt "Include assignments?" -Default $false
|
||||
$addCompanyName = $false
|
||||
if($action -eq "Export")
|
||||
{
|
||||
$addCompanyName = Read-YesNo -Prompt "Add company name to folders?" -Default $false
|
||||
}
|
||||
|
||||
# 9. Review
|
||||
Clear-Host
|
||||
Write-Host "Review your selection:" -ForegroundColor Green
|
||||
Write-Host " Action : $action"
|
||||
Write-Host " TenantId : $tenantId"
|
||||
Write-Host " Object Types : $($typeSelection -join ', ')"
|
||||
if($action -eq "Export")
|
||||
{
|
||||
Write-Host " Export Path : $path"
|
||||
Write-Host " Add Company Name : $addCompanyName"
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Host " Import Path : $path"
|
||||
Write-Host " Import Type : $importType"
|
||||
Write-Host " Include Scope Tags : $includeScopeTags"
|
||||
Write-Host " Replace Dep IDs : $replaceDependencyIds"
|
||||
}
|
||||
Write-Host " Name Filter : $(if($nameFilter){$nameFilter}else{'(none)'})"
|
||||
Write-Host " Name Search Pattern : $(if($nameSearchPattern){$nameSearchPattern}else{'(none)'})"
|
||||
Write-Host " Name Replace Pattern: $(if($nameReplacePattern){$nameReplacePattern}else{'(none)'})"
|
||||
Write-Host " Include Assignments : $includeAssignments"
|
||||
|
||||
$confirm = Read-Host "`nProceed? [Y/n] (or type 'back' to restart)"
|
||||
if($confirm -eq "back") { continue }
|
||||
if(-not ([string]::IsNullOrWhiteSpace($confirm) -or $confirm -match "^\s*y"))
|
||||
{
|
||||
Write-Host "Cancelled." -ForegroundColor Yellow
|
||||
continue
|
||||
}
|
||||
|
||||
# 10. Build result
|
||||
$result = [PSCustomObject]@{
|
||||
Action = $action
|
||||
TenantId = $tenantId
|
||||
ObjectTypes = $typeSelection
|
||||
NameFilter = $nameFilter
|
||||
NameSearchPattern = $nameSearchPattern
|
||||
NameReplacePattern = $nameReplacePattern
|
||||
IncludeAssignments = $includeAssignments
|
||||
}
|
||||
|
||||
if($action -eq "Export")
|
||||
{
|
||||
$result | Add-Member -NotePropertyName ExportPath -NotePropertyValue $path
|
||||
$result | Add-Member -NotePropertyName AddCompanyName -NotePropertyValue $addCompanyName
|
||||
}
|
||||
else
|
||||
{
|
||||
$result | Add-Member -NotePropertyName ImportPath -NotePropertyValue $path
|
||||
$result | Add-Member -NotePropertyName ImportType -NotePropertyValue $importType
|
||||
$result | Add-Member -NotePropertyName IncludeScopeTags -NotePropertyValue $includeScopeTags
|
||||
$result | Add-Member -NotePropertyName ReplaceDependencyIds -NotePropertyValue $replaceDependencyIds
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet("Export","Import")]
|
||||
[string]$Action,
|
||||
|
||||
@@ -13,7 +12,7 @@ param(
|
||||
|
||||
[string]$Certificate,
|
||||
|
||||
[ValidateSet("AppOnly","Browser")]
|
||||
[ValidateSet("AppOnly","Browser","DeviceCode")]
|
||||
[string]$AuthMode = "AppOnly",
|
||||
|
||||
[string]$RedirectUri,
|
||||
@@ -24,6 +23,10 @@ param(
|
||||
|
||||
[string]$NameFilter = "",
|
||||
|
||||
[string]$NameSearchPattern = "",
|
||||
|
||||
[string]$NameReplacePattern = "",
|
||||
|
||||
[string[]]$ObjectTypes,
|
||||
|
||||
[string]$ExportPath,
|
||||
@@ -39,12 +42,48 @@ param(
|
||||
|
||||
[switch]$IncludeScopeTags,
|
||||
|
||||
[switch]$ReplaceDependencyIds
|
||||
[switch]$ReplaceDependencyIds,
|
||||
|
||||
[switch]$Interactive
|
||||
)
|
||||
|
||||
$modulePath = Join-Path $PSScriptRoot "Headless/IntuneManagement.Headless.psd1"
|
||||
Import-Module $modulePath -Force
|
||||
|
||||
if($Interactive -and -not $Action)
|
||||
{
|
||||
Write-Host "Interactive mode will prompt for the action and other settings." -ForegroundColor Cyan
|
||||
}
|
||||
elseif(-not $Action)
|
||||
{
|
||||
throw "Action is required. Use -Interactive to select it in a terminal UI."
|
||||
}
|
||||
|
||||
if($Interactive)
|
||||
{
|
||||
$tuiScript = Join-Path $PSScriptRoot "Scripts/Start-IntuneManagementTui.ps1"
|
||||
if(Test-Path $tuiScript)
|
||||
{
|
||||
$tuiResult = & $tuiScript
|
||||
if(-not $tuiResult) { Write-Host "No selection made. Exiting." -ForegroundColor Yellow; exit 0 }
|
||||
foreach($prop in $tuiResult.PSObject.Properties)
|
||||
{
|
||||
if($prop.Value -ne $null -and $prop.Name -ne "Action")
|
||||
{
|
||||
Set-Variable -Name $prop.Name -Value $prop.Value
|
||||
}
|
||||
elseif($prop.Name -eq "Action")
|
||||
{
|
||||
$Action = $prop.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw "TUI script not found: $tuiScript"
|
||||
}
|
||||
}
|
||||
|
||||
$invokeParams = @{
|
||||
Action = $Action
|
||||
TenantId = $TenantId
|
||||
@@ -53,6 +92,8 @@ $invokeParams = @{
|
||||
SettingsFile = $SettingsFile
|
||||
BatchFile = $BatchFile
|
||||
NameFilter = $NameFilter
|
||||
NameSearchPattern = $NameSearchPattern
|
||||
NameReplacePattern = $NameReplacePattern
|
||||
ExportPath = $ExportPath
|
||||
ImportPath = $ImportPath
|
||||
ImportType = $ImportType
|
||||
@@ -62,7 +103,9 @@ $invokeParams = @{
|
||||
ReplaceDependencyIds = $ReplaceDependencyIds
|
||||
}
|
||||
|
||||
if($PSBoundParameters.ContainsKey("ObjectTypes"))
|
||||
if($Interactive -and $Action) { $invokeParams.Action = $Action }
|
||||
|
||||
if($PSBoundParameters.ContainsKey("ObjectTypes") -or $ObjectTypes)
|
||||
{
|
||||
$invokeParams.ObjectTypes = $ObjectTypes
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user