Compare commits
1
Commits
4.0.1
..
dc35cd68f2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc35cd68f2 |
-16
@@ -1,16 +0,0 @@
|
||||
.vs/
|
||||
.vscode/
|
||||
.git/
|
||||
/*.Log
|
||||
/*.Lo_
|
||||
/*.csv
|
||||
/*.zip
|
||||
/*.new
|
||||
/*.old
|
||||
/Extensions/*.zip
|
||||
.gitignore
|
||||
IntuneManagement.log
|
||||
GraphMetaData.xml
|
||||
%LOCALAPPDATA%/
|
||||
*%LOCALAPPDATA%*
|
||||
CloudAPIPowerShellManagement/
|
||||
@@ -1,81 +0,0 @@
|
||||
baseline:
|
||||
name: OpenIntuneBaseline-v3-Example
|
||||
conflictResolution: Skip # Skip | Update | Error
|
||||
whatIf: false
|
||||
|
||||
# Global name mutation applied to every policy (optional)
|
||||
tenantMutation:
|
||||
search: "OIB-"
|
||||
replace: "CONTOSO-"
|
||||
# Alternatively use prefix instead of search/replace:
|
||||
# prefix: "CONTOSO-"
|
||||
|
||||
# Cloud-only security groups to create if they do not exist
|
||||
groups:
|
||||
- displayName: "Baseline - Windows Devices"
|
||||
mailNickname: "BaselineWinDevices"
|
||||
securityEnabled: true
|
||||
- displayName: "Baseline - macOS Devices"
|
||||
mailNickname: "BaselineMacDevices"
|
||||
securityEnabled: true
|
||||
- displayName: "Baseline - Pilot Users"
|
||||
mailNickname: "BaselinePilotUsers"
|
||||
securityEnabled: true
|
||||
|
||||
policies:
|
||||
# Device Configuration
|
||||
- sourcePath: ./policies/OIB-Windows-Defender-ASR.json
|
||||
type: DeviceConfiguration
|
||||
assignments:
|
||||
- targetType: Group
|
||||
groupName: "Baseline - Windows Devices"
|
||||
|
||||
# Settings Catalog (uses 'name' instead of displayName)
|
||||
- sourcePath: ./policies/OIB-SettingsCatalog-LoginWindow.json
|
||||
type: SettingsCatalog
|
||||
# Per-policy mutation override
|
||||
mutation:
|
||||
search: "OIB-"
|
||||
replace: "CONTOSO-"
|
||||
assignments:
|
||||
- targetType: Group
|
||||
groupName: "Baseline - macOS Devices"
|
||||
- targetType: AllDevices
|
||||
|
||||
# Compliance Policy
|
||||
- sourcePath: ./policies/OIB-Compliance-Windows.json
|
||||
type: CompliancePolicies
|
||||
assignments:
|
||||
- targetType: Group
|
||||
groupName: "Baseline - Windows Devices"
|
||||
|
||||
# Endpoint Security (DeviceManagementIntents)
|
||||
# If a sibling file *_Settings.json exists, it will be imported automatically.
|
||||
- sourcePath: ./policies/OIB-EndpointSecurity-Defender.json
|
||||
type: EndpointSecurity
|
||||
assignments:
|
||||
- targetType: Group
|
||||
groupName: "Baseline - Windows Devices"
|
||||
|
||||
# Administrative Templates
|
||||
- sourcePath: ./policies/OIB-ADMX-OfficeSettings.json
|
||||
type: AdministrativeTemplates
|
||||
assignments:
|
||||
- targetType: Group
|
||||
groupName: "Baseline - Pilot Users"
|
||||
|
||||
# macOS Script
|
||||
- sourcePath: ./policies/OIB-MacScript-CompanyBranding.json
|
||||
type: MacScripts
|
||||
assignments:
|
||||
- targetType: Group
|
||||
groupName: "Baseline - macOS Devices"
|
||||
|
||||
# Application (metadata JSON only; .intunewin binary upload is NOT handled here)
|
||||
- sourcePath: ./apps/OIB-CompanyPortal.json
|
||||
type: Applications
|
||||
assignments:
|
||||
- targetType: AllUsers
|
||||
intent: Available
|
||||
- targetType: AllDevices
|
||||
intent: Required
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,83 +0,0 @@
|
||||
# macOS Intune Toolkit Changelog
|
||||
|
||||
## 2026-04-13 — API Permissions Sync for `Initialize-IntuneAuth.ps1`
|
||||
|
||||
### Modified
|
||||
- **`Scripts/Initialize-IntuneAuth.ps1`**
|
||||
- Unified the required Microsoft Graph application permissions into a single `$requiredRoles` list defined before app creation/reuse logic:
|
||||
- `DeviceManagementApps.ReadWrite.All`
|
||||
- `DeviceManagementConfiguration.ReadWrite.All`
|
||||
- `DeviceManagementManagedDevices.ReadWrite.All`
|
||||
- `DeviceManagementScripts.ReadWrite.All`
|
||||
- `DeviceManagementServiceConfig.ReadWrite.All`
|
||||
- `DeviceManagementRBAC.ReadWrite.All`
|
||||
- `Group.ReadWrite.All`
|
||||
- `Directory.Read.All`
|
||||
- `User.Read.All`
|
||||
- `Organization.Read.All`
|
||||
- `Policy.ReadWrite.ConditionalAccess`
|
||||
- `Agreement.ReadWrite.All`
|
||||
- `CloudPC.ReadWrite.All`
|
||||
- `Application.Read.All`
|
||||
- **Existing app patching**: When reusing an existing app registration, the script now inspects its current `RequiredResourceAccess`. If any required permissions are missing, it patches the app via `Update-MgApplication`, refreshes the local app object, and the downstream admin-consent loop automatically grants consent for the newly added roles.
|
||||
|
||||
---
|
||||
|
||||
## Prior delivered changes (context summary)
|
||||
|
||||
### New scripts added
|
||||
- `Scripts/Bulk-AppAssignment.ps1` — bulk-assign apps to groups/All Users/All Devices
|
||||
- `Scripts/Bulk-AssignmentManager.ps1` — add/remove assignments for any policy type using correct `@odata.type` and bulk `/assign` endpoint
|
||||
- `Scripts/Backup-Restore-Assignments.ps1` — JSON backup with cross-tenant group name resolution
|
||||
- `Scripts/Export-AssignmentsToCsv.ps1` — CSV and Markdown documentation output
|
||||
- `Scripts/Bulk-RenamePolicies.ps1` — search/replace, add/strip prefix across displayName/description
|
||||
- `Scripts/Bulk-DeviceOperations.ps1` — delete/retire/wipe/lock/sync with `-WhatIf` safeguards
|
||||
- `Scripts/Start-IntuneToolkit.ps1` — unified reverse-numbered `fzf`-based launcher
|
||||
- `Scripts/Initialize-IntuneAuth.ps1` — one-time Entra app + secret + Keychain setup
|
||||
|
||||
### Core / Extensions / Headless changes
|
||||
- **`Extensions/MSGraph.psm1`**
|
||||
- `Invoke-GraphRequest` now throws on 4xx/5xx HTTP errors (was silently returning null)
|
||||
- Added `-AllPages` support to `Get-GraphObjects` and toolkit queries for large tenants
|
||||
- **`Headless/IntuneManagement.Headless.psm1`**
|
||||
- Expanded `Get-DefaultIntunePolicyObjectTypes` to ~45 types, including `DeviceManagementIntents`
|
||||
- Threaded `NameSearchPattern` / `NameReplacePattern` through export/import/action flows
|
||||
- **Settings Catalog fixes**
|
||||
- Uses `name` property instead of `displayName` for queries/labels
|
||||
- Assignments use `#microsoft.graph.deviceManagementConfigurationPolicyAssignment` and the bulk `POST …/assign` endpoint
|
||||
- **TUI / `fzf`**
|
||||
- Spacebar toggle, Esc to go back, reverse numbering (10→1) in unified launcher
|
||||
|
||||
|
||||
## 2026-04-13 — Declarative Baseline Deployer
|
||||
|
||||
### Added
|
||||
- **`Scripts/Deploy-IntuneBaseline.ps1`**
|
||||
- YAML-driven one-click deployment of Intune policies + assignments to new tenants.
|
||||
- Supports global and per-policy name mutations (`search`/`replace` or `prefix`).
|
||||
- Auto-creates cloud-only security groups if missing.
|
||||
- Idempotent imports with configurable conflict resolution (`Skip`, `Update`, `Error`).
|
||||
- Full `-WhatIf` dry-run support.
|
||||
- Handles 20+ policy types including Settings Catalog (`name` property), EndpointSecurity (settings file companion upload), and Applications.
|
||||
- Integrates with existing auth stack (Settings.json / macOS Keychain).
|
||||
|
||||
- **`Scripts/ConvertTo-IntuneBaseline.ps1`**
|
||||
- Converts an existing toolkit export folder into a baseline YAML skeleton.
|
||||
- Maps folder names to baseline types, extracts display names, and generates empty assignment blocks.
|
||||
|
||||
- **`Baselines/OpenIntuneBaseline.example.yaml`**
|
||||
- Example manifest demonstrating groups, mutations, policies, and assignments.
|
||||
|
||||
### Dependencies
|
||||
- `powershell-yaml` module (auto-install prompt if missing).
|
||||
|
||||
|
||||
## 2026-04-13 — Unified Launcher: Baseline Deployer Integration
|
||||
|
||||
### Modified
|
||||
- **`Scripts/Start-IntuneToolkit.ps1`**
|
||||
- Added menu entries for baseline deployment:
|
||||
- `10. Deploy baseline`
|
||||
- `11. Deploy baseline (dry-run / WhatIf)`
|
||||
- Forwards `-WhatIf` switch correctly when dry-run option is selected.
|
||||
- Ensures `WhatIf` flag is cleared between loop iterations to avoid leakage to other tools.
|
||||
@@ -1,42 +0,0 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Identity.Client;
|
||||
|
||||
public class HttpFactoryWithProxy : IMsalHttpClientFactory
|
||||
{
|
||||
private static HttpClient _httpClient;
|
||||
|
||||
public HttpFactoryWithProxy(string proxyURI) : this(proxyURI, null, null)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public HttpFactoryWithProxy(string proxyURI, string proxyUserName = null, string proxyPassword = null)
|
||||
{
|
||||
if (_httpClient == null)
|
||||
{
|
||||
var proxy = new WebProxy
|
||||
{
|
||||
Address = new Uri(proxyURI),
|
||||
BypassProxyOnLocal = false,
|
||||
UseDefaultCredentials = false,
|
||||
Credentials = new NetworkCredential(
|
||||
userName: proxyUserName,
|
||||
password: proxyPassword)
|
||||
};
|
||||
|
||||
var httpClientHandler = new HttpClientHandler
|
||||
{
|
||||
Proxy = proxy,
|
||||
};
|
||||
|
||||
_httpClient = new HttpClient(handler: httpClientHandler);
|
||||
}
|
||||
}
|
||||
|
||||
public HttpClient GetHttpClient()
|
||||
{
|
||||
return _httpClient;
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
// Updated original code from
|
||||
// Added support for custom file location
|
||||
// https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-net-token-cache-serialization#simple-token-cache-serialization-msal-only
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Identity.Client;
|
||||
|
||||
public static class TokenCacheHelperEx
|
||||
{
|
||||
public static void EnableSerialization(ITokenCache tokenCache, String fileName = @"%LOCALAPPDATA%\GraphPowerShellManager\MSALToken.bin")
|
||||
{
|
||||
tokenCache.SetBeforeAccess(BeforeAccessNotification);
|
||||
tokenCache.SetAfterAccess(AfterAccessNotification);
|
||||
|
||||
CacheFilePath = Environment.ExpandEnvironmentVariables(fileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Path to the token cache
|
||||
/// </summary>
|
||||
|
||||
public static string CacheFilePath { get; private set;}
|
||||
|
||||
private static readonly object FileLock = new object();
|
||||
|
||||
private static void BeforeAccessNotification(TokenCacheNotificationArgs args)
|
||||
{
|
||||
lock (FileLock)
|
||||
{
|
||||
args.TokenCache.DeserializeMsalV3(File.Exists(CacheFilePath)
|
||||
? ProtectedData.Unprotect(File.ReadAllBytes(CacheFilePath),
|
||||
null,
|
||||
DataProtectionScope.CurrentUser)
|
||||
: null);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AfterAccessNotification(TokenCacheNotificationArgs args)
|
||||
{
|
||||
// if the access operation resulted in a cache update
|
||||
if (args.HasStateChanged)
|
||||
{
|
||||
lock (FileLock)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(CacheFilePath));
|
||||
// reflect changes in the persistent store
|
||||
File.WriteAllBytes(CacheFilePath,
|
||||
ProtectedData.Protect(args.TokenCache.SerializeMsalV3(),
|
||||
null,
|
||||
DataProtectionScope.CurrentUser)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,17 +0,0 @@
|
||||
@{
|
||||
RootModule = 'IntuneManagement.Headless.psm1'
|
||||
ModuleVersion = '0.1.0'
|
||||
GUID = 'b5b4183d-8d6b-4b31-bbde-f2f0f0a0739d'
|
||||
Author = 'OpenAI Codex'
|
||||
Copyright = '(c) OpenAI. Adapter module for headless Intune policy migration.'
|
||||
Description = 'Headless export/import wrapper for IntuneManagement.'
|
||||
FunctionsToExport = @(
|
||||
'Get-DefaultIntunePolicyObjectTypes',
|
||||
'Export-IntunePolicies',
|
||||
'Import-IntunePolicies',
|
||||
'Invoke-IntunePolicyAction'
|
||||
)
|
||||
AliasesToExport = @()
|
||||
VariablesToExport = @()
|
||||
CmdletsToExport = @()
|
||||
}
|
||||
@@ -1,469 +0,0 @@
|
||||
$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",
|
||||
"CompliancePolicies",
|
||||
"CompliancePoliciesV2",
|
||||
"EndpointSecurity",
|
||||
"DeviceManagementIntents",
|
||||
"PolicySets"
|
||||
)
|
||||
}
|
||||
|
||||
function Get-DefaultBrowserAppId
|
||||
{
|
||||
"14d82eec-204b-4c2f-b7e8-296a70dab67e"
|
||||
}
|
||||
|
||||
function Get-IntuneManagementProjectRoot
|
||||
{
|
||||
Split-Path -Parent $PSScriptRoot
|
||||
}
|
||||
|
||||
function Resolve-HeadlessSettingsPath
|
||||
{
|
||||
param([string]$SettingsFile)
|
||||
|
||||
if($SettingsFile)
|
||||
{
|
||||
return $SettingsFile
|
||||
}
|
||||
|
||||
# Default to the persistent data folder (same location used by Initialize-IntuneAuth)
|
||||
Join-Path (Get-CloudApiDataFolder) "Settings.json"
|
||||
}
|
||||
|
||||
function New-TemporaryBatchFile
|
||||
{
|
||||
param([string]$Prefix)
|
||||
|
||||
Join-Path ([IO.Path]::GetTempPath()) ("IntuneManagement.{0}.{1}.json" -f $Prefix, [guid]::NewGuid().ToString())
|
||||
}
|
||||
|
||||
function Test-AuthParameters
|
||||
{
|
||||
param(
|
||||
[string]$AuthMode,
|
||||
[string]$AppId,
|
||||
[string]$Secret,
|
||||
[string]$Certificate
|
||||
)
|
||||
|
||||
if($AuthMode -eq "Browser" -or $AuthMode -eq "DeviceCode")
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
if(-not $AppId)
|
||||
{
|
||||
throw "Specify -AppId for AppOnly auth."
|
||||
return
|
||||
}
|
||||
|
||||
if((-not $Secret) -and (-not $Certificate))
|
||||
{
|
||||
throw "Specify -Secret or -Certificate for AppOnly auth, or use -AuthMode Browser."
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-IntuneHeadlessBatch
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TenantId,
|
||||
|
||||
[string]$AppId,
|
||||
|
||||
[string]$Secret,
|
||||
|
||||
[string]$Certificate,
|
||||
|
||||
[ValidateSet("AppOnly","Browser","DeviceCode")]
|
||||
[string]$AuthMode = "AppOnly",
|
||||
|
||||
[string]$RedirectUri,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$BatchConfig,
|
||||
|
||||
[string]$SettingsFile,
|
||||
|
||||
[string]$BatchFile
|
||||
)
|
||||
|
||||
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
|
||||
$runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1"
|
||||
|
||||
if(-not (Test-Path $runtimeModule))
|
||||
{
|
||||
throw "Could not find IntuneManagement.Runtime.psd1 in $projectRoot"
|
||||
}
|
||||
|
||||
$deleteBatchFile = $false
|
||||
if(-not $BatchFile)
|
||||
{
|
||||
$BatchFile = New-TemporaryBatchFile "Batch"
|
||||
$deleteBatchFile = $true
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$BatchConfig | ConvertTo-Json -Depth 20 | Out-File -LiteralPath $BatchFile -Encoding utf8 -Force
|
||||
|
||||
$invokeParams = @{
|
||||
Silent = $true
|
||||
JSonSettings = $true
|
||||
JSonFile = $settingsPath
|
||||
TenantId = $TenantId
|
||||
AppId = $AppId
|
||||
SilentBatchFile = $BatchFile
|
||||
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
|
||||
}
|
||||
finally
|
||||
{
|
||||
if($deleteBatchFile -and (Test-Path $BatchFile))
|
||||
{
|
||||
Remove-Item -LiteralPath $BatchFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Export-IntunePolicies
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TenantId,
|
||||
|
||||
[string]$AppId,
|
||||
|
||||
[string]$Secret,
|
||||
|
||||
[string]$Certificate,
|
||||
|
||||
[ValidateSet("AppOnly","Browser","DeviceCode")]
|
||||
[string]$AuthMode = "AppOnly",
|
||||
|
||||
[string]$RedirectUri,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ExportPath,
|
||||
|
||||
[string]$SettingsFile,
|
||||
|
||||
[string]$BatchFile,
|
||||
|
||||
[string]$NameFilter = "",
|
||||
|
||||
[string]$NameSearchPattern = "",
|
||||
|
||||
[string]$NameReplacePattern = "",
|
||||
|
||||
[string[]]$ObjectTypes = (Get-DefaultIntunePolicyObjectTypes),
|
||||
|
||||
[switch]$IncludeAssignments,
|
||||
|
||||
[switch]$AddCompanyName
|
||||
)
|
||||
|
||||
$batchConfig = [PSCustomObject]@{
|
||||
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 },
|
||||
[PSCustomObject]@{ Name = "ObjectTypes"; Type = "Custom"; ObjectTypes = @($ObjectTypes) }
|
||||
)
|
||||
}
|
||||
|
||||
Invoke-IntuneHeadlessBatch `
|
||||
-TenantId $TenantId `
|
||||
-AppId $AppId `
|
||||
-Secret $Secret `
|
||||
-Certificate $Certificate `
|
||||
-AuthMode $AuthMode `
|
||||
-RedirectUri $RedirectUri `
|
||||
-BatchConfig $batchConfig `
|
||||
-SettingsFile $SettingsFile `
|
||||
-BatchFile $BatchFile
|
||||
}
|
||||
|
||||
function Import-IntunePolicies
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TenantId,
|
||||
|
||||
[string]$AppId,
|
||||
|
||||
[string]$Secret,
|
||||
|
||||
[string]$Certificate,
|
||||
|
||||
[ValidateSet("AppOnly","Browser","DeviceCode")]
|
||||
[string]$AuthMode = "AppOnly",
|
||||
|
||||
[string]$RedirectUri,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ImportPath,
|
||||
|
||||
[string]$SettingsFile,
|
||||
|
||||
[string]$BatchFile,
|
||||
|
||||
[string]$NameFilter = "",
|
||||
|
||||
[string]$NameSearchPattern = "",
|
||||
|
||||
[string]$NameReplacePattern = "",
|
||||
|
||||
[ValidateSet("alwaysImport","skipIfExist","replace","replace_with_assignments","update")]
|
||||
[string]$ImportType = "alwaysImport",
|
||||
|
||||
[string[]]$ObjectTypes = (Get-DefaultIntunePolicyObjectTypes),
|
||||
|
||||
[switch]$IncludeAssignments,
|
||||
|
||||
[switch]$IncludeScopeTags,
|
||||
|
||||
[switch]$ReplaceDependencyIds
|
||||
)
|
||||
|
||||
$batchConfig = [PSCustomObject]@{
|
||||
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 },
|
||||
[PSCustomObject]@{ Name = "chkReplaceDependencyIDs"; Value = $ReplaceDependencyIds.IsPresent },
|
||||
[PSCustomObject]@{ Name = "cbImportType"; Value = $ImportType },
|
||||
[PSCustomObject]@{ Name = "ObjectTypes"; Type = "Custom"; ObjectTypes = @($ObjectTypes) }
|
||||
)
|
||||
}
|
||||
|
||||
Invoke-IntuneHeadlessBatch `
|
||||
-TenantId $TenantId `
|
||||
-AppId $AppId `
|
||||
-Secret $Secret `
|
||||
-Certificate $Certificate `
|
||||
-AuthMode $AuthMode `
|
||||
-RedirectUri $RedirectUri `
|
||||
-BatchConfig $batchConfig `
|
||||
-SettingsFile $SettingsFile `
|
||||
-BatchFile $BatchFile
|
||||
}
|
||||
|
||||
function Invoke-IntunePolicyAction
|
||||
{
|
||||
[CmdletBinding(DefaultParameterSetName = 'Export')]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet("Export","Import")]
|
||||
[string]$Action,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TenantId,
|
||||
|
||||
[string]$AppId,
|
||||
|
||||
[string]$Secret,
|
||||
|
||||
[string]$Certificate,
|
||||
|
||||
[ValidateSet("AppOnly","Browser","DeviceCode")]
|
||||
[string]$AuthMode = "AppOnly",
|
||||
|
||||
[string]$RedirectUri,
|
||||
|
||||
[string]$SettingsFile,
|
||||
|
||||
[string]$BatchFile,
|
||||
|
||||
[string]$NameFilter = "",
|
||||
|
||||
[string]$NameSearchPattern = "",
|
||||
|
||||
[string]$NameReplacePattern = "",
|
||||
|
||||
[string[]]$ObjectTypes = (Get-DefaultIntunePolicyObjectTypes),
|
||||
|
||||
[string]$ExportPath,
|
||||
|
||||
[string]$ImportPath,
|
||||
|
||||
[ValidateSet("alwaysImport","skipIfExist","replace","replace_with_assignments","update")]
|
||||
[string]$ImportType = "alwaysImport",
|
||||
|
||||
[switch]$IncludeAssignments,
|
||||
|
||||
[switch]$AddCompanyName,
|
||||
|
||||
[switch]$IncludeScopeTags,
|
||||
|
||||
[switch]$ReplaceDependencyIds
|
||||
)
|
||||
|
||||
switch($Action)
|
||||
{
|
||||
"Export"
|
||||
{
|
||||
if(-not $ExportPath) { throw "Export requires -ExportPath." }
|
||||
Export-IntunePolicies `
|
||||
-TenantId $TenantId `
|
||||
-AppId $AppId `
|
||||
-Secret $Secret `
|
||||
-Certificate $Certificate `
|
||||
-AuthMode $AuthMode `
|
||||
-RedirectUri $RedirectUri `
|
||||
-ExportPath $ExportPath `
|
||||
-SettingsFile $SettingsFile `
|
||||
-BatchFile $BatchFile `
|
||||
-NameFilter $NameFilter `
|
||||
-NameSearchPattern $NameSearchPattern `
|
||||
-NameReplacePattern $NameReplacePattern `
|
||||
-ObjectTypes $ObjectTypes `
|
||||
-IncludeAssignments:$IncludeAssignments `
|
||||
-AddCompanyName:$AddCompanyName
|
||||
}
|
||||
"Import"
|
||||
{
|
||||
if(-not $ImportPath) { throw "Import requires -ImportPath." }
|
||||
Import-IntunePolicies `
|
||||
-TenantId $TenantId `
|
||||
-AppId $AppId `
|
||||
-Secret $Secret `
|
||||
-Certificate $Certificate `
|
||||
-AuthMode $AuthMode `
|
||||
-RedirectUri $RedirectUri `
|
||||
-ImportPath $ImportPath `
|
||||
-SettingsFile $SettingsFile `
|
||||
-BatchFile $BatchFile `
|
||||
-NameFilter $NameFilter `
|
||||
-NameSearchPattern $NameSearchPattern `
|
||||
-NameReplacePattern $NameReplacePattern `
|
||||
-ImportType $ImportType `
|
||||
-ObjectTypes $ObjectTypes `
|
||||
-IncludeAssignments:$IncludeAssignments `
|
||||
-IncludeScopeTags:$IncludeScopeTags `
|
||||
-ReplaceDependencyIds:$ReplaceDependencyIds
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
# Headless Runtime
|
||||
|
||||
This folder contains the reusable CLI module for the repo.
|
||||
|
||||
## Module
|
||||
|
||||
* [IntuneManagement.Headless.psd1](/Users/avedelphina/Local/IntuneManagement/Headless/IntuneManagement.Headless.psd1)
|
||||
* [IntuneManagement.Headless.psm1](/Users/avedelphina/Local/IntuneManagement/Headless/IntuneManagement.Headless.psm1)
|
||||
|
||||
## Exported commands
|
||||
|
||||
* `Get-DefaultIntunePolicyObjectTypes`
|
||||
* `Export-IntunePolicies`
|
||||
* `Import-IntunePolicies`
|
||||
* `Invoke-IntunePolicyAction`
|
||||
|
||||
## Example
|
||||
|
||||
```powershell
|
||||
Import-Module ./Headless/IntuneManagement.Headless.psd1
|
||||
|
||||
Export-IntunePolicies `
|
||||
-TenantId "<source-tenant-id>" `
|
||||
-AppId "<app-id>" `
|
||||
-Secret "<client-secret>" `
|
||||
-ExportPath "/tmp/intune-export"
|
||||
```
|
||||
|
||||
```powershell
|
||||
Export-IntunePolicies `
|
||||
-TenantId "<source-tenant-id>" `
|
||||
-AuthMode Browser `
|
||||
-RedirectUri "http://localhost" `
|
||||
-ExportPath "/tmp/intune-export"
|
||||
```
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 Mikael Karlsson
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,147 +0,0 @@
|
||||
# Agent Operations Log
|
||||
|
||||
## Session: 2026-04-13T13:31:37+02:00
|
||||
|
||||
### Task
|
||||
Update `Initialize-IntuneAuth.ps1` to ensure the Entra app registration is granted all Microsoft Graph permissions required by the expanded macOS Intune Toolkit, and log all operations.
|
||||
|
||||
---
|
||||
|
||||
## Operations performed
|
||||
|
||||
1. **ReadFile**
|
||||
- Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/Initialize-IntuneAuth.ps1`
|
||||
- Line offset: `140`, Lines: `60`
|
||||
- Purpose: Inspect the existing roles array and app creation logic.
|
||||
|
||||
2. **StrReplaceFile**
|
||||
- Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/Initialize-IntuneAuth.ps1`
|
||||
- Change: Replaced the inline `$roles` array + app creation block with a unified `$requiredRoles` list and added logic to patch missing permissions into an existing app registration via `Update-MgApplication`.
|
||||
- Impact: New apps get the full permission set; existing apps are updated if roles are missing.
|
||||
|
||||
3. **Grep**
|
||||
- Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/Initialize-IntuneAuth.ps1`
|
||||
- Pattern: `New-MgServicePrincipalAppRoleAssignment`
|
||||
- Purpose: Verify the downstream admin-consent loop would pick up updated roles.
|
||||
|
||||
4. **ReadFile**
|
||||
- Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/Initialize-IntuneAuth.ps1`
|
||||
- Line offset: `220`, Lines: `50`
|
||||
- Purpose: Inspect the consent-granting region around `New-MgServicePrincipalAppRoleAssignment`.
|
||||
|
||||
5. **Grep**
|
||||
- Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/Initialize-IntuneAuth.ps1`
|
||||
- Pattern: `\$roles`
|
||||
- Purpose: Confirm no stale `$roles` references remained after the refactor.
|
||||
|
||||
6. **Shell**
|
||||
- Command: `pwsh -Command "$ErrorActionPreference='Stop'; Get-Command /Users/avedelphina/Local/IntuneManagement/Scripts/Initialize-IntuneAuth.ps1 | Out-Null; Write-Host 'Syntax OK'"`
|
||||
- Result: Syntax OK (PowerShell parsed the script without errors).
|
||||
|
||||
7. **Shell**
|
||||
- Command: `cd /Users/avedelphina/Local/IntuneManagement && git diff -- Scripts/Initialize-IntuneAuth.ps1`
|
||||
- Result: No diff output (file is untracked in git).
|
||||
|
||||
8. **Shell**
|
||||
- Command: `cd /Users/avedelphina/Local/IntuneManagement && git status --short`
|
||||
- Result: Listed modified and untracked files, confirming `Scripts/Initialize-IntuneAuth.ps1` is `??` (untracked).
|
||||
|
||||
9. **WriteFile**
|
||||
- Path: `/Users/avedelphina/Local/IntuneManagement/CHANGELOG_macOS_IntuneToolkit.md`
|
||||
- Content: High-level changelog of the permissions update and prior context.
|
||||
|
||||
10. **WriteFile**
|
||||
- Path: `/Users/avedelphina/Local/IntuneManagement/OPERATIONS_LOG.md`
|
||||
- Content: This file.
|
||||
|
||||
---
|
||||
|
||||
## Files modified in this session
|
||||
- `Scripts/Initialize-IntuneAuth.ps1`
|
||||
|
||||
## Files created in this session
|
||||
- `CHANGELOG_macOS_IntuneToolkit.md`
|
||||
- `OPERATIONS_LOG.md`
|
||||
|
||||
|
||||
## Session: 2026-04-13 (continuation)
|
||||
|
||||
### Task
|
||||
Build a declarative Intune baseline deployment system (YAML manifest + deployer script + converter helper).
|
||||
|
||||
---
|
||||
|
||||
## Operations performed
|
||||
|
||||
1. **ReadFile**
|
||||
- Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/Bulk-AssignmentManager.ps1`
|
||||
- Purpose: Studied auth bootstrap pattern, `$assignableTypes` registry, and assignment payload format.
|
||||
|
||||
2. **ReadFile**
|
||||
- Path: `/Users/avedelphina/Local/IntuneManagement/Extensions/MSGraph.psm1`
|
||||
- Purpose: Verified `Invoke-GraphRequest` parameter names (`Content` alias `Body`, `HttpMethod` alias `Method`).
|
||||
|
||||
3. **WriteFile**
|
||||
- Path: `/Users/avedelphina/Local/IntuneManagement/Baselines/OpenIntuneBaseline.example.yaml`
|
||||
- Content: Example baseline manifest with groups, mutations, and policy assignments.
|
||||
|
||||
4. **WriteFile**
|
||||
- Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/Deploy-IntuneBaseline.ps1`
|
||||
- Content: Main deployer script (~550 lines) with YAML parsing, group creation, policy import/update, mutation, idempotency, and assignment deployment.
|
||||
|
||||
5. **WriteFile**
|
||||
- Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/ConvertTo-IntuneBaseline.ps1`
|
||||
- Content: Helper script that scans an export folder and emits a baseline YAML skeleton.
|
||||
|
||||
6. **Shell**
|
||||
- Commands: Syntax validation (`Get-Command`) on both new scripts.
|
||||
- Result: Both scripts parsed successfully.
|
||||
|
||||
7. **WriteFile (append)**
|
||||
- Path: `/Users/avedelphina/Local/IntuneManagement/CHANGELOG_macOS_IntuneToolkit.md`
|
||||
- Added changelog entry for the baseline deployer feature.
|
||||
|
||||
8. **WriteFile (append)**
|
||||
- Path: `/Users/avedelphina/Local/IntuneManagement/OPERATIONS_LOG.md`
|
||||
- Added this very entry.
|
||||
|
||||
## Files created in this session
|
||||
- `Baselines/OpenIntuneBaseline.example.yaml`
|
||||
- `Scripts/Deploy-IntuneBaseline.ps1`
|
||||
- `Scripts/ConvertTo-IntuneBaseline.ps1`
|
||||
|
||||
|
||||
## Session: 2026-04-13 (continuation 2)
|
||||
|
||||
### Task
|
||||
Add the new `Deploy-IntuneBaseline.ps1` to the unified launcher menu, with separate dry-run and write entries.
|
||||
|
||||
---
|
||||
|
||||
## Operations performed
|
||||
|
||||
1. **ReadFile**
|
||||
- Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/Start-IntuneToolkit.ps1`
|
||||
- Purpose: Inspect menu array, switch mapping, and parameter forwarding logic.
|
||||
|
||||
2. **StrReplaceFile**
|
||||
- Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/Start-IntuneToolkit.ps1`
|
||||
- Changes:
|
||||
- Expanded `$menuItems` from 10→0 to 12→0, inserting `Deploy baseline (dry-run / WhatIf)` and `Deploy baseline`.
|
||||
- Added `switch` cases for `10` and `11` mapping to `Scripts/Deploy-IntuneBaseline.ps1`.
|
||||
- Added `$commonParams.Remove("WhatIf")` in the cleanup block to prevent flag leakage.
|
||||
|
||||
3. **Shell**
|
||||
- Command: Syntax validation on `Start-IntuneToolkit.ps1`.
|
||||
- Result: Syntax OK.
|
||||
|
||||
4. **WriteFile (append)**
|
||||
- Path: `/Users/avedelphina/Local/IntuneManagement/CHANGELOG_macOS_IntuneToolkit.md`
|
||||
- Added launcher integration changelog entry.
|
||||
|
||||
5. **WriteFile (append)**
|
||||
- Path: `/Users/avedelphina/Local/IntuneManagement/OPERATIONS_LOG.md`
|
||||
- Added this operations entry.
|
||||
|
||||
## Files modified in this session
|
||||
- `Scripts/Start-IntuneToolkit.ps1`
|
||||
@@ -1,156 +1,2 @@
|
||||
# macOS Intune Management
|
||||
# macOS_IntuneManagement
|
||||
|
||||
Cross-platform, headless Intune policy export/import with PowerShell.
|
||||
|
||||
**Current version:** `4.0.0` — see [`CHANGELOG_macOS_IntuneToolkit.md`](CHANGELOG_macOS_IntuneToolkit.md) for recent changes.
|
||||
|
||||
This repository is now CLI-first. The old WPF application surface has been removed from the repo. The supported workflow is:
|
||||
|
||||
1. export policies from a source tenant
|
||||
2. store the exported JSON and migration table
|
||||
3. import into a target tenant with app-only or browser authentication
|
||||
|
||||
## Quick start
|
||||
|
||||
The easiest way to get started is the unified launcher. It provides a single terminal UI for every tool and remembers your tenants.
|
||||
|
||||
```powershell
|
||||
pwsh ./Scripts/Start-IntuneToolkit.ps1
|
||||
```
|
||||
|
||||
If `fzf` is installed you get an interactive picker; otherwise you get a numbered menu. You can also pass a tenant directly:
|
||||
|
||||
```powershell
|
||||
pwsh ./Scripts/Start-IntuneToolkit.ps1 -TenantId "<tenant-id>"
|
||||
```
|
||||
|
||||
## Entry points
|
||||
|
||||
* [Scripts/Start-IntuneToolkit.ps1](/Users/avedelphina/Local/IntuneManagement/Scripts/Start-IntuneToolkit.ps1) — unified launcher (recommended)
|
||||
* [Start-HeadlessIntune.ps1](/Users/avedelphina/Local/IntuneManagement/Start-HeadlessIntune.ps1) — single action wrapper with optional TUI
|
||||
* [Scripts/Export-Policies.ps1](/Users/avedelphina/Local/IntuneManagement/Scripts/Export-Policies.ps1)
|
||||
* [Scripts/Import-Policies.ps1](/Users/avedelphina/Local/IntuneManagement/Scripts/Import-Policies.ps1)
|
||||
* [Scripts/Initialize-IntuneAuth.ps1](/Users/avedelphina/Local/IntuneManagement/Scripts/Initialize-IntuneAuth.ps1) — one-time Entra app + secret + Keychain setup
|
||||
* [Headless/IntuneManagement.Headless.psd1](/Users/avedelphina/Local/IntuneManagement/Headless/IntuneManagement.Headless.psd1)
|
||||
|
||||
## Runtime
|
||||
|
||||
* `pwsh` 7+
|
||||
* Microsoft Graph app registration
|
||||
* App-only auth with client secret or certificate, or browser auth with a public client redirect URI
|
||||
* `fzf` (optional) — for the best interactive menu experience in `Start-IntuneToolkit.ps1` and `Start-IntuneManagementTui.ps1`. Falls back to numbered menus if not installed.
|
||||
* macOS: `brew install fzf`
|
||||
* Linux: `sudo apt install fzf` (or `dnf` / `pacman`)
|
||||
* Windows: `winget install junegunn.fzf` (or `choco install fzf`)
|
||||
|
||||
## Default object types
|
||||
|
||||
The default headless policy scope is:
|
||||
|
||||
* `DeviceConfiguration`
|
||||
* `SettingsCatalog`
|
||||
* `AdministrativeTemplates`
|
||||
* `CompliancePolicies`
|
||||
* `EndpointSecurity`
|
||||
* `PolicySets`
|
||||
|
||||
You can override that list with `-ObjectTypes`.
|
||||
|
||||
## First-time setup
|
||||
|
||||
If you don't already have an Entra app registration, run the auth initializer. It creates the app, grants admin consent, and stores the secret in the macOS Keychain (or Windows Credential Manager).
|
||||
|
||||
```powershell
|
||||
pwsh ./Scripts/Initialize-IntuneAuth.ps1
|
||||
```
|
||||
|
||||
## Export
|
||||
|
||||
```powershell
|
||||
pwsh ./Scripts/Export-Policies.ps1 `
|
||||
-TenantId "<source-tenant-id>" `
|
||||
-AppId "<app-id>" `
|
||||
-Secret "<client-secret>" `
|
||||
-ExportPath "/tmp/intune-export" `
|
||||
-IncludeAssignments
|
||||
```
|
||||
|
||||
## Export with browser auth
|
||||
|
||||
```powershell
|
||||
pwsh ./Scripts/Export-Policies.ps1 `
|
||||
-TenantId "<source-tenant-id>" `
|
||||
-AuthMode Browser `
|
||||
-ExportPath "/tmp/intune-export"
|
||||
```
|
||||
|
||||
## Import
|
||||
|
||||
```powershell
|
||||
pwsh ./Scripts/Import-Policies.ps1 `
|
||||
-TenantId "<target-tenant-id>" `
|
||||
-AppId "<app-id>" `
|
||||
-Secret "<client-secret>" `
|
||||
-ImportPath "/tmp/intune-export/SourceTenantName" `
|
||||
-ImportType alwaysImport `
|
||||
-IncludeAssignments `
|
||||
-IncludeScopeTags `
|
||||
-ReplaceDependencyIds
|
||||
```
|
||||
|
||||
## Import with browser auth
|
||||
|
||||
```powershell
|
||||
pwsh ./Scripts/Import-Policies.ps1 `
|
||||
-TenantId "<target-tenant-id>" `
|
||||
-AuthMode Browser `
|
||||
-ImportPath "/tmp/intune-export/SourceTenantName"
|
||||
```
|
||||
|
||||
## Single action entry point
|
||||
|
||||
```powershell
|
||||
pwsh ./Start-HeadlessIntune.ps1 `
|
||||
-Action Export `
|
||||
-TenantId "<source-tenant-id>" `
|
||||
-AppId "<app-id>" `
|
||||
-Secret "<client-secret>" `
|
||||
-ExportPath "/tmp/intune-export"
|
||||
```
|
||||
|
||||
```powershell
|
||||
pwsh ./Start-HeadlessIntune.ps1 `
|
||||
-Action Import `
|
||||
-TenantId "<target-tenant-id>" `
|
||||
-AppId "<app-id>" `
|
||||
-Secret "<client-secret>" `
|
||||
-ImportPath "/tmp/intune-export/SourceTenantName" `
|
||||
-ImportType alwaysImport
|
||||
```
|
||||
|
||||
```powershell
|
||||
pwsh ./Start-HeadlessIntune.ps1 `
|
||||
-Action Export `
|
||||
-TenantId "<source-tenant-id>" `
|
||||
-AuthMode Browser `
|
||||
-RedirectUri "http://localhost" `
|
||||
-ExportPath "/tmp/intune-export"
|
||||
```
|
||||
|
||||
## Additional toolkit scripts
|
||||
|
||||
* **Baseline deployment** — [`Deploy-IntuneBaseline.ps1`](Scripts/Deploy-IntuneBaseline.ps1) deploys a YAML manifest of policies + assignments to a tenant, with dry-run support. [`ConvertTo-IntuneBaseline.ps1`](Scripts/ConvertTo-IntuneBaseline.ps1) turns an existing export folder into a baseline skeleton.
|
||||
* **Bulk assignments** — [`Bulk-AssignmentManager.ps1`](Scripts/Bulk-AssignmentManager.ps1) adds or removes assignments for any policy type using the bulk `/assign` endpoint. [`Bulk-AppAssignment.ps1`](Scripts/Bulk-AppAssignment.ps1) does the same for applications.
|
||||
* **Backup / restore assignments** — [`Backup-Restore-Assignments.ps1`](Scripts/Backup-Restore-Assignments.ps1) saves assignments to JSON and can restore them with cross-tenant group name resolution.
|
||||
* **Bulk rename** — [`Bulk-RenamePolicies.ps1`](Scripts/Bulk-RenamePolicies.ps1) performs search/replace or prefix mutations across policy names and descriptions.
|
||||
* **Device operations** — [`Bulk-DeviceOperations.ps1`](Scripts/Bulk-DeviceOperations.ps1) supports delete, retire, wipe, lock, and sync with `-WhatIf` safeguards.
|
||||
* **Assignment documentation** — [`Export-AssignmentsToCsv.ps1`](Scripts/Export-AssignmentsToCsv.ps1) exports assignments to CSV and Markdown.
|
||||
|
||||
## Notes
|
||||
|
||||
* Export writes a migration table used during cross-tenant import.
|
||||
* Import can translate dependency IDs and recreate missing assignment groups.
|
||||
* This repo intentionally does not preserve the old Windows UI launch flow.
|
||||
* Browser auth uses the system browser and a loopback redirect.
|
||||
* If you omit `-AppId` with `-AuthMode Browser`, the CLI defaults to the Microsoft Graph PowerShell public client app id `14d82eec-204b-4c2f-b7e8-296a70dab67e`.
|
||||
* If your own app registration does not allow loopback redirects, pass `-AppId` and `-RedirectUri "http://localhost"` and configure the same redirect URI in Entra ID.
|
||||
|
||||
-1380
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
||||
@{
|
||||
RootModule = 'IntuneManagement.Runtime.psm1'
|
||||
ModuleVersion = '4.0.0'
|
||||
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', 'Expand-FileName')
|
||||
AliasesToExport = @()
|
||||
ModuleList = @('IntuneManagement.Runtime.psm1')
|
||||
PrivateData = @{
|
||||
PSData = @{
|
||||
Tags = @('Intune','IntuneManagement','Microsoft Graph','PowerShell','CLI','Headless')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
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()]
|
||||
param(
|
||||
[string]$View = "",
|
||||
[switch]$ShowConsoleWindow,
|
||||
[switch]$JSonSettings,
|
||||
[string]$JSonFile,
|
||||
[switch]$Silent,
|
||||
[string]$SilentBatchFile,
|
||||
[string]$TenantId,
|
||||
[string]$AppId,
|
||||
[string]$Secret,
|
||||
[string]$Certificate,
|
||||
[ValidateSet("AppOnly","Browser","DeviceCode")]
|
||||
[string]$AuthMode = "AppOnly",
|
||||
[string]$RedirectUri,
|
||||
[string]$GraphEnvironment,
|
||||
[string]$GCCType
|
||||
)
|
||||
|
||||
$PSModuleAutoloadingPreference = "none"
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
$global:hideUI = $true
|
||||
$global:SilentBatchFile = $SilentBatchFile
|
||||
$global:TenantId = $TenantId
|
||||
$global:AzureAppId = $AppId
|
||||
$global:ClientSecret = $Secret
|
||||
$global:ClientCert = $Certificate
|
||||
$global:HeadlessAuthMode = $AuthMode
|
||||
$global:MSALRedirectUri = $RedirectUri
|
||||
$global:UseGraphEnvironment = $GraphEnvironment
|
||||
$global:UseGCCType = $GCCType
|
||||
$global:UseJSonSettings = ($JSonSettings -eq $true)
|
||||
$global:JSonSettingFile = $JSonFile
|
||||
|
||||
if(-not $Silent)
|
||||
{
|
||||
Write-Warning "UI support has been removed. Continuing in headless mode."
|
||||
}
|
||||
|
||||
if(-not $global:TenantId)
|
||||
{
|
||||
Write-Error "Tenant Id is missing. Use -TenantId <Tenant-guid>."
|
||||
return
|
||||
}
|
||||
|
||||
if($global:TenantId)
|
||||
{
|
||||
Write-Host "Using Tenant Id: $($global:TenantId)"
|
||||
}
|
||||
|
||||
if($global:AzureAppId)
|
||||
{
|
||||
Write-Host "Using Azure App Id: $($global:AzureAppId)"
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Warning "Azure App Id is missing. Use -AppId <AppId>."
|
||||
}
|
||||
|
||||
if($global:ClientSecret)
|
||||
{
|
||||
Write-Host "Using Azure App Secret"
|
||||
}
|
||||
elseif($global:ClientCert)
|
||||
{
|
||||
Write-Host "Using Azure App Certificate"
|
||||
}
|
||||
elseif($global:HeadlessAuthMode -eq "Browser")
|
||||
{
|
||||
Write-Host "Using browser authentication"
|
||||
}
|
||||
elseif($global:HeadlessAuthMode -eq "DeviceCode")
|
||||
{
|
||||
Write-Host "Using device code authentication"
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Warning "Azure App Secret or Certificate is missing. Use -Secret <Secret> or -Certificate <Certificate>."
|
||||
}
|
||||
|
||||
if($global:UseJSonSettings)
|
||||
{
|
||||
Write-Host "Use json settings"
|
||||
}
|
||||
|
||||
Import-Module (Join-Path (Split-Path -Parent $PSScriptRoot) "Core.psm1") -Force -Global
|
||||
Start-CoreApp $View
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Initialize-IntuneManagementRuntime, Test-IsWindowsPlatform, Expand-FileName
|
||||
@@ -1,523 +0,0 @@
|
||||
#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
|
||||
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&`$orderby=displayName" -AllPages
|
||||
$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&`$orderby=displayName" -AllPages
|
||||
$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
|
||||
@@ -1,446 +0,0 @@
|
||||
#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
|
||||
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" -AllPages
|
||||
$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
|
||||
@@ -1,732 +0,0 @@
|
||||
#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
|
||||
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" -AllPages
|
||||
$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
|
||||
}
|
||||
|
||||
# Helper: compute TargetDesc for an assignment
|
||||
function Get-AssignmentTargetDesc
|
||||
{
|
||||
param($Ass)
|
||||
$tt = $Ass.target."@odata.type"
|
||||
switch($tt)
|
||||
{
|
||||
"#microsoft.graph.groupAssignmentTarget"
|
||||
{
|
||||
$grp = $groups | Where-Object { $_.id -eq $Ass.target.groupId } | Select-Object -First 1
|
||||
return "Include: $(if($grp){$grp.displayName}else{$Ass.target.groupId})"
|
||||
}
|
||||
"#microsoft.graph.exclusionGroupAssignmentTarget"
|
||||
{
|
||||
$grp = $groups | Where-Object { $_.id -eq $Ass.target.groupId } | Select-Object -First 1
|
||||
return "Exclude: $(if($grp){$grp.displayName}else{$Ass.target.groupId})"
|
||||
}
|
||||
"#microsoft.graph.allLicensedUsersAssignmentTarget" { return "All Users" }
|
||||
"#microsoft.graph.allDevicesAssignmentTarget" { return "All Devices" }
|
||||
default { return "Unknown" }
|
||||
}
|
||||
}
|
||||
|
||||
# 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
|
||||
try
|
||||
{
|
||||
$existing = Invoke-GraphRequest "$($objectType.API)/$($obj.id)/assignments"
|
||||
$remaining = @()
|
||||
foreach($ea in $existing.value)
|
||||
{
|
||||
$desc = Get-AssignmentTargetDesc -Ass $ea
|
||||
if($desc -in $selectedTargetDisplays)
|
||||
{
|
||||
continue
|
||||
}
|
||||
# Sanitize for re-post
|
||||
$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
|
||||
}
|
||||
$remaining += $clean
|
||||
}
|
||||
|
||||
$assignPayload = @{
|
||||
$objectType.AssignmentsType = $remaining
|
||||
} | ConvertTo-Json -Depth 50 -Compress
|
||||
|
||||
$null = Invoke-GraphRequest "$($objectType.API)/$($obj.id)/assign" -HttpMethod POST -Content $assignPayload
|
||||
foreach($ass in $objAssignments)
|
||||
{
|
||||
Write-Host " OK: Removed $($ass.TargetDesc)" -ForegroundColor Green
|
||||
$success++
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
foreach($ass in $objAssignments)
|
||||
{
|
||||
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
|
||||
@@ -1,411 +0,0 @@
|
||||
#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
|
||||
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
|
||||
@@ -1,450 +0,0 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bulk rename Intune policy/app displayNames and descriptions.
|
||||
.DESCRIPTION
|
||||
Search and replace names or descriptions across multiple Intune object types
|
||||
in a single operation. Supports regex search/replace and prefix add/strip.
|
||||
Integrates with the IntuneManagement headless auth stack.
|
||||
.EXAMPLE
|
||||
./Scripts/Bulk-RenamePolicies.ps1 -TenantId "contoso.onmicrosoft.com"
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TenantId,
|
||||
|
||||
[string]$AppId,
|
||||
|
||||
[string]$Secret,
|
||||
|
||||
[string]$Certificate,
|
||||
|
||||
[ValidateSet("AppOnly","Browser","DeviceCode")]
|
||||
[string]$AuthMode = "AppOnly",
|
||||
|
||||
[string]$RedirectUri,
|
||||
|
||||
[string]$SettingsFile,
|
||||
|
||||
[switch]$WhatIf
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
#region Helper functions
|
||||
function Test-FzfAvailable
|
||||
{
|
||||
return [bool](Get-Command fzf -ErrorAction SilentlyContinue)
|
||||
}
|
||||
|
||||
function Show-FzfMenu
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string[]]$Items,
|
||||
[string]$Header = "Select one",
|
||||
[switch]$Multi
|
||||
)
|
||||
$argsList = @("--header=$Header")
|
||||
if($Multi) { $argsList += "--multi" }
|
||||
$selected = $Items | fzf @argsList
|
||||
if(-not $selected) { return $null }
|
||||
if($Multi) { return @($selected -split "`r?`n" | Where-Object { $_ }) }
|
||||
return $selected
|
||||
}
|
||||
|
||||
function Show-NumberedMenu
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string[]]$Items,
|
||||
[string]$Header = "Select one or more",
|
||||
[switch]$Multi
|
||||
)
|
||||
Write-Host "`n$Header" -ForegroundColor Cyan
|
||||
for($i=0; $i -lt $Items.Count; $i++)
|
||||
{
|
||||
Write-Host " $($i+1). $($Items[$i])"
|
||||
}
|
||||
if($Multi)
|
||||
{
|
||||
$prompt = "Enter numbers separated by commas (e.g. 1,3,5) or 'all'"
|
||||
}
|
||||
else
|
||||
{
|
||||
$prompt = "Enter a number"
|
||||
}
|
||||
$choice = Read-Host $prompt
|
||||
if($choice -eq "all" -and $Multi) { return $Items }
|
||||
$indices = $choice -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ -match "^\d+$" } | ForEach-Object { [int]$_ - 1 } | Where-Object { $_ -ge 0 -and $_ -lt $Items.Count }
|
||||
if($Multi)
|
||||
{
|
||||
return $Items[$indices] | Select-Object -Unique
|
||||
}
|
||||
else
|
||||
{
|
||||
if($indices.Count -eq 0) { return $null }
|
||||
return $Items[$indices[0]]
|
||||
}
|
||||
}
|
||||
|
||||
function Select-MenuItem
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string[]]$Items,
|
||||
[string]$Header = "Select one",
|
||||
[switch]$Multi
|
||||
)
|
||||
if(Test-FzfAvailable)
|
||||
{
|
||||
return Show-FzfMenu -Items $Items -Header $Header -Multi:$Multi
|
||||
}
|
||||
return Show-NumberedMenu -Items $Items -Header $Header -Multi:$Multi
|
||||
}
|
||||
|
||||
function Read-YesNo
|
||||
{
|
||||
param(
|
||||
[string]$Prompt,
|
||||
[bool]$Default = $false
|
||||
)
|
||||
$defaultChar = if($Default) { "Y" } else { "N" }
|
||||
$response = Read-Host "$Prompt [Y/n] (default: $defaultChar)"
|
||||
if([string]::IsNullOrWhiteSpace($response)) { return $Default }
|
||||
return $response -match "^\s*y"
|
||||
}
|
||||
|
||||
function Get-DefaultSettingsPath
|
||||
{
|
||||
if($IsWindows -or $env:OS -eq "Windows_NT")
|
||||
{
|
||||
if($env:LOCALAPPDATA) { return (Join-Path $env:LOCALAPPDATA "macOS_IntuneManagement\Settings.json") }
|
||||
return (Join-Path $env:USERPROFILE "AppData\Local\macOS_IntuneManagement\Settings.json")
|
||||
}
|
||||
if($IsMacOS) { return (Join-Path $HOME "Library/Application Support/macOS_IntuneManagement/Settings.json") }
|
||||
return (Join-Path $HOME ".local/share/macOS_IntuneManagement/Settings.json")
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Initialize Runtime
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1"
|
||||
if(-not (Test-Path $runtimeModule))
|
||||
{
|
||||
throw "Could not find IntuneManagement.Runtime.psd1 in $projectRoot"
|
||||
}
|
||||
|
||||
$settingsPath = $SettingsFile
|
||||
if(-not $settingsPath)
|
||||
{
|
||||
$settingsPath = Get-DefaultSettingsPath
|
||||
}
|
||||
|
||||
# Pre-load auth from settings
|
||||
if($AuthMode -eq "AppOnly" -and (Test-Path $settingsPath) -and (-not $AppId -or (-not $Secret -and -not $Certificate)))
|
||||
{
|
||||
try
|
||||
{
|
||||
$raw = Get-Content -Path $settingsPath -Raw -ErrorAction Stop
|
||||
$settingsObj = ConvertFrom-Json $raw -AsHashtable -ErrorAction Stop
|
||||
if($settingsObj -and $settingsObj.ContainsKey($TenantId))
|
||||
{
|
||||
$tenantNode = $settingsObj[$TenantId]
|
||||
if(-not $AppId -and $tenantNode.ContainsKey("GraphAzureAppId"))
|
||||
{
|
||||
$AppId = $tenantNode["GraphAzureAppId"]
|
||||
}
|
||||
if(-not $Secret -and $tenantNode.ContainsKey("GraphAzureAppSecret"))
|
||||
{
|
||||
$Secret = $tenantNode["GraphAzureAppSecret"]
|
||||
}
|
||||
if(-not $Certificate -and $tenantNode.ContainsKey("GraphAzureAppCert"))
|
||||
{
|
||||
$Certificate = $tenantNode["GraphAzureAppCert"]
|
||||
}
|
||||
}
|
||||
|
||||
if(-not $Secret -and $IsMacOS -and $AppId)
|
||||
{
|
||||
try
|
||||
{
|
||||
$keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" -w 2>$null
|
||||
if($keychainSecret) { $Secret = $keychainSecret }
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
$invokeParams = @{
|
||||
Silent = $true
|
||||
JSonSettings = $true
|
||||
JSonFile = $settingsPath
|
||||
TenantId = $TenantId
|
||||
AppId = $AppId
|
||||
AuthMode = $AuthMode
|
||||
}
|
||||
if($RedirectUri) { $invokeParams.RedirectUri = $RedirectUri }
|
||||
if($AuthMode -eq "AppOnly" -and $Secret) { $invokeParams.Secret = $Secret }
|
||||
elseif($AuthMode -eq "AppOnly") { $invokeParams.Certificate = $Certificate }
|
||||
|
||||
Import-Module $runtimeModule -Force
|
||||
Initialize-IntuneManagementRuntime -View "IntuneGraphAPI" @invokeParams
|
||||
#endregion
|
||||
|
||||
#region Ensure Graph connectivity
|
||||
if(-not (Get-Command Invoke-GraphRequest -ErrorAction SilentlyContinue))
|
||||
{
|
||||
throw "Graph runtime did not load Invoke-GraphRequest. Aborting."
|
||||
}
|
||||
|
||||
Write-Host "`nConnecting to Microsoft Graph..." -ForegroundColor Cyan
|
||||
try
|
||||
{
|
||||
$org = Invoke-GraphRequest "/organization"
|
||||
Write-Host "Connected to tenant: $($org.value[0].displayName) ($($org.value[0].id))" -ForegroundColor Green
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw "Failed to connect to Graph. Ensure auth parameters are correct. Error: $_"
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Object type registry (editable types)
|
||||
$editableTypes = @(
|
||||
[PSCustomObject]@{ Title = "Applications"; API = "/deviceAppManagement/mobileApps"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Device Configuration"; API = "/deviceManagement/deviceConfigurations"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Settings Catalog"; API = "/deviceManagement/configurationPolicies"; NameProp = "name"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Compliance Policies"; API = "/deviceManagement/deviceCompliancePolicies"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Administrative Templates"; API = "/deviceManagement/groupPolicyConfigurations"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Endpoint Security"; API = "/deviceManagement/intents"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "App Protection"; API = "/deviceAppManagement/managedAppPolicies"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "App Configuration (Device)"; API = "/deviceAppManagement/mobileAppConfigurations"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Platform Scripts"; API = "/deviceManagement/deviceManagementScripts"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "macOS Scripts"; API = "/deviceManagement/deviceShellScripts"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Device Health Scripts"; API = "/deviceManagement/deviceHealthScripts"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "macOS Custom Attributes"; API = "/deviceManagement/deviceCustomAttributeShellScripts"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Enrollment Restrictions"; API = "/deviceManagement/deviceEnrollmentConfigurations"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Enrollment Status Page"; API = "/deviceManagement/deviceEnrollmentConfigurations"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Autopilot"; API = "/deviceManagement/windowsAutopilotDeploymentProfiles"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Terms and Conditions"; API = "/deviceManagement/termsAndConditions"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Policy Sets"; API = "/deviceAppManagement/policySets"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Update Policies"; API = "/deviceManagement/windowsUpdateForBusinessConfigurations"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Feature Updates"; API = "/deviceManagement/windowsFeatureUpdateProfiles"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Quality Updates"; API = "/deviceManagement/windowsQualityUpdateProfiles"; NameProp = "displayName"; DescProp = "description" },
|
||||
[PSCustomObject]@{ Title = "Device Management Intents"; API = "/deviceManagement/intents"; NameProp = "displayName"; DescProp = "description" }
|
||||
)
|
||||
#endregion
|
||||
|
||||
Clear-Host
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Intune Bulk Rename Tool" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
|
||||
#region Select object type
|
||||
$typeTitles = $editableTypes | ForEach-Object { $_.Title }
|
||||
$selectedTypeTitle = Select-MenuItem -Items $typeTitles -Header "Select object type"
|
||||
if(-not $selectedTypeTitle) { Write-Host "Cancelled." -ForegroundColor Yellow; exit 0 }
|
||||
$objectType = $editableTypes | Where-Object { $_.Title -eq $selectedTypeTitle } | Select-Object -First 1
|
||||
#endregion
|
||||
|
||||
#region Load objects
|
||||
Write-Host "`nLoading $($objectType.Title) objects..." -ForegroundColor Cyan
|
||||
$api = "$($objectType.API)?`$select=id,$($objectType.NameProp),$(if($objectType.DescProp){$objectType.DescProp})&`$orderby=$($objectType.NameProp)"
|
||||
$objectsResponse = Invoke-GraphRequest $api -AllPages
|
||||
$objects = $objectsResponse.value | Where-Object { $_ } | Sort-Object $objectType.NameProp
|
||||
Write-Host "Found $($objects.Count) objects." -ForegroundColor Green
|
||||
|
||||
$filter = Read-Host "`nFilter by current name (optional, press Enter to skip)"
|
||||
if(-not [string]::IsNullOrWhiteSpace($filter))
|
||||
{
|
||||
$objects = $objects | Where-Object { $_."$($objectType.NameProp)" -like "*$filter*" }
|
||||
Write-Host "Filtered to $($objects.Count) objects." -ForegroundColor Green
|
||||
}
|
||||
|
||||
if($objects.Count -eq 0)
|
||||
{
|
||||
Write-Host "No objects found. Exiting." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
$objectDisplays = $objects | ForEach-Object { "$($_."$($objectType.NameProp)") [$($_.id)]" }
|
||||
$selectedDisplays = Select-MenuItem -Items $objectDisplays -Header "Select objects to rename (multi-select)" -Multi
|
||||
if(-not $selectedDisplays)
|
||||
{
|
||||
Write-Host "No objects selected. Exiting." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
$selectedObjects = @()
|
||||
foreach($disp in $selectedDisplays)
|
||||
{
|
||||
$id = $disp -replace '.*\[(.*?)\]$', '$1'
|
||||
$obj = $objects | Where-Object { $_.id -eq $id } | Select-Object -First 1
|
||||
if($obj) { $selectedObjects += $obj }
|
||||
}
|
||||
Write-Host "Selected $($selectedObjects.Count) objects." -ForegroundColor Green
|
||||
#endregion
|
||||
|
||||
#region Mutation options
|
||||
$fieldToEdit = Select-MenuItem -Items @("displayName","description","both") -Header "Which field to edit?"
|
||||
if(-not $fieldToEdit) { $fieldToEdit = "displayName" }
|
||||
|
||||
$mode = Select-MenuItem -Items @("Search and replace","Add prefix","Strip prefix") -Header "Select rename mode"
|
||||
if(-not $mode) { Write-Host "Cancelled." -ForegroundColor Yellow; exit 0 }
|
||||
|
||||
$searchPattern = ""
|
||||
$replacePattern = ""
|
||||
$prefix = ""
|
||||
|
||||
switch($mode)
|
||||
{
|
||||
"Search and replace"
|
||||
{
|
||||
$searchPattern = Read-Host "Enter search regex"
|
||||
$replacePattern = Read-Host "Enter replacement string"
|
||||
}
|
||||
"Add prefix"
|
||||
{
|
||||
$prefix = Read-Host "Enter prefix to add"
|
||||
}
|
||||
"Strip prefix"
|
||||
{
|
||||
$prefix = Read-Host "Enter prefix to strip (will be removed from start)"
|
||||
}
|
||||
}
|
||||
|
||||
# Preview changes
|
||||
Write-Host "`nPreview of changes:" -ForegroundColor Cyan
|
||||
$changes = @()
|
||||
foreach($obj in $selectedObjects)
|
||||
{
|
||||
$oldName = $obj."$($objectType.NameProp)"
|
||||
$oldDesc = if($objectType.DescProp -and $obj.PSObject.Properties[$objectType.DescProp]) { $obj."$($objectType.DescProp)" } else { "" }
|
||||
$newName = $oldName
|
||||
$newDesc = $oldDesc
|
||||
|
||||
if($fieldToEdit -in @("displayName","both"))
|
||||
{
|
||||
switch($mode)
|
||||
{
|
||||
"Search and replace" { if($oldName -match $searchPattern) { $newName = $oldName -replace $searchPattern, $replacePattern } }
|
||||
"Add prefix" { if(-not $oldName.StartsWith($prefix)) { $newName = "$prefix$oldName" } }
|
||||
"Strip prefix" { if($oldName.StartsWith($prefix)) { $newName = $oldName.Substring($prefix.Length) } }
|
||||
}
|
||||
}
|
||||
if($fieldToEdit -in @("description","both") -and $objectType.DescProp)
|
||||
{
|
||||
switch($mode)
|
||||
{
|
||||
"Search and replace" { if($oldDesc -match $searchPattern) { $newDesc = $oldDesc -replace $searchPattern, $replacePattern } }
|
||||
"Add prefix" { if(-not $oldDesc.StartsWith($prefix)) { $newDesc = "$prefix$oldDesc" } }
|
||||
"Strip prefix" { if($oldDesc.StartsWith($prefix)) { $newDesc = $oldDesc.Substring($prefix.Length) } }
|
||||
}
|
||||
}
|
||||
|
||||
if($newName -ne $oldName -or $newDesc -ne $oldDesc)
|
||||
{
|
||||
$changes += [PSCustomObject]@{
|
||||
Object = $obj
|
||||
OldName = $oldName
|
||||
NewName = $newName
|
||||
OldDesc = $oldDesc
|
||||
NewDesc = $newDesc
|
||||
}
|
||||
Write-Host " $($oldName)" -ForegroundColor DarkGray
|
||||
if($newName -ne $oldName) { Write-Host " -> Name: $newName" -ForegroundColor Green }
|
||||
if($newDesc -ne $oldDesc) { Write-Host " -> Desc: $newDesc" -ForegroundColor Green }
|
||||
}
|
||||
}
|
||||
|
||||
if($changes.Count -eq 0)
|
||||
{
|
||||
Write-Host "No objects would be changed. Exiting." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
$confirm = Read-Host "`nProceed with renaming $($changes.Count) objects? [Y/n]"
|
||||
if(-not ([string]::IsNullOrWhiteSpace($confirm) -or $confirm -match "^\s*y"))
|
||||
{
|
||||
Write-Host "Cancelled." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Execute
|
||||
$success = 0
|
||||
$failed = 0
|
||||
|
||||
foreach($change in $changes)
|
||||
{
|
||||
$obj = $change.Object
|
||||
$payload = @{}
|
||||
|
||||
if($fieldToEdit -in @("displayName","both") -and $change.NewName -ne $change.OldName)
|
||||
{
|
||||
$payload[$objectType.NameProp] = $change.NewName
|
||||
}
|
||||
if($fieldToEdit -in @("description","both") -and $objectType.DescProp -and $change.NewDesc -ne $change.OldDesc)
|
||||
{
|
||||
$payload[$objectType.DescProp] = $change.NewDesc
|
||||
}
|
||||
|
||||
if($payload.Count -eq 0) { continue }
|
||||
|
||||
try
|
||||
{
|
||||
if($WhatIf)
|
||||
{
|
||||
Write-Host " WHATIF: Would update $($change.OldName)" -ForegroundColor Magenta
|
||||
$success++
|
||||
}
|
||||
else
|
||||
{
|
||||
$body = $payload | ConvertTo-Json -Depth 10 -Compress
|
||||
$maxRetries = 3
|
||||
$retryDelay = 2
|
||||
$renamed = $false
|
||||
for($r = 1; $r -le $maxRetries; $r++)
|
||||
{
|
||||
try
|
||||
{
|
||||
$null = Invoke-GraphRequest "$($objectType.API)/$($obj.id)" -HttpMethod PATCH -Content $body
|
||||
Write-Host " OK: Renamed '$($change.OldName)' -> '$($change.NewName)'" -ForegroundColor Green
|
||||
$success++
|
||||
$renamed = $true
|
||||
break
|
||||
}
|
||||
catch
|
||||
{
|
||||
$statusCode = $_.Exception.Response.StatusCode
|
||||
if($r -lt $maxRetries -and ($statusCode -ge 500 -or $statusCode -eq 429))
|
||||
{
|
||||
Write-Host " Retry $r/$maxRetries after $retryDelay`s (HTTP $statusCode)..." -ForegroundColor DarkYellow
|
||||
Start-Sleep -Seconds $retryDelay
|
||||
}
|
||||
else
|
||||
{
|
||||
throw
|
||||
}
|
||||
}
|
||||
}
|
||||
if(-not $renamed) { throw "Rename failed after $maxRetries attempts." }
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Host " ERROR: Failed to rename '$($change.OldName)'. $($_.Exception.Message)" -ForegroundColor Red
|
||||
$failed++
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "`n========================================" -ForegroundColor Cyan
|
||||
Write-Host " Bulk Rename Complete" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Success : $success"
|
||||
Write-Host " Failed : $failed"
|
||||
#endregion
|
||||
@@ -1,148 +0,0 @@
|
||||
#requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Converts an existing IntuneManagement export folder into a baseline YAML manifest.
|
||||
.DESCRIPTION
|
||||
Scans a toolkit export directory, infers policy types from folder names,
|
||||
extracts display names from JSON files, and emits a baseline YAML skeleton
|
||||
with empty assignment blocks ready for editing.
|
||||
.EXAMPLE
|
||||
./Scripts/ConvertTo-IntuneBaseline.ps1 -ExportPath ./Exports/2025-01-15 -OutputPath ./Baselines/mybaseline.yaml
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ExportPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputPath,
|
||||
|
||||
[string]$BaselineName = "ConvertedBaseline"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
#region Dependency check
|
||||
$yamlModule = Get-Module -ListAvailable -Name powershell-yaml | Select-Object -First 1
|
||||
if(-not $yamlModule)
|
||||
{
|
||||
Write-Warning "powershell-yaml module not found. Installing..."
|
||||
Install-Module powershell-yaml -Scope CurrentUser -Force
|
||||
}
|
||||
Import-Module powershell-yaml -Force
|
||||
#endregion
|
||||
|
||||
$exportPathResolved = Resolve-Path $ExportPath | Select-Object -ExpandProperty Path
|
||||
$outputPathResolved = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
|
||||
|
||||
if(-not (Test-Path $exportPathResolved))
|
||||
{
|
||||
throw "Export path not found: $ExportPath"
|
||||
}
|
||||
|
||||
# Folder-to-type whitelist (matches toolkit export folders and baseline types)
|
||||
$folderTypeMap = @{
|
||||
"DeviceConfiguration" = "DeviceConfiguration"
|
||||
"SettingsCatalog" = "SettingsCatalog"
|
||||
"CompliancePolicies" = "CompliancePolicies"
|
||||
"CompliancePoliciesV2" = "CompliancePoliciesV2"
|
||||
"AdministrativeTemplates" = "AdministrativeTemplates"
|
||||
"EndpointSecurity" = "EndpointSecurity"
|
||||
"DeviceManagementIntents" = "DeviceManagementIntents"
|
||||
"AppProtection" = "AppProtection"
|
||||
"AppConfigurationManagedDevice" = "AppConfigurationManagedDevice"
|
||||
"PlatformScripts" = "PlatformScripts"
|
||||
"MacScripts" = "MacScripts"
|
||||
"DeviceHealthScripts" = "DeviceHealthScripts"
|
||||
"MacCustomAttributes" = "MacCustomAttributes"
|
||||
"EnrollmentRestrictions" = "EnrollmentRestrictions"
|
||||
"EnrollmentStatusPage" = "EnrollmentStatusPage"
|
||||
"Autopilot" = "Autopilot"
|
||||
"TermsAndConditions" = "TermsAndConditions"
|
||||
"PolicySets" = "PolicySets"
|
||||
"UpdatePolicies" = "UpdatePolicies"
|
||||
"FeatureUpdates" = "FeatureUpdates"
|
||||
"QualityUpdates" = "QualityUpdates"
|
||||
"Applications" = "Applications"
|
||||
}
|
||||
|
||||
$policies = @()
|
||||
|
||||
foreach($folder in Get-ChildItem -Path $exportPathResolved -Directory)
|
||||
{
|
||||
$folderName = $folder.Name
|
||||
if(-not $folderTypeMap.ContainsKey($folderName))
|
||||
{
|
||||
Write-Verbose "Skipping unrecognized folder: $folderName"
|
||||
continue
|
||||
}
|
||||
$typeName = $folderTypeMap[$folderName]
|
||||
$nameProp = if($typeName -eq "SettingsCatalog" -or $typeName -eq "CompliancePoliciesV2") { "name" } else { "displayName" }
|
||||
|
||||
$jsonFiles = Get-ChildItem -Path $folder.FullName -Filter "*.json"
|
||||
foreach($file in $jsonFiles)
|
||||
{
|
||||
# Skip *_Settings.json companion files
|
||||
if($file.BaseName -like "*_Settings")
|
||||
{
|
||||
continue
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$json = Get-Content $file.FullName -Raw | ConvertFrom-Json -Depth 10
|
||||
$displayName = $json.$nameProp
|
||||
if(-not $displayName)
|
||||
{
|
||||
$displayName = $json.displayName
|
||||
}
|
||||
if(-not $displayName)
|
||||
{
|
||||
$displayName = $file.BaseName
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Warning "Could not parse $($file.FullName); using filename as display name."
|
||||
$displayName = $file.BaseName
|
||||
}
|
||||
|
||||
$relativePath = "." + $file.FullName.Substring($exportPathResolved.Length).Replace("\", "/")
|
||||
|
||||
$policies += [ordered]@{
|
||||
sourcePath = $relativePath
|
||||
type = $typeName
|
||||
assignments = @()
|
||||
}
|
||||
|
||||
Write-Host "Mapped: [$typeName] $displayName -> $relativePath"
|
||||
}
|
||||
}
|
||||
|
||||
if($policies.Count -eq 0)
|
||||
{
|
||||
throw "No convertible policies found in $exportPathResolved"
|
||||
}
|
||||
|
||||
$baseline = [ordered]@{
|
||||
baseline = [ordered]@{
|
||||
name = $BaselineName
|
||||
conflictResolution = "Skip"
|
||||
whatIf = $false
|
||||
tenantMutation = [ordered]@{
|
||||
search = ""
|
||||
replace = ""
|
||||
}
|
||||
groups = @()
|
||||
policies = $policies
|
||||
}
|
||||
}
|
||||
|
||||
$yaml = ConvertTo-Yaml -Data $baseline
|
||||
$yaml | Set-Content -Path $outputPathResolved -Encoding UTF8
|
||||
|
||||
Write-Host "`nBaseline skeleton written to: $outputPathResolved" -ForegroundColor Green
|
||||
Write-Host "Policies found: $($policies.Count)" -ForegroundColor Green
|
||||
Write-Host "Next steps:" -ForegroundColor Cyan
|
||||
Write-Host " 1. Edit the YAML to add group names and assignments."
|
||||
Write-Host " 2. Run Deploy-IntuneBaseline.ps1 against a target tenant."
|
||||
@@ -1,112 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates a Microsoft Entra app registration for headless Intune export/import.
|
||||
.DESCRIPTION
|
||||
Uses the Microsoft Graph PowerShell SDK to create an app, add required Graph
|
||||
permissions, generate a client secret, and output the values needed for
|
||||
AppOnly authentication.
|
||||
|
||||
Requires: Microsoft.Graph.Authentication, Microsoft.Graph.Applications
|
||||
Install if missing: Install-Module Microsoft.Graph -Scope CurrentUser
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$DisplayName = "IntuneManagement-Headless",
|
||||
|
||||
[ValidateSet("Export","Import","Both")]
|
||||
[string]$PermissionLevel = "Both"
|
||||
)
|
||||
|
||||
$requiredModules = @("Microsoft.Graph.Authentication", "Microsoft.Graph.Applications")
|
||||
foreach ($mod in $requiredModules) {
|
||||
if (-not (Get-Module $mod -ListAvailable)) {
|
||||
throw "Module '$mod' is not installed. Run: Install-Module Microsoft.Graph -Scope CurrentUser"
|
||||
}
|
||||
}
|
||||
|
||||
Import-Module Microsoft.Graph.Authentication -Force
|
||||
Import-Module Microsoft.Graph.Applications -Force
|
||||
|
||||
Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Cyan
|
||||
Write-Host "A browser window will open for authentication." -ForegroundColor Cyan
|
||||
Connect-MgGraph -Scopes "Application.ReadWrite.All", "AppRoleAssignment.ReadWrite.All" -NoWelcome
|
||||
|
||||
$graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
|
||||
if (-not $graphSp) {
|
||||
throw "Could not retrieve Microsoft Graph service principal."
|
||||
}
|
||||
|
||||
$exportRoles = @(
|
||||
"DeviceManagementApps.Read.All",
|
||||
"DeviceManagementConfiguration.Read.All",
|
||||
"DeviceManagementManagedDevices.Read.All",
|
||||
"DeviceManagementScripts.Read.All",
|
||||
"DeviceManagementServiceConfig.Read.All",
|
||||
"Group.Read.All",
|
||||
"Organization.Read.All"
|
||||
)
|
||||
|
||||
$importRoles = @(
|
||||
"DeviceManagementApps.ReadWrite.All",
|
||||
"DeviceManagementConfiguration.ReadWrite.All",
|
||||
"DeviceManagementManagedDevices.ReadWrite.All",
|
||||
"DeviceManagementScripts.ReadWrite.All",
|
||||
"DeviceManagementServiceConfig.ReadWrite.All",
|
||||
"Group.ReadWrite.All",
|
||||
"Organization.Read.All"
|
||||
)
|
||||
|
||||
$roles = switch ($PermissionLevel) {
|
||||
"Export" { $exportRoles }
|
||||
"Import" { $importRoles }
|
||||
"Both" { ($exportRoles + $importRoles) | Select-Object -Unique }
|
||||
}
|
||||
|
||||
$resourceAccess = @()
|
||||
foreach ($roleName in $roles) {
|
||||
$appRole = $graphSp.AppRoles | Where-Object { $_.Value -eq $roleName } | Select-Object -First 1
|
||||
if (-not $appRole) {
|
||||
Write-Warning "Could not find app role: $roleName"
|
||||
continue
|
||||
}
|
||||
$resourceAccess += @{
|
||||
id = $appRole.Id
|
||||
type = "Role"
|
||||
}
|
||||
}
|
||||
|
||||
$appParams = @{
|
||||
DisplayName = $DisplayName
|
||||
SignInAudience = "AzureADMyOrg"
|
||||
RequiredResourceAccess = @(@{
|
||||
resourceAppId = "00000003-0000-0000-c000-000000000000"
|
||||
resourceAccess = $resourceAccess
|
||||
})
|
||||
}
|
||||
|
||||
Write-Host "Creating application '$DisplayName'..." -ForegroundColor Cyan
|
||||
$app = New-MgApplication @appParams
|
||||
|
||||
Write-Host "Creating service principal..." -ForegroundColor Cyan
|
||||
$sp = New-MgServicePrincipal -AppId $app.AppId
|
||||
|
||||
Write-Host "Adding client secret..." -ForegroundColor Cyan
|
||||
$passwordCred = @{
|
||||
displayName = "IntuneManagementSecret"
|
||||
endDateTime = (Get-Date).AddYears(1)
|
||||
}
|
||||
$secret = Add-MgApplicationPassword -ApplicationId $app.Id -PasswordCredential $passwordCred
|
||||
|
||||
Write-Host "`n=============================================================" -ForegroundColor Green
|
||||
Write-Host "App Registration created successfully!" -ForegroundColor Green
|
||||
Write-Host "=============================================================" -ForegroundColor Green
|
||||
Write-Host "TenantId : $(Get-MgContext | Select-Object -ExpandProperty TenantId)"
|
||||
Write-Host "AppId : $($app.AppId)"
|
||||
Write-Host "Secret : $($secret.SecretText)"
|
||||
Write-Host "=============================================================" -ForegroundColor Green
|
||||
Write-Host "IMPORTANT: Go to the Entra portal > API Permissions and click" -ForegroundColor Yellow
|
||||
Write-Host " 'Grant admin consent for <tenant>' before using" -ForegroundColor Yellow
|
||||
Write-Host " the app for Export or Import." -ForegroundColor Yellow
|
||||
Write-Host "=============================================================" -ForegroundColor Green
|
||||
|
||||
Disconnect-MgGraph | Out-Null
|
||||
@@ -1,582 +0,0 @@
|
||||
#requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Deploys a declarative Intune baseline from a YAML manifest.
|
||||
.DESCRIPTION
|
||||
Reads a baseline YAML file, creates missing groups, imports policies,
|
||||
applies name mutations, and assigns objects — all in a single command.
|
||||
Ideal for seeding new tenants with OpenIntuneBaseline-style configurations.
|
||||
.EXAMPLE
|
||||
./Scripts/Deploy-IntuneBaseline.ps1 -BaselinePath ./Baselines/mybaseline.yaml -TenantId "contoso.onmicrosoft.com"
|
||||
.EXAMPLE
|
||||
./Scripts/Deploy-IntuneBaseline.ps1 -BaselinePath ./Baselines/mybaseline.yaml -WhatIf
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BaselinePath,
|
||||
|
||||
[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 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")
|
||||
}
|
||||
|
||||
function Resolve-RelativePath
|
||||
{
|
||||
param([string]$Path, [string]$BasePath)
|
||||
if([System.IO.Path]::IsPathRooted($Path)) { return $Path }
|
||||
$baseDir = Split-Path -Parent $BasePath
|
||||
return Join-Path $baseDir $Path
|
||||
}
|
||||
|
||||
function Test-YamlModule
|
||||
{
|
||||
return [bool](Get-Module -ListAvailable -Name powershell-yaml)
|
||||
}
|
||||
|
||||
function Install-YamlModule
|
||||
{
|
||||
Write-Host "powershell-yaml module is required but not installed." -ForegroundColor Yellow
|
||||
if(-not $WhatIf)
|
||||
{
|
||||
$confirm = Read-Host "Install powershell-yaml from PSGallery now? [Y/n]"
|
||||
if($confirm -match "^\s*n")
|
||||
{
|
||||
throw "powershell-yaml is required. Install it with: Install-Module powershell-yaml -Scope CurrentUser -Force"
|
||||
}
|
||||
Install-Module powershell-yaml -Scope CurrentUser -Force
|
||||
Import-Module powershell-yaml -Force
|
||||
}
|
||||
}
|
||||
|
||||
function Get-BaselineTypeMap
|
||||
{
|
||||
return @{
|
||||
"Applications" = @{ API = "/deviceAppManagement/mobileApps"; AssignmentsType = "mobileAppAssignments"; AssignmentODataType = "#microsoft.graph.mobileAppAssignment"; HasIntent = $true; NameProp = "displayName"; SettingsAPI = $null }
|
||||
"DeviceConfiguration" = @{ API = "/deviceManagement/deviceConfigurations"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceConfigurationAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
|
||||
"SettingsCatalog" = @{ API = "/deviceManagement/configurationPolicies"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceManagementConfigurationPolicyAssignment"; HasIntent = $false; NameProp = "name"; SettingsAPI = $null }
|
||||
"CompliancePolicies" = @{ API = "/deviceManagement/deviceCompliancePolicies"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceCompliancePolicyAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
|
||||
"CompliancePoliciesV2" = @{ API = "/deviceManagement/compliancePolicies"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceCompliancePolicyAssignment"; HasIntent = $false; NameProp = "name"; SettingsAPI = $null }
|
||||
"AdministrativeTemplates" = @{ API = "/deviceManagement/groupPolicyConfigurations"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.groupPolicyConfigurationAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
|
||||
"EndpointSecurity" = @{ API = "/deviceManagement/intents"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceManagementIntentAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = "updateSettings" }
|
||||
"DeviceManagementIntents" = @{ API = "/deviceManagement/intents"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceManagementIntentAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = "updateSettings" }
|
||||
"AppProtection" = @{ API = "/deviceAppManagement/managedAppPolicies"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.targetedManagedAppPolicyAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
|
||||
"AppConfigurationManagedDevice" = @{ API = "/deviceAppManagement/mobileAppConfigurations"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.managedDeviceMobileAppConfigurationAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
|
||||
"PlatformScripts" = @{ API = "/deviceManagement/deviceManagementScripts"; AssignmentsType = "deviceManagementScriptAssignments"; AssignmentODataType = "#microsoft.graph.deviceManagementScriptAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
|
||||
"MacScripts" = @{ API = "/deviceManagement/deviceShellScripts"; AssignmentsType = "deviceManagementScriptAssignments"; AssignmentODataType = "#microsoft.graph.deviceManagementScriptAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
|
||||
"DeviceHealthScripts" = @{ API = "/deviceManagement/deviceHealthScripts"; AssignmentsType = "deviceHealthScriptAssignments"; AssignmentODataType = "#microsoft.graph.deviceHealthScriptAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
|
||||
"MacCustomAttributes" = @{ API = "/deviceManagement/deviceCustomAttributeShellScripts"; AssignmentsType = "deviceManagementScriptAssignments"; AssignmentODataType = "#microsoft.graph.deviceManagementScriptAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
|
||||
"EnrollmentRestrictions" = @{ API = "/deviceManagement/deviceEnrollmentConfigurations"; AssignmentsType = "enrollmentConfigurationAssignments"; AssignmentODataType = "#microsoft.graph.enrollmentConfigurationAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
|
||||
"EnrollmentStatusPage" = @{ API = "/deviceManagement/deviceEnrollmentConfigurations"; AssignmentsType = "enrollmentConfigurationAssignments"; AssignmentODataType = "#microsoft.graph.enrollmentConfigurationAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
|
||||
"Autopilot" = @{ API = "/deviceManagement/windowsAutopilotDeploymentProfiles"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.windowsAutopilotDeploymentProfileAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
|
||||
"TermsAndConditions" = @{ API = "/deviceManagement/termsAndConditions"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.termsAndConditionsAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
|
||||
"PolicySets" = @{ API = "/deviceAppManagement/policySets"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.policySetAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
|
||||
"UpdatePolicies" = @{ API = "/deviceManagement/windowsUpdateForBusinessConfigurations"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.windowsUpdateForBusinessConfigurationAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
|
||||
"FeatureUpdates" = @{ API = "/deviceManagement/windowsFeatureUpdateProfiles"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.windowsFeatureUpdateProfileAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
|
||||
"QualityUpdates" = @{ API = "/deviceManagement/windowsQualityUpdateProfiles"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.windowsQualityUpdateProfileAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-SanitizeObject
|
||||
{
|
||||
param($Obj)
|
||||
$propsToRemove = @("id","createdDateTime","lastModifiedDateTime","source","status","version","isAssigned","publishingState")
|
||||
foreach($prop in $propsToRemove)
|
||||
{
|
||||
if($Obj.PSObject.Properties[$prop])
|
||||
{
|
||||
$Obj.PSObject.Properties.Remove($prop)
|
||||
}
|
||||
}
|
||||
return $Obj
|
||||
}
|
||||
|
||||
function Invoke-ApplyMutation
|
||||
{
|
||||
param($Obj, $NameProp, [hashtable]$Mutation)
|
||||
if(-not $Mutation) { return $Obj }
|
||||
|
||||
$search = $Mutation["search"]
|
||||
$replace = $Mutation["replace"]
|
||||
$prefix = $Mutation["prefix"]
|
||||
|
||||
foreach($prop in @($NameProp, "description"))
|
||||
{
|
||||
if($Obj.PSObject.Properties[$prop] -and $Obj.$prop)
|
||||
{
|
||||
$val = $Obj.$prop
|
||||
if($search -and $replace)
|
||||
{
|
||||
$val = $val -replace $search, $replace
|
||||
}
|
||||
elseif($prefix)
|
||||
{
|
||||
if(-not $val.StartsWith($prefix))
|
||||
{
|
||||
$val = "$prefix$val"
|
||||
}
|
||||
}
|
||||
$Obj.$prop = $val
|
||||
}
|
||||
}
|
||||
return $Obj
|
||||
}
|
||||
|
||||
function Get-ExistingObject
|
||||
{
|
||||
param($Api, $NameProp, $NameValue)
|
||||
$escaped = $NameValue -replace "'","''"
|
||||
$filter = "$NameProp eq '$escaped'"
|
||||
$url = "$Api`?`$filter=$filter"
|
||||
try
|
||||
{
|
||||
$resp = Invoke-GraphRequest -Url $url
|
||||
if($resp.value -and $resp.value.Count -gt 0)
|
||||
{
|
||||
return $resp.value[0]
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
# Some APIs don't support $filter; swallow and return null
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function New-CloudOnlyGroup
|
||||
{
|
||||
param([string]$DisplayName, [string]$MailNickname, [bool]$SecurityEnabled = $true)
|
||||
$body = @{
|
||||
displayName = $DisplayName
|
||||
mailEnabled = $false
|
||||
mailNickname = $MailNickname
|
||||
securityEnabled = $SecurityEnabled
|
||||
} | ConvertTo-Json -Depth 5
|
||||
return Invoke-GraphRequest -Url "/groups" -HttpMethod POST -Content $body
|
||||
}
|
||||
|
||||
function Invoke-DeployAssignments
|
||||
{
|
||||
param(
|
||||
[string]$ObjectId,
|
||||
[hashtable]$TypeMeta,
|
||||
[array]$Assignments,
|
||||
[hashtable]$GroupCache,
|
||||
[switch]$WhatIf
|
||||
)
|
||||
if(-not $Assignments -or $Assignments.Count -eq 0) { return }
|
||||
|
||||
$assignmentList = @()
|
||||
foreach($ass in $Assignments)
|
||||
{
|
||||
$targetType = $ass["targetType"]
|
||||
$groupName = $ass["groupName"]
|
||||
$intent = $ass["intent"]
|
||||
|
||||
$odataType = switch($targetType)
|
||||
{
|
||||
"Group" { "#microsoft.graph.groupAssignmentTarget" }
|
||||
"AllUsers" { "#microsoft.graph.allLicensedUsersAssignmentTarget" }
|
||||
"AllDevices" { "#microsoft.graph.allDevicesAssignmentTarget" }
|
||||
"ExcludeGroup" { "#microsoft.graph.exclusionGroupAssignmentTarget" }
|
||||
default { throw "Unknown targetType: $targetType" }
|
||||
}
|
||||
|
||||
$targetPayload = @{
|
||||
"@odata.type" = $odataType
|
||||
}
|
||||
if($targetType -in @("Group","ExcludeGroup"))
|
||||
{
|
||||
if(-not $groupName) { throw "groupName is required for targetType $targetType" }
|
||||
$gid = $GroupCache[$groupName]
|
||||
if(-not $gid) { throw "Group '$groupName' not found in cache" }
|
||||
$targetPayload["groupId"] = $gid
|
||||
}
|
||||
|
||||
$payload = @{
|
||||
"@odata.type" = $TypeMeta.AssignmentODataType
|
||||
target = $targetPayload
|
||||
}
|
||||
if($TypeMeta.HasIntent -and $intent)
|
||||
{
|
||||
$payload["intent"] = $intent.ToString().ToLower()
|
||||
}
|
||||
$assignmentList += $payload
|
||||
}
|
||||
|
||||
if($assignmentList.Count -eq 0) { return }
|
||||
|
||||
$assignBody = @{
|
||||
$TypeMeta.AssignmentsType = $assignmentList
|
||||
} | ConvertTo-Json -Depth 50 -Compress
|
||||
|
||||
$assignUrl = "$($TypeMeta.API)/$ObjectId/assign"
|
||||
if($WhatIf)
|
||||
{
|
||||
Write-Host " [WHATIF] Would assign $($assignmentList.Count) target(s) to $assignUrl" -ForegroundColor Magenta
|
||||
return
|
||||
}
|
||||
$null = Invoke-GraphRequest -Url $assignUrl -HttpMethod POST -Content $assignBody
|
||||
}
|
||||
#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 Dependency check
|
||||
if(-not (Test-YamlModule))
|
||||
{
|
||||
Install-YamlModule
|
||||
}
|
||||
Import-Module powershell-yaml -Force
|
||||
#endregion
|
||||
|
||||
#region Load and validate baseline
|
||||
$baselinePathResolved = Resolve-Path $BaselinePath | Select-Object -ExpandProperty Path
|
||||
if(-not (Test-Path $baselinePathResolved))
|
||||
{
|
||||
throw "Baseline file not found: $BaselinePath"
|
||||
}
|
||||
|
||||
Write-Host "`nLoading baseline: $baselinePathResolved" -ForegroundColor Cyan
|
||||
$yamlText = Get-Content $baselinePathResolved -Raw
|
||||
$yamlRoot = ConvertFrom-Yaml -Yaml $yamlText
|
||||
|
||||
if(-not $yamlRoot -or -not $yamlRoot.ContainsKey("baseline"))
|
||||
{
|
||||
throw "Invalid baseline YAML: missing 'baseline' root node."
|
||||
}
|
||||
$baseline = $yamlRoot["baseline"]
|
||||
|
||||
$conflictResolution = if($baseline.ContainsKey("conflictResolution")) { $baseline["conflictResolution"] } else { "Skip" }
|
||||
$baselineWhatIf = if($baseline.ContainsKey("whatIf")) { [bool]$baseline["whatIf"] } else { $false }
|
||||
$effectiveWhatIf = $WhatIf.IsPresent -or $baselineWhatIf
|
||||
|
||||
$globalMutation = $null
|
||||
if($baseline.ContainsKey("tenantMutation"))
|
||||
{
|
||||
$globalMutation = $baseline["tenantMutation"]
|
||||
}
|
||||
|
||||
Write-Host "Baseline name : $($baseline["name"])" -ForegroundColor Cyan
|
||||
Write-Host "Conflict mode : $conflictResolution" -ForegroundColor Cyan
|
||||
if($effectiveWhatIf) { Write-Host "*** DRY-RUN MODE ENABLED ***" -ForegroundColor Magenta }
|
||||
#endregion
|
||||
|
||||
#region Resolve / create groups
|
||||
$groupCache = @{}
|
||||
if($baseline.ContainsKey("groups") -and $baseline["groups"])
|
||||
{
|
||||
Write-Host "`nResolving groups..." -ForegroundColor Cyan
|
||||
$existingGroupsResp = Invoke-GraphRequest "/groups?`$select=id,displayName&`$orderby=displayName" -AllPages
|
||||
$existingGroups = $existingGroupsResp.value
|
||||
|
||||
foreach($grpDef in $baseline["groups"])
|
||||
{
|
||||
$displayName = $grpDef["displayName"]
|
||||
$existing = $existingGroups | Where-Object { $_.displayName -eq $displayName } | Select-Object -First 1
|
||||
if($existing)
|
||||
{
|
||||
Write-Host " Group exists: $displayName ($($existing.id))" -ForegroundColor Green
|
||||
$groupCache[$displayName] = $existing.id
|
||||
}
|
||||
else
|
||||
{
|
||||
$mailNick = $grpDef["mailNickname"]
|
||||
$secEnabled = if($grpDef.ContainsKey("securityEnabled")) { [bool]$grpDef["securityEnabled"] } else { $true }
|
||||
if($effectiveWhatIf)
|
||||
{
|
||||
Write-Host " [WHATIF] Would create group: $displayName" -ForegroundColor Magenta
|
||||
$groupCache[$displayName] = "WHATIF-$displayName"
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Host " Creating group: $displayName" -ForegroundColor Yellow
|
||||
$newGrp = New-CloudOnlyGroup -DisplayName $displayName -MailNickname $mailNick -SecurityEnabled $secEnabled
|
||||
$groupCache[$displayName] = $newGrp.id
|
||||
Write-Host " Created: $($newGrp.id)" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Pre-load all existing groups for assignment resolution
|
||||
Write-Host "`nPre-loading group directory..." -ForegroundColor Cyan
|
||||
$allGroupsResp = Invoke-GraphRequest "/groups?`$select=id,displayName&`$orderby=displayName" -AllPages
|
||||
foreach($g in $allGroupsResp.value)
|
||||
{
|
||||
if(-not $groupCache.ContainsKey($g.displayName))
|
||||
{
|
||||
$groupCache[$g.displayName] = $g.id
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Process policies
|
||||
$typeMap = Get-BaselineTypeMap
|
||||
$stats = @{
|
||||
Created = 0
|
||||
Updated = 0
|
||||
Skipped = 0
|
||||
Failed = 0
|
||||
Assigned = 0
|
||||
}
|
||||
|
||||
if($baseline.ContainsKey("policies") -and $baseline["policies"])
|
||||
{
|
||||
$policies = $baseline["policies"]
|
||||
Write-Host "`nDeploying $($policies.Count) policy(ies)..." -ForegroundColor Cyan
|
||||
|
||||
foreach($policyDef in $policies)
|
||||
{
|
||||
$sourcePath = Resolve-RelativePath -Path $policyDef["sourcePath"] -BasePath $baselinePathResolved
|
||||
$typeName = $policyDef["type"]
|
||||
|
||||
if(-not $typeMap.ContainsKey($typeName))
|
||||
{
|
||||
Write-Warning "Unknown policy type '$typeName'. Skipping."
|
||||
$stats.Failed++
|
||||
continue
|
||||
}
|
||||
$typeMeta = $typeMap[$typeName]
|
||||
|
||||
if(-not (Test-Path $sourcePath))
|
||||
{
|
||||
Write-Warning "Policy file not found: $sourcePath"
|
||||
$stats.Failed++
|
||||
continue
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$jsonRaw = Get-Content $sourcePath -Raw
|
||||
$policyObj = $jsonRaw | ConvertFrom-Json -Depth 100
|
||||
|
||||
# Sanitize
|
||||
$policyObj = Invoke-SanitizeObject -Obj $policyObj
|
||||
|
||||
# Mutate
|
||||
$mutation = $globalMutation
|
||||
if($policyDef.ContainsKey("mutation"))
|
||||
{
|
||||
$mutation = $policyDef["mutation"]
|
||||
}
|
||||
$policyObj = Invoke-ApplyMutation -Obj $policyObj -NameProp $typeMeta.NameProp -Mutation $mutation
|
||||
$mutatedName = $policyObj.($typeMeta.NameProp)
|
||||
|
||||
Write-Host "`nPolicy: $mutatedName [$typeName]" -ForegroundColor Cyan
|
||||
|
||||
# Idempotency check
|
||||
$existingObj = Get-ExistingObject -Api $typeMeta.API -NameProp $typeMeta.NameProp -NameValue $mutatedName
|
||||
$objectId = $null
|
||||
$shouldAssign = $false
|
||||
|
||||
if($existingObj)
|
||||
{
|
||||
Write-Host " Existing object found: $($existingObj.id)" -ForegroundColor Yellow
|
||||
if($conflictResolution -eq "Error")
|
||||
{
|
||||
throw "Conflict: object '$mutatedName' already exists and conflictResolution is Error."
|
||||
}
|
||||
elseif($conflictResolution -eq "Skip")
|
||||
{
|
||||
Write-Host " Skipping import (Skip mode)." -ForegroundColor Yellow
|
||||
$objectId = $existingObj.id
|
||||
$shouldAssign = $true # still apply assignments to existing object
|
||||
$stats.Skipped++
|
||||
}
|
||||
elseif($conflictResolution -eq "Update")
|
||||
{
|
||||
if($effectiveWhatIf)
|
||||
{
|
||||
Write-Host " [WHATIF] Would PATCH existing object $($existingObj.id)" -ForegroundColor Magenta
|
||||
}
|
||||
else
|
||||
{
|
||||
$patchBody = $policyObj | Select-Object * | ConvertTo-Json -Depth 50
|
||||
$null = Invoke-GraphRequest -Url "$($typeMeta.API)/$($existingObj.id)" -HttpMethod PATCH -Content $patchBody
|
||||
Write-Host " Updated existing object." -ForegroundColor Green
|
||||
}
|
||||
$objectId = $existingObj.id
|
||||
$shouldAssign = $true
|
||||
$stats.Updated++
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if($effectiveWhatIf)
|
||||
{
|
||||
Write-Host " [WHATIF] Would POST new object to $($typeMeta.API)" -ForegroundColor Magenta
|
||||
$objectId = "WHATIF-NEW"
|
||||
$shouldAssign = $true
|
||||
$stats.Created++
|
||||
}
|
||||
else
|
||||
{
|
||||
$postBody = $policyObj | ConvertTo-Json -Depth 50
|
||||
$newObj = Invoke-GraphRequest -Url $typeMeta.API -HttpMethod POST -Content $postBody
|
||||
$objectId = $newObj.id
|
||||
Write-Host " Created: $objectId" -ForegroundColor Green
|
||||
$shouldAssign = $true
|
||||
$stats.Created++
|
||||
|
||||
# Secondary settings upload (EndpointSecurity / DeviceManagementIntents)
|
||||
if($typeMeta.SettingsAPI)
|
||||
{
|
||||
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($sourcePath)
|
||||
$settingsPathCandidate = Join-Path (Split-Path -Parent $sourcePath) "$baseName`_Settings.json"
|
||||
if(Test-Path $settingsPathCandidate)
|
||||
{
|
||||
$settingsRaw = Get-Content $settingsPathCandidate -Raw
|
||||
$settingsJson = $settingsRaw | ConvertFrom-Json
|
||||
# The toolkit exports settings as { "settings": [...] }
|
||||
$settingsBody = $settingsJson | ConvertTo-Json -Depth 50
|
||||
$settingsUrl = "$($typeMeta.API)/$objectId/$($typeMeta.SettingsAPI)"
|
||||
Write-Host " Uploading settings from $settingsPathCandidate" -ForegroundColor Cyan
|
||||
$null = Invoke-GraphRequest -Url $settingsUrl -HttpMethod POST -Content $settingsBody
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Assignments
|
||||
if($shouldAssign -and $policyDef.ContainsKey("assignments"))
|
||||
{
|
||||
Invoke-DeployAssignments -ObjectId $objectId -TypeMeta $typeMeta -Assignments $policyDef["assignments"] -GroupCache $groupCache -WhatIf:$effectiveWhatIf
|
||||
$stats.Assigned++
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Warning "Failed to deploy policy '$sourcePath': $_"
|
||||
$stats.Failed++
|
||||
}
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Summary
|
||||
Write-Host "`n========================================" -ForegroundColor Cyan
|
||||
Write-Host "Baseline deployment summary" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host "Created : $($stats.Created)"
|
||||
Write-Host "Updated : $($stats.Updated)"
|
||||
Write-Host "Skipped : $($stats.Skipped)"
|
||||
Write-Host "Assigned: $($stats.Assigned)"
|
||||
Write-Host "Failed : $($stats.Failed)"
|
||||
if($effectiveWhatIf)
|
||||
{
|
||||
Write-Host "`n*** This was a dry-run (WhatIf). No changes were made. ***" -ForegroundColor Magenta
|
||||
}
|
||||
#endregion
|
||||
@@ -1,368 +0,0 @@
|
||||
#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
|
||||
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&`$orderby=displayName" -AllPages
|
||||
$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
|
||||
@@ -1,44 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Headless Intune policy export wrapper for macOS/Linux/Windows.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TenantId,
|
||||
|
||||
[string]$AppId,
|
||||
|
||||
[string]$Secret,
|
||||
|
||||
[string]$Certificate,
|
||||
|
||||
[ValidateSet("AppOnly","Browser","DeviceCode")]
|
||||
[string]$AuthMode = "AppOnly",
|
||||
|
||||
[string]$RedirectUri,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ExportPath,
|
||||
|
||||
[string]$SettingsFile,
|
||||
|
||||
[string]$BatchFile,
|
||||
|
||||
[string]$NameFilter = "",
|
||||
|
||||
[string]$NameSearchPattern = "",
|
||||
|
||||
[string]$NameReplacePattern = "",
|
||||
|
||||
[string[]]$ObjectTypes = (Get-DefaultIntunePolicyObjectTypes),
|
||||
|
||||
[switch]$IncludeAssignments,
|
||||
|
||||
[switch]$AddCompanyName
|
||||
)
|
||||
|
||||
$modulePath = Join-Path (Split-Path -Parent $PSScriptRoot) "Headless/IntuneManagement.Headless.psd1"
|
||||
Import-Module $modulePath -Force
|
||||
|
||||
Export-IntunePolicies @PSBoundParameters
|
||||
@@ -1,49 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Headless Intune policy import wrapper for macOS/Linux/Windows.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TenantId,
|
||||
|
||||
[string]$AppId,
|
||||
|
||||
[string]$Secret,
|
||||
|
||||
[string]$Certificate,
|
||||
|
||||
[ValidateSet("AppOnly","Browser","DeviceCode")]
|
||||
[string]$AuthMode = "AppOnly",
|
||||
|
||||
[string]$RedirectUri,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ImportPath,
|
||||
|
||||
[string]$SettingsFile,
|
||||
|
||||
[string]$BatchFile,
|
||||
|
||||
[string]$NameFilter = "",
|
||||
|
||||
[string]$NameSearchPattern = "",
|
||||
|
||||
[string]$NameReplacePattern = "",
|
||||
|
||||
[ValidateSet("alwaysImport","skipIfExist","replace","replace_with_assignments","update")]
|
||||
[string]$ImportType = "alwaysImport",
|
||||
|
||||
[string[]]$ObjectTypes = (Get-DefaultIntunePolicyObjectTypes),
|
||||
|
||||
[switch]$IncludeAssignments,
|
||||
|
||||
[switch]$IncludeScopeTags,
|
||||
|
||||
[switch]$ReplaceDependencyIds
|
||||
)
|
||||
|
||||
$modulePath = Join-Path (Split-Path -Parent $PSScriptRoot) "Headless/IntuneManagement.Headless.psd1"
|
||||
Import-Module $modulePath -Force
|
||||
|
||||
Import-IntunePolicies @PSBoundParameters
|
||||
@@ -1,333 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
One-time setup helper for IntuneManagement headless authentication.
|
||||
|
||||
.DESCRIPTION
|
||||
Creates a Microsoft Entra app registration (or reuses an existing one),
|
||||
adds required Microsoft Graph permissions, creates a client secret, and
|
||||
stores credentials securely:
|
||||
- TenantId and AppId are saved to the JSON settings file.
|
||||
- Client secret is saved to the macOS Keychain (default on macOS).
|
||||
|
||||
Requires: Microsoft.Graph.Authentication, Microsoft.Graph.Applications
|
||||
Install if missing: Install-Module Microsoft.Graph -Scope CurrentUser
|
||||
|
||||
.PARAMETER TenantId
|
||||
The Microsoft Entra tenant ID (GUID). If omitted, the script reads from
|
||||
existing settings or prompts interactively.
|
||||
|
||||
.PARAMETER DisplayName
|
||||
The display name for the app registration. Default: IntuneManagement-Headless.
|
||||
|
||||
.PARAMETER SettingsFile
|
||||
Path to the JSON settings file. If omitted, defaults to the macOS_IntuneManagement
|
||||
settings folder (~/Library/Application Support/macOS_IntuneManagement/Settings.json).
|
||||
|
||||
.PARAMETER Force
|
||||
Recreate the app registration and secret even if existing credentials are found.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$TenantId,
|
||||
|
||||
[string]$DisplayName = "IntuneManagement-Headless",
|
||||
|
||||
[string]$SettingsFile,
|
||||
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
#region Helper: settings file
|
||||
$coreModule = Join-Path (Split-Path -Parent $PSScriptRoot) "Core.psm1"
|
||||
if (-not (Test-Path $coreModule))
|
||||
{
|
||||
throw "Could not find Core.psm1 at $coreModule"
|
||||
}
|
||||
Import-Module $coreModule -Force -Global
|
||||
|
||||
if (-not $SettingsFile)
|
||||
{
|
||||
$dataFolder = Get-CloudApiDataFolder
|
||||
$SettingsFile = Join-Path $dataFolder "Settings.json"
|
||||
}
|
||||
|
||||
$global:JSonSettingFile = $SettingsFile
|
||||
Initialize-JsonSettings
|
||||
|
||||
function Save-AuthSetting
|
||||
{
|
||||
param($Key, $Value, [string]$SubPath = "")
|
||||
Save-Setting -SubPath $SubPath -Key $Key -Value $Value
|
||||
}
|
||||
|
||||
function Get-AuthSetting
|
||||
{
|
||||
param($Key, [string]$SubPath = "", $DefaultValue = $null)
|
||||
Get-Setting -SubPath $SubPath -Key $Key -DefaultValue $DefaultValue
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Determine TenantId
|
||||
if (-not $TenantId)
|
||||
{
|
||||
$TenantId = Get-AuthSetting -Key "TenantId"
|
||||
if (-not $TenantId)
|
||||
{
|
||||
$TenantId = Read-Host "Enter your Microsoft Entra Tenant ID (GUID)"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $TenantId)
|
||||
{
|
||||
throw "TenantId is required."
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Check for existing credentials
|
||||
$existingAppId = Get-AuthSetting -SubPath $TenantId -Key "GraphAzureAppId"
|
||||
if ($existingAppId -and -not $Force)
|
||||
{
|
||||
$hasSecret = $false
|
||||
if ($IsMacOS)
|
||||
{
|
||||
try
|
||||
{
|
||||
$keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$existingAppId" -w 2>$null
|
||||
if ($keychainSecret) { $hasSecret = $true }
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
else
|
||||
{
|
||||
$plainSecret = Get-AuthSetting -SubPath $TenantId -Key "GraphAzureAppSecret"
|
||||
if ($plainSecret) { $hasSecret = $true }
|
||||
}
|
||||
|
||||
if ($hasSecret)
|
||||
{
|
||||
Write-Host ""
|
||||
Write-Host "Existing credentials already configured for tenant $TenantId (AppId: $existingAppId)." -ForegroundColor Green
|
||||
Write-Host "Use -Force to recreate the app registration and secret." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
return
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Microsoft Graph modules
|
||||
$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
|
||||
#endregion
|
||||
|
||||
#region Connect to Graph
|
||||
Write-Host ""
|
||||
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
|
||||
#endregion
|
||||
|
||||
#region App registration
|
||||
$graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
|
||||
if (-not $graphSp)
|
||||
{
|
||||
throw "Could not retrieve Microsoft Graph service principal."
|
||||
}
|
||||
|
||||
$requiredRoles = @(
|
||||
"DeviceManagementApps.ReadWrite.All",
|
||||
"DeviceManagementConfiguration.ReadWrite.All",
|
||||
"DeviceManagementManagedDevices.ReadWrite.All",
|
||||
"DeviceManagementScripts.ReadWrite.All",
|
||||
"DeviceManagementServiceConfig.ReadWrite.All",
|
||||
"DeviceManagementRBAC.ReadWrite.All",
|
||||
"Group.ReadWrite.All",
|
||||
"Directory.Read.All",
|
||||
"User.Read.All",
|
||||
"Organization.Read.All",
|
||||
"Policy.ReadWrite.ConditionalAccess",
|
||||
"Agreement.ReadWrite.All",
|
||||
"CloudPC.ReadWrite.All",
|
||||
"Application.Read.All"
|
||||
)
|
||||
|
||||
$resourceAccess = @()
|
||||
foreach ($roleName in $requiredRoles)
|
||||
{
|
||||
$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" }
|
||||
}
|
||||
|
||||
$app = $null
|
||||
$updatedPermissions = $false
|
||||
if (-not $Force)
|
||||
{
|
||||
$existingApps = Get-MgApplication -Filter "displayName eq '$DisplayName'" -All
|
||||
if ($existingApps)
|
||||
{
|
||||
$app = $existingApps | Select-Object -First 1
|
||||
Write-Host "Reusing existing app registration: $($app.DisplayName) ($($app.AppId))" -ForegroundColor Yellow
|
||||
|
||||
# Check for missing permissions and patch if needed
|
||||
$existingRra = $app.RequiredResourceAccess | Where-Object { $_.resourceAppId -eq "00000003-0000-0000-c000-000000000000" }
|
||||
$existingIds = @()
|
||||
if($existingRra -and $existingRra.resourceAccess)
|
||||
{
|
||||
$existingIds = $existingRra.resourceAccess | Select-Object -ExpandProperty id
|
||||
}
|
||||
|
||||
$missingAccess = $resourceAccess | Where-Object { $_.id -notin $existingIds }
|
||||
if($missingAccess)
|
||||
{
|
||||
Write-Host "Adding missing Graph API permissions to existing app..." -ForegroundColor Cyan
|
||||
$newRra = @(@{
|
||||
resourceAppId = "00000003-0000-0000-c000-000000000000"
|
||||
resourceAccess = @($existingIds | ForEach-Object { @{ id = $_; type = "Role" } }) + $missingAccess
|
||||
})
|
||||
Update-MgApplication -ApplicationId $app.Id -RequiredResourceAccess $newRra
|
||||
$updatedPermissions = $true
|
||||
# Refresh app object
|
||||
$app = Get-MgApplication -ApplicationId $app.Id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $app)
|
||||
{
|
||||
Write-Host "Creating new app registration: $DisplayName" -ForegroundColor Cyan
|
||||
|
||||
$appParams = @{
|
||||
DisplayName = $DisplayName
|
||||
SignInAudience = "AzureADMyOrg"
|
||||
RequiredResourceAccess = @(@{
|
||||
resourceAppId = "00000003-0000-0000-c000-000000000000"
|
||||
resourceAccess = $resourceAccess
|
||||
})
|
||||
}
|
||||
|
||||
$app = New-MgApplication @appParams
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Service Principal & Admin Consent
|
||||
$sp = Get-MgServicePrincipal -Filter "appId eq '$($app.AppId)'" -ErrorAction SilentlyContinue
|
||||
if (-not $sp)
|
||||
{
|
||||
Write-Host "Creating service principal for the app..." -ForegroundColor Cyan
|
||||
$sp = New-MgServicePrincipal -AppId $app.AppId
|
||||
}
|
||||
|
||||
$consentGranted = $false
|
||||
if ($sp)
|
||||
{
|
||||
Write-Host "Granting admin consent for Microsoft Graph permissions..." -ForegroundColor Cyan
|
||||
$requiredAppRoles = $app.RequiredResourceAccess[0].resourceAccess
|
||||
foreach ($ra in $requiredAppRoles)
|
||||
{
|
||||
$existing = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id |
|
||||
Where-Object { $_.AppRoleId -eq $ra.id }
|
||||
if (-not $existing)
|
||||
{
|
||||
try
|
||||
{
|
||||
New-MgServicePrincipalAppRoleAssignment `
|
||||
-ServicePrincipalId $sp.Id `
|
||||
-PrincipalId $sp.Id `
|
||||
-ResourceId $graphSp.Id `
|
||||
-AppRoleId $ra.id | Out-Null
|
||||
$consentGranted = $true
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Warning "Failed to grant consent for role $($ra.id): $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
$consentGranted = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Client Secret
|
||||
Write-Host "Creating client secret..." -ForegroundColor Cyan
|
||||
$passwordCred = @{
|
||||
displayName = "IntuneManagementSecret"
|
||||
endDateTime = (Get-Date).AddYears(1)
|
||||
}
|
||||
$secret = Add-MgApplicationPassword -ApplicationId $app.Id -PasswordCredential $passwordCred
|
||||
#endregion
|
||||
|
||||
#region Save settings
|
||||
Write-Host "Saving settings to $SettingsFile ..." -ForegroundColor Cyan
|
||||
Save-AuthSetting -SubPath $TenantId -Key "GraphAzureAppId" -Value $app.AppId
|
||||
Save-AuthSetting -SubPath $TenantId -Key "GraphAzureAppLogin" -Value $true
|
||||
Save-AuthSetting -Key "TenantId" -Value $TenantId
|
||||
|
||||
if ($IsMacOS)
|
||||
{
|
||||
Write-Host "Storing client secret in macOS Keychain..." -ForegroundColor Cyan
|
||||
$null = security add-generic-password -a "IntuneManagement" -s "IntuneMgmt-$($app.AppId)" -w "$($secret.SecretText)" -U 2>$null
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Warning "Not running on macOS. Storing client secret in the settings file (less secure)."
|
||||
Save-AuthSetting -SubPath $TenantId -Key "GraphAzureAppSecret" -Value $secret.SecretText
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Summary
|
||||
Write-Host ""
|
||||
Write-Host "=============================================================" -ForegroundColor Green
|
||||
Write-Host "Authentication setup complete!" -ForegroundColor Green
|
||||
Write-Host "=============================================================" -ForegroundColor Green
|
||||
Write-Host "TenantId : $TenantId"
|
||||
Write-Host "AppId : $($app.AppId)"
|
||||
Write-Host "Settings : $SettingsFile"
|
||||
if ($IsMacOS)
|
||||
{
|
||||
Write-Host "Secret : <stored in macOS Keychain>"
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Host "Secret : $($secret.SecretText)"
|
||||
}
|
||||
Write-Host "=============================================================" -ForegroundColor Green
|
||||
|
||||
if (-not $consentGranted)
|
||||
{
|
||||
Write-Host "IMPORTANT: Admin consent could not be granted automatically." -ForegroundColor Yellow
|
||||
Write-Host " Go to the Entra portal > API Permissions and click" -ForegroundColor Yellow
|
||||
Write-Host " 'Grant admin consent for <tenant>' before using" -ForegroundColor Yellow
|
||||
Write-Host " the app for Export or Import." -ForegroundColor Yellow
|
||||
Write-Host "=============================================================" -ForegroundColor Green
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Host "Admin consent granted successfully." -ForegroundColor Green
|
||||
Write-Host "=============================================================" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "You can now run exports without specifying -AppId or -Secret:" -ForegroundColor Cyan
|
||||
Write-Host " ./Scripts/Export-Policies.ps1 -TenantId `"$TenantId`" -ExportPath `"/tmp/intune-export`" -IncludeAssignments" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
Disconnect-MgGraph | Out-Null
|
||||
#endregion
|
||||
@@ -1,267 +0,0 @@
|
||||
#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-FzfHint
|
||||
{
|
||||
if(Test-FzfAvailable) { return }
|
||||
Write-Host "[fzf not found]" -ForegroundColor Yellow -NoNewline
|
||||
Write-Host " Install fzf for the best interactive menu experience. Falling back to numbered menus." -ForegroundColor DarkGray
|
||||
if($IsMacOS)
|
||||
{
|
||||
Write-Host " Install: brew install fzf" -ForegroundColor DarkGray
|
||||
}
|
||||
elseif($IsLinux)
|
||||
{
|
||||
Write-Host " Install: sudo apt install fzf (or dnf/pacman)" -ForegroundColor DarkGray
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Host " Install: winget install junegunn.fzf (or choco install fzf)" -ForegroundColor DarkGray
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
function Show-FzfMenu
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string[]]$Items,
|
||||
[string]$Header = "Select one",
|
||||
[switch]$Multi
|
||||
)
|
||||
$argsList = @("--header=$Header")
|
||||
if($Multi) { $argsList += "--multi" }
|
||||
$selected = $Items | fzf @argsList
|
||||
if(-not $selected) { return $null }
|
||||
if($Multi) { return @($selected -split "`r?`n" | Where-Object { $_ }) }
|
||||
return $selected
|
||||
}
|
||||
|
||||
function Show-NumberedMenu
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string[]]$Items,
|
||||
[string]$Header = "Select one or more",
|
||||
[switch]$Multi
|
||||
)
|
||||
Write-Host "`n$Header" -ForegroundColor Cyan
|
||||
for($i=0; $i -lt $Items.Count; $i++)
|
||||
{
|
||||
Write-Host " $($i+1). $($Items[$i])"
|
||||
}
|
||||
if($Multi)
|
||||
{
|
||||
$prompt = "Enter numbers separated by commas (e.g. 1,3,5) or 'all'"
|
||||
}
|
||||
else
|
||||
{
|
||||
$prompt = "Enter a number"
|
||||
}
|
||||
$choice = Read-Host $prompt
|
||||
if($choice -eq "all" -and $Multi) { return $Items }
|
||||
$indices = $choice -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ -match "^\d+$" } | ForEach-Object { [int]$_ - 1 } | Where-Object { $_ -ge 0 -and $_ -lt $Items.Count }
|
||||
if($Multi)
|
||||
{
|
||||
return $Items[$indices] | Select-Object -Unique
|
||||
}
|
||||
else
|
||||
{
|
||||
if($indices.Count -eq 0) { return $null }
|
||||
return $Items[$indices[0]]
|
||||
}
|
||||
}
|
||||
|
||||
function Select-MenuItem
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string[]]$Items,
|
||||
[string]$Header = "Select one",
|
||||
[switch]$Multi
|
||||
)
|
||||
if(Test-FzfAvailable)
|
||||
{
|
||||
return Show-FzfMenu -Items $Items -Header $Header -Multi:$Multi
|
||||
}
|
||||
return Show-NumberedMenu -Items $Items -Header $Header -Multi:$Multi
|
||||
}
|
||||
|
||||
function Read-YesNo
|
||||
{
|
||||
param(
|
||||
[string]$Prompt,
|
||||
[bool]$Default = $false
|
||||
)
|
||||
$defaultChar = if($Default) { "Y" } else { "N" }
|
||||
$response = Read-Host "$Prompt [Y/n] (default: $defaultChar)"
|
||||
if([string]::IsNullOrWhiteSpace($response)) { return $Default }
|
||||
return $response -match "^\s*y"
|
||||
}
|
||||
|
||||
function Get-DefaultSettingsPath
|
||||
{
|
||||
if($IsWindows -or $env:OS -eq "Windows_NT")
|
||||
{
|
||||
if($env:LOCALAPPDATA) { return (Join-Path $env:LOCALAPPDATA "macOS_IntuneManagement\Settings.json") }
|
||||
return (Join-Path $env:USERPROFILE "AppData\Local\macOS_IntuneManagement\Settings.json")
|
||||
}
|
||||
if($IsMacOS) { return (Join-Path $HOME "Library/Application Support/macOS_IntuneManagement/Settings.json") }
|
||||
return (Join-Path $HOME ".local/share/macOS_IntuneManagement/Settings.json")
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region 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
|
||||
|
||||
Show-FzfHint
|
||||
|
||||
while($true)
|
||||
{
|
||||
Clear-Host
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " IntuneManagement Terminal UI" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Press Esc to go back, Space to select" -ForegroundColor DarkGray
|
||||
|
||||
# 1. Action
|
||||
$action = Select-MenuItem -Items @("Export","Import") -Header "Select action"
|
||||
if(-not $action) { continue }
|
||||
|
||||
# 2. TenantId
|
||||
$tenantPrompt = "Enter Tenant ID"
|
||||
if($preloadedTenantId) { $tenantPrompt += " (default: $preloadedTenantId)" }
|
||||
$tenantId = Read-Host $tenantPrompt
|
||||
if([string]::IsNullOrWhiteSpace($tenantId)) { $tenantId = $preloadedTenantId }
|
||||
if([string]::IsNullOrWhiteSpace($tenantId)) { Write-Host "Tenant ID is required." -ForegroundColor Red; continue }
|
||||
|
||||
# 3. Object Types
|
||||
Write-Host "`nObject type selection..." -ForegroundColor Cyan
|
||||
$typeSelection = Select-MenuItem -Items $defaultTypes -Header "Select object types to include (Space to multi-select)" -Multi
|
||||
if(-not $typeSelection) { continue }
|
||||
|
||||
# 4. Path
|
||||
$pathPrompt = if($action -eq "Export") { "Enter export root folder path" } else { "Enter import root folder path" }
|
||||
$path = Read-Host $pathPrompt
|
||||
if([string]::IsNullOrWhiteSpace($path)) { Write-Host "Path is required." -ForegroundColor Red; return $null }
|
||||
|
||||
# 5. Name Filter
|
||||
$nameFilter = Read-Host "Name filter regex (optional, e.g. '^Win-OIB-')"
|
||||
|
||||
# 6. Name Mutation
|
||||
$nameSearchPattern = Read-Host "Name search regex for mutation (optional, e.g. '^Win-OIB-')"
|
||||
$nameReplacePattern =
|
||||
if(-not [string]::IsNullOrWhiteSpace($nameSearchPattern))
|
||||
{
|
||||
$nameReplacePattern = Read-Host "Replacement string (e.g. 'Win-TEST-')"
|
||||
}
|
||||
|
||||
# 7. Import-specific options
|
||||
$importType = $null
|
||||
$includeScopeTags = $false
|
||||
$replaceDependencyIds = $false
|
||||
if($action -eq "Import")
|
||||
{
|
||||
$importType = Select-MenuItem -Items @("alwaysImport","skipIfExist","replace","replace_with_assignments","update") -Header "Select import behavior"
|
||||
if(-not $importType) { $importType = "alwaysImport" }
|
||||
$includeScopeTags = Read-YesNo -Prompt "Import scope tags?" -Default $false
|
||||
$replaceDependencyIds = Read-YesNo -Prompt "Replace dependency IDs?" -Default $false
|
||||
}
|
||||
|
||||
# 8. Common toggles
|
||||
$includeAssignments = Read-YesNo -Prompt "Include assignments?" -Default $false
|
||||
$addCompanyName = $false
|
||||
if($action -eq "Export")
|
||||
{
|
||||
$addCompanyName = Read-YesNo -Prompt "Add company name to folders?" -Default $false
|
||||
}
|
||||
|
||||
# 9. Review
|
||||
Clear-Host
|
||||
Write-Host "Review your selection:" -ForegroundColor Green
|
||||
Write-Host " Action : $action"
|
||||
Write-Host " TenantId : $tenantId"
|
||||
Write-Host " Object Types : $($typeSelection -join ', ')"
|
||||
if($action -eq "Export")
|
||||
{
|
||||
Write-Host " Export Path : $path"
|
||||
Write-Host " Add Company Name : $addCompanyName"
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Host " Import Path : $path"
|
||||
Write-Host " Import Type : $importType"
|
||||
Write-Host " Include Scope Tags : $includeScopeTags"
|
||||
Write-Host " Replace Dep IDs : $replaceDependencyIds"
|
||||
}
|
||||
Write-Host " Name Filter : $(if($nameFilter){$nameFilter}else{'(none)'})"
|
||||
Write-Host " Name Search Pattern : $(if($nameSearchPattern){$nameSearchPattern}else{'(none)'})"
|
||||
Write-Host " Name Replace Pattern: $(if($nameReplacePattern){$nameReplacePattern}else{'(none)'})"
|
||||
Write-Host " Include Assignments : $includeAssignments"
|
||||
|
||||
$confirm = Read-Host "`nProceed? [Y/n] (or type 'back' to restart)"
|
||||
if($confirm -eq "back") { continue }
|
||||
if(-not ([string]::IsNullOrWhiteSpace($confirm) -or $confirm -match "^\s*y"))
|
||||
{
|
||||
Write-Host "Cancelled." -ForegroundColor Yellow
|
||||
continue
|
||||
}
|
||||
|
||||
# 10. Build result
|
||||
$result = [PSCustomObject]@{
|
||||
Action = $action
|
||||
TenantId = $tenantId
|
||||
ObjectTypes = $typeSelection
|
||||
NameFilter = $nameFilter
|
||||
NameSearchPattern = $nameSearchPattern
|
||||
NameReplacePattern = $nameReplacePattern
|
||||
IncludeAssignments = $includeAssignments
|
||||
}
|
||||
|
||||
if($action -eq "Export")
|
||||
{
|
||||
$result | Add-Member -NotePropertyName ExportPath -NotePropertyValue $path
|
||||
$result | Add-Member -NotePropertyName AddCompanyName -NotePropertyValue $addCompanyName
|
||||
}
|
||||
else
|
||||
{
|
||||
$result | Add-Member -NotePropertyName ImportPath -NotePropertyValue $path
|
||||
$result | Add-Member -NotePropertyName ImportType -NotePropertyValue $importType
|
||||
$result | Add-Member -NotePropertyName IncludeScopeTags -NotePropertyValue $includeScopeTags
|
||||
$result | Add-Member -NotePropertyName ReplaceDependencyIds -NotePropertyValue $replaceDependencyIds
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Unified launcher for the macOS Intune Toolkit.
|
||||
.DESCRIPTION
|
||||
Presents a single terminal UI to choose from all available
|
||||
headless Intune management tools. Passes through common auth parameters.
|
||||
Press Esc to go back to the menu from any selection.
|
||||
.EXAMPLE
|
||||
./Scripts/Start-IntuneToolkit.ps1 -TenantId "contoso.onmicrosoft.com"
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[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-FzfHint
|
||||
{
|
||||
if(Test-FzfAvailable) { return }
|
||||
Write-Host "`n[fzf not found]" -ForegroundColor Yellow -NoNewline
|
||||
Write-Host " Install fzf for the best interactive menu experience.`n" -ForegroundColor DarkGray
|
||||
if($IsMacOS)
|
||||
{
|
||||
Write-Host " macOS: brew install fzf" -ForegroundColor DarkGray
|
||||
}
|
||||
elseif($IsLinux)
|
||||
{
|
||||
Write-Host " Debian/Ubuntu: sudo apt install fzf" -ForegroundColor DarkGray
|
||||
Write-Host " Fedora: sudo dnf install fzf" -ForegroundColor DarkGray
|
||||
Write-Host " Arch: sudo pacman -S fzf" -ForegroundColor DarkGray
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Host " Windows: winget install junegunn.fzf" -ForegroundColor DarkGray
|
||||
Write-Host " choco install fzf" -ForegroundColor DarkGray
|
||||
}
|
||||
Write-Host " (Falling back to numbered menus for now.)`n" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
function Show-FzfMenu
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string[]]$Items,
|
||||
[string]$Header = "Select one"
|
||||
)
|
||||
$selected = $Items | fzf --header=$Header
|
||||
if(-not $selected) { return $null }
|
||||
return $selected
|
||||
}
|
||||
|
||||
function Show-NumberedMenu
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string[]]$Items,
|
||||
[string]$Header = "Select one"
|
||||
)
|
||||
Write-Host "`n$Header" -ForegroundColor Cyan
|
||||
for($i=0; $i -lt $Items.Count; $i++)
|
||||
{
|
||||
Write-Host " $($i+1). $($Items[$i])"
|
||||
}
|
||||
$choice = Read-Host "Enter a number (0 to exit)"
|
||||
if($choice -eq "0") { return "EXIT" }
|
||||
$index = [int]$choice - 1
|
||||
if($index -ge 0 -and $index -lt $Items.Count)
|
||||
{
|
||||
return $Items[$index]
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Select-MenuItem
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string[]]$Items,
|
||||
[string]$Header = "Select one"
|
||||
)
|
||||
if(Test-FzfAvailable)
|
||||
{
|
||||
return Show-FzfMenu -Items $Items -Header $Header
|
||||
}
|
||||
return Show-NumberedMenu -Items $Items -Header $Header
|
||||
}
|
||||
#endregion
|
||||
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
|
||||
#region Tenant selection
|
||||
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")
|
||||
}
|
||||
|
||||
function Get-SavedTenants
|
||||
{
|
||||
param([string]$SettingsPath)
|
||||
if(-not (Test-Path $SettingsPath)) { return @() }
|
||||
try
|
||||
{
|
||||
$raw = Get-Content $SettingsPath -Raw -ErrorAction Stop | ConvertFrom-Json -AsHashtable -ErrorAction Stop
|
||||
$tenants = @()
|
||||
foreach($key in $raw.Keys)
|
||||
{
|
||||
if($key -match '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')
|
||||
{
|
||||
$name = $null
|
||||
if($raw[$key] -is [hashtable] -and $raw[$key].ContainsKey('TenantName'))
|
||||
{
|
||||
$name = $raw[$key]['TenantName']
|
||||
}
|
||||
elseif($raw[$key] -is [psobject] -and $raw[$key].PSObject.Properties['TenantName'])
|
||||
{
|
||||
$name = $raw[$key].TenantName
|
||||
}
|
||||
$display = if($name) { "$name ($key)" } else { $key }
|
||||
$tenants += [PSCustomObject]@{ TenantId = $key; TenantName = $name; Display = $display }
|
||||
}
|
||||
}
|
||||
return $tenants | Sort-Object Display
|
||||
}
|
||||
catch
|
||||
{
|
||||
return @()
|
||||
}
|
||||
}
|
||||
|
||||
function Update-TenantNameCache
|
||||
{
|
||||
param([string]$SettingsPath, [string]$TenantId, [string]$TenantName)
|
||||
if(-not (Test-Path $SettingsPath)) { return }
|
||||
try
|
||||
{
|
||||
$raw = Get-Content $SettingsPath -Raw -ErrorAction Stop | ConvertFrom-Json -AsHashtable -ErrorAction Stop
|
||||
if($raw[$TenantId] -is [hashtable])
|
||||
{
|
||||
$raw[$TenantId]['TenantName'] = $TenantName
|
||||
}
|
||||
else
|
||||
{
|
||||
$raw[$TenantId] = @{ TenantName = $TenantName }
|
||||
}
|
||||
$raw | ConvertTo-Json -Depth 10 | Set-Content -Path $SettingsPath -Force
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
function Resolve-TenantName
|
||||
{
|
||||
param([string]$TenantId, [string]$SettingsPath)
|
||||
$settingsObj = $null
|
||||
try
|
||||
{
|
||||
$settingsObj = Get-Content $SettingsPath -Raw -ErrorAction Stop | ConvertFrom-Json -AsHashtable -ErrorAction Stop
|
||||
}
|
||||
catch { return $null }
|
||||
|
||||
$tenantNode = $settingsObj[$TenantId]
|
||||
if(-not $tenantNode) { return $null }
|
||||
|
||||
$appId = $tenantNode['GraphAzureAppId']
|
||||
if(-not $appId) { return $null }
|
||||
|
||||
$secret = $tenantNode['GraphAzureAppSecret']
|
||||
$cert = $tenantNode['GraphAzureAppCert']
|
||||
|
||||
if(-not $secret -and $IsMacOS)
|
||||
{
|
||||
try
|
||||
{
|
||||
$keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" -w 2>$null
|
||||
if($keychainSecret) { $secret = $keychainSecret }
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
$runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1"
|
||||
if(-not (Test-Path $runtimeModule)) { return $null }
|
||||
|
||||
$invokeParams = @{
|
||||
Silent = $true
|
||||
JSonSettings = $true
|
||||
JSonFile = $SettingsPath
|
||||
TenantId = $TenantId
|
||||
AppId = $appId
|
||||
AuthMode = "AppOnly"
|
||||
}
|
||||
if($secret) { $invokeParams.Secret = $secret }
|
||||
elseif($cert) { $invokeParams.Certificate = $cert }
|
||||
|
||||
try
|
||||
{
|
||||
Import-Module $runtimeModule -Force | Out-Null
|
||||
Initialize-IntuneManagementRuntime -View "IntuneGraphAPI" @invokeParams | Out-Null
|
||||
if(Get-Command Invoke-GraphRequest -ErrorAction SilentlyContinue)
|
||||
{
|
||||
$org = Invoke-GraphRequest "/organization" -ErrorAction Stop
|
||||
if($org.value -and $org.value[0].displayName)
|
||||
{
|
||||
return $org.value[0].displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return $null
|
||||
}
|
||||
|
||||
$settingsPath = $SettingsFile
|
||||
if(-not $settingsPath) { $settingsPath = Get-DefaultSettingsPath }
|
||||
|
||||
if(-not $TenantId)
|
||||
{
|
||||
$tenants = Get-SavedTenants -SettingsPath $settingsPath
|
||||
$tenantOptions = @()
|
||||
foreach($t in $tenants)
|
||||
{
|
||||
$tenantOptions += $t.Display
|
||||
}
|
||||
$tenantOptions += "[+ Onboard new tenant]"
|
||||
$tenantOptions += "[Exit]"
|
||||
|
||||
$selectedTenantDisplay = Select-MenuItem -Items $tenantOptions -Header "Select a tenant"
|
||||
if(-not $selectedTenantDisplay -or $selectedTenantDisplay -eq "[Exit]")
|
||||
{
|
||||
exit 0
|
||||
}
|
||||
elseif($selectedTenantDisplay -eq "[+ Onboard new tenant]")
|
||||
{
|
||||
$TenantId = Read-Host "Enter the new Tenant ID (GUID)"
|
||||
if(-not $TenantId)
|
||||
{
|
||||
Write-Host "No tenant ID provided. Exiting." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
$TenantId = $selectedTenantDisplay -replace '.*\(([0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})\)$', '$1'
|
||||
if(-not $TenantId)
|
||||
{
|
||||
$TenantId = $selectedTenantDisplay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$currentTenant = (Get-SavedTenants -SettingsPath $settingsPath) | Where-Object { $_.TenantId -eq $TenantId } | Select-Object -First 1
|
||||
if(-not $currentTenant -or -not $currentTenant.TenantName)
|
||||
{
|
||||
Write-Host "`nResolving tenant name..." -ForegroundColor Cyan
|
||||
$resolvedName = Resolve-TenantName -TenantId $TenantId -SettingsPath $settingsPath
|
||||
if($resolvedName)
|
||||
{
|
||||
Update-TenantNameCache -SettingsPath $settingsPath -TenantId $TenantId -TenantName $resolvedName
|
||||
Write-Host "Cached tenant name: $resolvedName" -ForegroundColor Green
|
||||
$currentTenant = [PSCustomObject]@{ TenantId = $TenantId; TenantName = $resolvedName; Display = "$resolvedName ($TenantId)" }
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
Show-FzfHint
|
||||
|
||||
# Build common parameter hashtable
|
||||
$commonParams = @{
|
||||
TenantId = $TenantId
|
||||
AppId = $AppId
|
||||
Secret = $Secret
|
||||
Certificate = $Certificate
|
||||
AuthMode = $AuthMode
|
||||
RedirectUri = $RedirectUri
|
||||
SettingsFile = $SettingsFile
|
||||
}
|
||||
|
||||
$menuItems = @(
|
||||
"13. Refresh tenant names"
|
||||
"12. Initialize auth (one-time setup)"
|
||||
"11. Deploy baseline (dry-run / WhatIf)"
|
||||
"10. Deploy baseline"
|
||||
"9. Bulk device operations"
|
||||
"8. Bulk rename policies"
|
||||
"7. Export assignments to CSV/Markdown"
|
||||
"6. Restore assignments"
|
||||
"5. Backup assignments"
|
||||
"4. Bulk assignment manager (policies)"
|
||||
"3. Bulk app assignment"
|
||||
"2. Import policies"
|
||||
"1. Export policies"
|
||||
"0. Exit"
|
||||
)
|
||||
|
||||
while($true)
|
||||
{
|
||||
Clear-Host
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " macOS Intune Toolkit" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
if($currentTenant -and $currentTenant.TenantName)
|
||||
{
|
||||
Write-Host " Tenant: $($currentTenant.TenantName) ($TenantId)" -ForegroundColor Green
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Host " Tenant: $TenantId" -ForegroundColor Green
|
||||
}
|
||||
Write-Host " Press Esc to go back, Space to select" -ForegroundColor DarkGray
|
||||
|
||||
$selection = Select-MenuItem -Items $menuItems -Header "Select a tool to launch"
|
||||
if(-not $selection)
|
||||
{
|
||||
continue
|
||||
}
|
||||
if($selection -eq "EXIT" -or $selection -like "*0. Exit*")
|
||||
{
|
||||
Write-Host "`nExiting. Goodbye!" -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
$choiceNumber = [int]($selection -replace "^(\d+)\..*$", '$1')
|
||||
|
||||
switch($choiceNumber)
|
||||
{
|
||||
1 { $script = "Start-HeadlessIntune.ps1"; $commonParams.Interactive = $true }
|
||||
2 { $script = "Start-HeadlessIntune.ps1"; $commonParams.Interactive = $true }
|
||||
3 { $script = "Scripts/Bulk-AppAssignment.ps1" }
|
||||
4 { $script = "Scripts/Bulk-AssignmentManager.ps1" }
|
||||
5 { $script = "Scripts/Backup-Restore-Assignments.ps1"; $commonParams.Mode = "Backup" }
|
||||
6 { $script = "Scripts/Backup-Restore-Assignments.ps1"; $commonParams.Mode = "Restore" }
|
||||
7 { $script = "Scripts/Export-AssignmentsToCsv.ps1" }
|
||||
8 { $script = "Scripts/Bulk-RenamePolicies.ps1" }
|
||||
9 { $script = "Scripts/Bulk-DeviceOperations.ps1" }
|
||||
10 { $script = "Scripts/Deploy-IntuneBaseline.ps1" }
|
||||
11 { $script = "Scripts/Deploy-IntuneBaseline.ps1"; $commonParams.WhatIf = $true }
|
||||
12 { $script = "Scripts/Initialize-IntuneAuth.ps1" }
|
||||
13 { $script = $null }
|
||||
default { continue }
|
||||
}
|
||||
|
||||
# Clear any mode-specific params from previous loop iteration
|
||||
$commonParams.Remove("Interactive")
|
||||
$commonParams.Remove("Mode")
|
||||
$commonParams.Remove("WhatIf")
|
||||
|
||||
switch($choiceNumber)
|
||||
{
|
||||
1 { $commonParams.Interactive = $true }
|
||||
2 { $commonParams.Interactive = $true }
|
||||
5 { $commonParams.Mode = "Backup" }
|
||||
6 { $commonParams.Mode = "Restore" }
|
||||
11 { $commonParams.WhatIf = $true }
|
||||
}
|
||||
|
||||
if($choiceNumber -eq 13)
|
||||
{
|
||||
Write-Host "`nRefreshing tenant names..." -ForegroundColor Cyan
|
||||
$tenantsToRefresh = Get-SavedTenants -SettingsPath $settingsPath
|
||||
$refreshed = 0
|
||||
$failed = 0
|
||||
foreach($t in $tenantsToRefresh)
|
||||
{
|
||||
Write-Host " Resolving $($t.TenantId) ..." -ForegroundColor DarkGray -NoNewline
|
||||
$name = Resolve-TenantName -TenantId $t.TenantId -SettingsPath $settingsPath
|
||||
if($name)
|
||||
{
|
||||
Update-TenantNameCache -SettingsPath $settingsPath -TenantId $t.TenantId -TenantName $name
|
||||
Write-Host " -> $name" -ForegroundColor Green
|
||||
$refreshed++
|
||||
if($t.TenantId -eq $TenantId)
|
||||
{
|
||||
$currentTenant = [PSCustomObject]@{ TenantId = $TenantId; TenantName = $name; Display = "$name ($TenantId)" }
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Host " -> FAILED" -ForegroundColor Red
|
||||
$failed++
|
||||
}
|
||||
}
|
||||
Write-Host "`nRefresh complete. Success: $refreshed, Failed: $failed" -ForegroundColor Cyan
|
||||
Write-Host "`nPress any key to return to the menu..." -ForegroundColor DarkGray
|
||||
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||
continue
|
||||
}
|
||||
|
||||
$scriptPath = Join-Path $projectRoot $script
|
||||
if(-not (Test-Path $scriptPath))
|
||||
{
|
||||
throw "Script not found: $scriptPath"
|
||||
}
|
||||
|
||||
Write-Host "`nLaunching $script ...`n" -ForegroundColor Green
|
||||
|
||||
# Clone params and sanitize for scripts that don't accept the full auth set
|
||||
$launchParams = $commonParams.Clone()
|
||||
if($script -eq "Scripts/Initialize-IntuneAuth.ps1")
|
||||
{
|
||||
@("AppId","Secret","Certificate","AuthMode","RedirectUri","Interactive","Mode","WhatIf") | ForEach-Object { $launchParams.Remove($_) }
|
||||
}
|
||||
|
||||
# Execute in same process so TUI flows naturally
|
||||
& $scriptPath @launchParams
|
||||
|
||||
Write-Host "`nPress any key to return to the menu..." -ForegroundColor DarkGray
|
||||
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet("Export","Import")]
|
||||
[string]$Action,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TenantId,
|
||||
|
||||
[string]$AppId,
|
||||
|
||||
[string]$Secret,
|
||||
|
||||
[string]$Certificate,
|
||||
|
||||
[ValidateSet("AppOnly","Browser","DeviceCode")]
|
||||
[string]$AuthMode = "AppOnly",
|
||||
|
||||
[string]$RedirectUri,
|
||||
|
||||
[string]$SettingsFile,
|
||||
|
||||
[string]$BatchFile,
|
||||
|
||||
[string]$NameFilter = "",
|
||||
|
||||
[string]$NameSearchPattern = "",
|
||||
|
||||
[string]$NameReplacePattern = "",
|
||||
|
||||
[string[]]$ObjectTypes,
|
||||
|
||||
[string]$ExportPath,
|
||||
|
||||
[string]$ImportPath,
|
||||
|
||||
[ValidateSet("alwaysImport","skipIfExist","replace","replace_with_assignments","update")]
|
||||
[string]$ImportType = "alwaysImport",
|
||||
|
||||
[switch]$IncludeAssignments,
|
||||
|
||||
[switch]$AddCompanyName,
|
||||
|
||||
[switch]$IncludeScopeTags,
|
||||
|
||||
[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
|
||||
AppId = $AppId
|
||||
AuthMode = $AuthMode
|
||||
SettingsFile = $SettingsFile
|
||||
BatchFile = $BatchFile
|
||||
NameFilter = $NameFilter
|
||||
NameSearchPattern = $NameSearchPattern
|
||||
NameReplacePattern = $NameReplacePattern
|
||||
ExportPath = $ExportPath
|
||||
ImportPath = $ImportPath
|
||||
ImportType = $ImportType
|
||||
IncludeAssignments = $IncludeAssignments
|
||||
AddCompanyName = $AddCompanyName
|
||||
IncludeScopeTags = $IncludeScopeTags
|
||||
ReplaceDependencyIds = $ReplaceDependencyIds
|
||||
}
|
||||
|
||||
if($Interactive -and $Action) { $invokeParams.Action = $Action }
|
||||
|
||||
if($PSBoundParameters.ContainsKey("ObjectTypes") -or $ObjectTypes)
|
||||
{
|
||||
$invokeParams.ObjectTypes = $ObjectTypes
|
||||
}
|
||||
|
||||
if($Secret)
|
||||
{
|
||||
$invokeParams.Secret = $Secret
|
||||
}
|
||||
elseif($Certificate)
|
||||
{
|
||||
$invokeParams.Certificate = $Certificate
|
||||
}
|
||||
|
||||
if($RedirectUri)
|
||||
{
|
||||
$invokeParams.RedirectUri = $RedirectUri
|
||||
}
|
||||
|
||||
Invoke-IntunePolicyAction @invokeParams
|
||||
Reference in New Issue
Block a user