#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 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" { if(-not $oldName.StartsWith($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" { if(-not $oldDesc.StartsWith($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 $maxRetries = 3 $retryDelay = 2 $renamed = $false for($r = 1; $r -le $maxRetries; $r++) { try { $null = Invoke-GraphRequest "$($objectType.API)/$($obj.id)" -HttpMethod PATCH -Content $body Write-Host " OK: Renamed '$($change.OldName)' -> '$($change.NewName)'" -ForegroundColor Green $success++ $renamed = $true break } catch { $statusCode = $_.Exception.Response.StatusCode if($r -lt $maxRetries -and ($statusCode -ge 500 -or $statusCode -eq 429)) { Write-Host " Retry $r/$maxRetries after $retryDelay`s (HTTP $statusCode)..." -ForegroundColor DarkYellow Start-Sleep -Seconds $retryDelay } else { throw } } } if(-not $renamed) { throw "Rename failed after $maxRetries attempts." } } } 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