diff --git a/Core.psm1 b/Core.psm1 index b9e12e2..2a3c2dc 100644 --- a/Core.psm1 +++ b/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 diff --git a/Extensions/EndpointManager.psm1 b/Extensions/EndpointManager.psm1 index c4e5fe8..c01d2de 100644 --- a/Extensions/EndpointManager.psm1 +++ b/Extensions/EndpointManager.psm1 @@ -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)) } } diff --git a/Extensions/MSALAuthentication.psm1 b/Extensions/MSALAuthentication.psm1 index 4d92175..e158c2a 100644 --- a/Extensions/MSALAuthentication.psm1 +++ b/Extensions/MSALAuthentication.psm1 @@ -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 diff --git a/Extensions/MSGraph.psm1 b/Extensions/MSGraph.psm1 index e747882..0dfc22d 100644 --- a/Extensions/MSGraph.psm1 +++ b/Extensions/MSGraph.psm1 @@ -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 diff --git a/Headless/IntuneManagement.Headless.psm1 b/Headless/IntuneManagement.Headless.psm1 index a5f920e..ff9f846 100644 --- a/Headless/IntuneManagement.Headless.psm1 +++ b/Headless/IntuneManagement.Headless.psm1 @@ -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 ` diff --git a/Runtime/IntuneManagement.Runtime.psd1 b/Runtime/IntuneManagement.Runtime.psd1 index 3035c82..7fe8aba 100644 --- a/Runtime/IntuneManagement.Runtime.psd1 +++ b/Runtime/IntuneManagement.Runtime.psd1 @@ -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 = @{ diff --git a/Runtime/IntuneManagement.Runtime.psm1 b/Runtime/IntuneManagement.Runtime.psm1 index 21c98c1..6ceb957 100644 --- a/Runtime/IntuneManagement.Runtime.psm1 +++ b/Runtime/IntuneManagement.Runtime.psm1 @@ -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 or -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 diff --git a/Scripts/Backup-Restore-Assignments.ps1 b/Scripts/Backup-Restore-Assignments.ps1 new file mode 100644 index 0000000..4db5054 --- /dev/null +++ b/Scripts/Backup-Restore-Assignments.ps1 @@ -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 diff --git a/Scripts/Bulk-AppAssignment.ps1 b/Scripts/Bulk-AppAssignment.ps1 new file mode 100644 index 0000000..d660f13 --- /dev/null +++ b/Scripts/Bulk-AppAssignment.ps1 @@ -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 diff --git a/Scripts/Bulk-AssignmentManager.ps1 b/Scripts/Bulk-AssignmentManager.ps1 new file mode 100644 index 0000000..7500755 --- /dev/null +++ b/Scripts/Bulk-AssignmentManager.ps1 @@ -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 diff --git a/Scripts/Bulk-DeviceOperations.ps1 b/Scripts/Bulk-DeviceOperations.ps1 new file mode 100644 index 0000000..7ef3901 --- /dev/null +++ b/Scripts/Bulk-DeviceOperations.ps1 @@ -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 diff --git a/Scripts/Bulk-RenamePolicies.ps1 b/Scripts/Bulk-RenamePolicies.ps1 new file mode 100644 index 0000000..be65dd7 --- /dev/null +++ b/Scripts/Bulk-RenamePolicies.ps1 @@ -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 diff --git a/Scripts/Create-IntuneManagementApp.ps1 b/Scripts/Create-IntuneManagementApp.ps1 new file mode 100644 index 0000000..1546664 --- /dev/null +++ b/Scripts/Create-IntuneManagementApp.ps1 @@ -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 ' before using" -ForegroundColor Yellow +Write-Host " the app for Export or Import." -ForegroundColor Yellow +Write-Host "=============================================================" -ForegroundColor Green + +Disconnect-MgGraph | Out-Null diff --git a/Scripts/Export-AssignmentsToCsv.ps1 b/Scripts/Export-AssignmentsToCsv.ps1 new file mode 100644 index 0000000..341f038 --- /dev/null +++ b/Scripts/Export-AssignmentsToCsv.ps1 @@ -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 diff --git a/Scripts/Export-Policies.ps1 b/Scripts/Export-Policies.ps1 index 88357f7..e7d4dc3 100644 --- a/Scripts/Export-Policies.ps1 +++ b/Scripts/Export-Policies.ps1 @@ -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, diff --git a/Scripts/Import-Policies.ps1 b/Scripts/Import-Policies.ps1 index 408fc4b..dd2e31e 100644 --- a/Scripts/Import-Policies.ps1 +++ b/Scripts/Import-Policies.ps1 @@ -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, diff --git a/Scripts/Start-IntuneManagementTui.ps1 b/Scripts/Start-IntuneManagementTui.ps1 new file mode 100644 index 0000000..6ad0a44 --- /dev/null +++ b/Scripts/Start-IntuneManagementTui.ps1 @@ -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 +} diff --git a/Start-HeadlessIntune.ps1 b/Start-HeadlessIntune.ps1 index 702f702..07125ef 100644 --- a/Start-HeadlessIntune.ps1 +++ b/Start-HeadlessIntune.ps1 @@ -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 }