mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 19:05:38 +02:00
IntuneManagement 4.0.0-beta1
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
function Compare-GraphPolicy {
|
||||
[CmdletBinding(DefaultParameterSetName = 'Direct')]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'Direct', Position = 0, ValueFromPipeline = $true)]
|
||||
[object[]]
|
||||
$Policies,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'ExportFiles')]
|
||||
[CompareExportFilesProvider]
|
||||
$ExportFiles,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'IntuneWithExport')]
|
||||
[CompareIntuneWithExportProvider]
|
||||
$IntuneWithExport,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'NamedObjects')]
|
||||
[CompareNamedObjectsProvider]
|
||||
$NamedObjects,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'ExportedFolders')]
|
||||
[CompareExportedFoldersProvider]
|
||||
$ExportedFolders,
|
||||
|
||||
[Parameter(ParameterSetName = 'ExportFiles')]
|
||||
[Parameter(ParameterSetName = 'IntuneWithExport')]
|
||||
[Parameter(ParameterSetName = 'NamedObjects')]
|
||||
[Parameter(ParameterSetName = 'ExportedFolders')]
|
||||
[string[]]
|
||||
$PolicyGroupIds = @()
|
||||
)
|
||||
|
||||
Process {
|
||||
switch($PSCmdlet.ParameterSetName)
|
||||
{
|
||||
'Direct' {
|
||||
if($Policies.Count -lt 2)
|
||||
{
|
||||
if($Policies.Count -eq 1)
|
||||
{
|
||||
Show-GraphCompareForm $Policies[0]
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Error "Provide at least one policy. With a single policy the compare UI will open."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
# Batch-hydrate via the unified orchestrator instead of N
|
||||
# sequential per-policy Get() calls. N>=2 here (single-policy
|
||||
# branch above returned early), so this always takes the
|
||||
# parallel $batch path inside Invoke-PolicyHydrate.
|
||||
$needFull = @($Policies | Where-Object {
|
||||
$_ -and $_.PSObject.Properties['_IsFullObject'] -and -not $_._IsFullObject -and $_.Id -and $_.PolicyType
|
||||
})
|
||||
if($needFull.Count -gt 0) {
|
||||
Invoke-PolicyHydrate -Policies $needFull
|
||||
}
|
||||
Compare-PolicyObjects $Policies
|
||||
}
|
||||
|
||||
default {
|
||||
$provider = switch($PSCmdlet.ParameterSetName)
|
||||
{
|
||||
'ExportFiles' { $ExportFiles }
|
||||
'IntuneWithExport' { $IntuneWithExport }
|
||||
'NamedObjects' { $NamedObjects }
|
||||
'ExportedFolders' { $ExportedFolders }
|
||||
}
|
||||
|
||||
$groups = if($PolicyGroupIds.Count -gt 0)
|
||||
{
|
||||
# Unknown ids are logged and raised as non-terminating errors by
|
||||
# the shared resolver instead of vanishing from the -in filter.
|
||||
@((Resolve-IntuneTargetSelectors -PolicyGroup $PolicyGroupIds -Caller 'Compare-GraphPolicy').Groups)
|
||||
}
|
||||
else
|
||||
{
|
||||
@($script:IntuneGroups)
|
||||
}
|
||||
|
||||
if(-not $groups)
|
||||
{
|
||||
Write-Error "No policy groups found. Ensure the module is initialized and group IDs are correct."
|
||||
return
|
||||
}
|
||||
|
||||
$pairs = $provider.GetComparePairs($groups)
|
||||
foreach($pair in $pairs)
|
||||
{
|
||||
$result = Compare-PolicyObjects @($pair.Policy1, $pair.Policy2)
|
||||
[PSCustomObject]@{
|
||||
Name = $pair.Name
|
||||
Id = $pair.Id
|
||||
PolicyType = $pair.PolicyType.Title
|
||||
Result = $result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
function Connect-IntuneManagement {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Authenticate to Microsoft Graph for use with IntuneManagement.
|
||||
|
||||
.DESCRIPTION
|
||||
Supports four non-interactive authentication methods:
|
||||
- App registration with a client secret
|
||||
- App registration with a certificate (thumbprint, X509Certificate2 object, or .pfx file)
|
||||
- Bring-your-own token (pass a raw Bearer token string)
|
||||
- Managed Identity (Azure VM / Azure Function workload identity)
|
||||
|
||||
The auth backend is selected via -Provider:
|
||||
- MSAL (default; uses the bundled MSAL.NET DLLs)
|
||||
- MgGraph (uses the Microsoft.Graph.Authentication PowerShell module; the
|
||||
module must be installed: Install-Module Microsoft.Graph.Authentication)
|
||||
|
||||
If -Provider is omitted, the value of the "ActiveAuthProvider" setting is
|
||||
used (default MSAL).
|
||||
|
||||
.EXAMPLE
|
||||
# Client secret with the default provider (MSAL)
|
||||
Connect-IntuneManagement -TenantId "contoso.onmicrosoft.com" -AppId "00000000-..." -Secret "abc123"
|
||||
|
||||
.EXAMPLE
|
||||
# Client secret via the Microsoft.Graph SDK provider
|
||||
Connect-IntuneManagement -Provider MgGraph -TenantId "contoso.onmicrosoft.com" -AppId "00000000-..." -Secret "abc123"
|
||||
|
||||
.EXAMPLE
|
||||
# Certificate thumbprint (looked up in Cert:\CurrentUser\My then Cert:\LocalMachine\My)
|
||||
Connect-IntuneManagement -TenantId "contoso.onmicrosoft.com" -AppId "00000000-..." -Certificate "A1B2C3..."
|
||||
|
||||
.EXAMPLE
|
||||
# Certificate from .pfx file
|
||||
Connect-IntuneManagement -TenantId "contoso.onmicrosoft.com" -AppId "00000000-..." `
|
||||
-CertificatePath "C:\certs\app.pfx" -CertificatePassword (ConvertTo-SecureString "pass" -AsPlainText -Force)
|
||||
|
||||
.EXAMPLE
|
||||
# Bring your own Graph Bearer token
|
||||
Connect-IntuneManagement -Token $myToken
|
||||
|
||||
.EXAMPLE
|
||||
# System-assigned managed identity (Azure VM / Azure Function)
|
||||
Connect-IntuneManagement -Provider MgGraph -ManagedIdentity
|
||||
|
||||
.EXAMPLE
|
||||
# User-assigned managed identity
|
||||
Connect-IntuneManagement -Provider MgGraph -ManagedIdentity -AppId "00000000-..."
|
||||
|
||||
.EXAMPLE
|
||||
# Direct-OAuth provider (no SDK, no DLL): client secret
|
||||
Connect-IntuneManagement -Provider OAuth -TenantId "contoso.onmicrosoft.com" -AppId "00000000-..." -Secret "abc"
|
||||
|
||||
.EXAMPLE
|
||||
# Direct-OAuth provider: workload identity federation (AKS / GitHub Actions OIDC)
|
||||
Connect-IntuneManagement -Provider OAuth -TenantId "..." -AppId "..." `
|
||||
-FederatedTokenFile $env:AZURE_FEDERATED_TOKEN_FILE
|
||||
|
||||
.EXAMPLE
|
||||
# Direct-OAuth provider: PSCredential (ROPC; non-MFA accounts only)
|
||||
Connect-IntuneManagement -Provider OAuth -TenantId "..." -AppId "..." -Credential (Get-Credential)
|
||||
|
||||
.EXAMPLE
|
||||
# Device code sign-in on the default provider (MSAL). MFA / FIDO2 /
|
||||
# YubiKey capable; the browser auth happens on any other device.
|
||||
Connect-IntuneManagement -DeviceCode
|
||||
|
||||
.EXAMPLE
|
||||
# Device code sign-in on the Direct-OAuth provider. Uses the app id
|
||||
# selected in Settings -> Entra, unless -AppId is supplied.
|
||||
Connect-IntuneManagement -Provider OAuth -DeviceCode
|
||||
|
||||
.EXAMPLE
|
||||
# Device code sign-in on the Microsoft.Graph SDK provider
|
||||
Connect-IntuneManagement -Provider MgGraph -DeviceCode
|
||||
|
||||
.EXAMPLE
|
||||
# Interactive sign-in (browser popup / WAM broker on the default MSAL
|
||||
# provider). Silent-from-cache first, falls back to browser prompt.
|
||||
Connect-IntuneManagement -Interactive
|
||||
|
||||
.EXAMPLE
|
||||
# Interactive against a specific tenant, force a fresh browser prompt
|
||||
Connect-IntuneManagement -Interactive -TenantId "contoso.onmicrosoft.com" -ForceInteractive
|
||||
|
||||
.EXAMPLE
|
||||
# Interactive against a sovereign cloud
|
||||
Connect-IntuneManagement -Interactive -Cloud USGov
|
||||
|
||||
.EXAMPLE
|
||||
# Interactive against the Direct-OAuth provider — no browser popup path
|
||||
# exists in that provider, so this transparently routes to device code
|
||||
# (headless-friendly, MFA/FIDO2 capable). Uses the app id selected in
|
||||
# Settings -> Entra, unless -AppId is supplied.
|
||||
Connect-IntuneManagement -Provider OAuth -Interactive
|
||||
#>
|
||||
[CmdletBinding(DefaultParameterSetName = 'Interactive')]
|
||||
[OutputType([PSCustomObject])]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'Secret')]
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'Certificate')]
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'CertificatePath')]
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'OAuthFederated')]
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'OAuthCredential')]
|
||||
[Parameter(Mandatory = $false, ParameterSetName = 'DeviceCode')]
|
||||
[Parameter(Mandatory = $false, ParameterSetName = 'Interactive')]
|
||||
[Parameter(Mandatory = $false, ParameterSetName = 'Token')]
|
||||
[Parameter(Mandatory = $false, ParameterSetName = 'ManagedIdentity')]
|
||||
[string]$TenantId,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'Secret')]
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'Certificate')]
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'CertificatePath')]
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'OAuthFederated')]
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'OAuthCredential')]
|
||||
[Parameter(Mandatory = $false, ParameterSetName = 'DeviceCode')]
|
||||
[Parameter(Mandatory = $false, ParameterSetName = 'Interactive')]
|
||||
[Parameter(Mandatory = $false, ParameterSetName = 'ManagedIdentity')]
|
||||
[string]$AppId,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'Secret')]
|
||||
[string]$Secret,
|
||||
|
||||
# Thumbprint string or X509Certificate2 object
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'Certificate')]
|
||||
$Certificate,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'CertificatePath')]
|
||||
[string]$CertificatePath,
|
||||
|
||||
[Parameter(Mandatory = $false, ParameterSetName = 'CertificatePath')]
|
||||
[SecureString]$CertificatePassword,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'Token')]
|
||||
[string]$Token,
|
||||
|
||||
# System-assigned (no -AppId) or user-assigned (with -AppId) managed identity.
|
||||
# MgGraph and OAuth providers support this; MSAL rejects with a clear error.
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'ManagedIdentity')]
|
||||
[switch]$ManagedIdentity,
|
||||
|
||||
# OAuth provider only — workload identity federation. Path to a file
|
||||
# containing an OIDC JWT to exchange at the /token endpoint as
|
||||
# client_assertion (jwt-bearer). Used by AKS Workload Identity, GitHub
|
||||
# Actions OIDC, Azure DevOps OIDC, etc.
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'OAuthFederated')]
|
||||
[string]$FederatedTokenFile,
|
||||
|
||||
# OAuth provider only — pass the federated assertion inline (alternative
|
||||
# to -FederatedTokenFile when the caller already has the JWT in memory).
|
||||
[Parameter(Mandatory = $false, ParameterSetName = 'OAuthFederated')]
|
||||
[string]$FederatedToken,
|
||||
|
||||
# OAuth provider only — username/password (ROPC) via PSCredential. Limited
|
||||
# to non-MFA accounts; intended for legacy automation. Use a managed
|
||||
# identity / federated credential / cert / secret in preference.
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'OAuthCredential')]
|
||||
[PSCredential]$Credential,
|
||||
|
||||
# Device code sign-in (RFC 8628). Prints a code + verification URL;
|
||||
# the user completes auth (MFA / FIDO2 / YubiKey all work) in a browser
|
||||
# on any device while this call polls for the token. Supported by every
|
||||
# provider: MSAL uses MSAL.NET's AcquireTokenWithDeviceCode (token lands
|
||||
# in the MSAL cache and refreshes silently); MgGraph uses Connect-MgGraph
|
||||
# -UseDeviceCode; OAuth speaks the RFC 8628 flow directly. TenantId is
|
||||
# optional on MSAL/OAuth ('organizations' / 'common' as appropriate).
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'DeviceCode')]
|
||||
[switch]$DeviceCode,
|
||||
|
||||
# Interactive sign-in — browser popup / WAM broker (MSAL) or device code
|
||||
# (OAuth). Silent-from-cache first, falls back to interactive prompt
|
||||
# when the cache is cold. Default parameter set — you can call
|
||||
# `Connect-IntuneManagement` with no args and get an interactive prompt.
|
||||
[Parameter(Mandatory = $false, ParameterSetName = 'Interactive')]
|
||||
[switch]$Interactive,
|
||||
|
||||
# Interactive-only: pin the account to sign in with (MSAL only). Same
|
||||
# semantics as passing $global:MSALLoginHint.
|
||||
[Parameter(Mandatory = $false, ParameterSetName = 'Interactive')]
|
||||
[string]$User,
|
||||
|
||||
# Interactive-only: bypass the token cache and force a fresh browser
|
||||
# prompt even when a cached token exists (MSAL only).
|
||||
[Parameter(Mandatory = $false, ParameterSetName = 'Interactive')]
|
||||
[switch]$ForceInteractive,
|
||||
|
||||
# Interactive-only: use the Windows broker (WAM) instead of a browser
|
||||
# popup. Requires PS7+ on Windows (MSAL only).
|
||||
[Parameter(Mandatory = $false, ParameterSetName = 'Interactive')]
|
||||
[switch]$AuthenticationBroker,
|
||||
|
||||
# Interactive-only: force the OAuth provider's browser (Authorization Code +
|
||||
# PKCE, loopback redirect) flow explicitly, even headless. Without this,
|
||||
# -Interactive uses the browser only when a GUI is present, else device code.
|
||||
# (OAuth only; ignored by MSAL/MgGraph.)
|
||||
[Parameter(Mandatory = $false, ParameterSetName = 'Interactive')]
|
||||
[switch]$Browser,
|
||||
|
||||
# New flat Cloud taxonomy (Phase 1 of the cloud redesign, 2026-05-22). Replaces
|
||||
# -GraphEnvironment + -GCCType. If omitted, falls back to the "DefaultCloud"
|
||||
# setting (defaults to Public). The legacy pair is still accepted for one
|
||||
# release with a deprecation warning — see resolution block below.
|
||||
[Parameter(Mandatory = $false)]
|
||||
[ValidateSet("Public", "USGov", "USGovDOD", "China")]
|
||||
[string]$Cloud,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[ValidateSet("public", "usGov", "china")]
|
||||
[string]$GraphEnvironment = "public",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[AllowNull()]
|
||||
[ValidateSet("", "gcc", "gccHigh", "gccDoD")]
|
||||
[string]$GCCType,
|
||||
|
||||
[switch]$DefaultToken,
|
||||
|
||||
# Pick the auth backend. If omitted, uses the value of the "ActiveAuthProvider"
|
||||
# setting (default MSAL). The named provider must be registered (MgGraph requires
|
||||
# Microsoft.Graph.Authentication installed). Valid values are enumerated
|
||||
# dynamically from every registered AuthenticationProvider — adding a new
|
||||
# provider via Register-AuthProvider is sufficient, no edit here needed.
|
||||
[Parameter(Mandatory = $false)]
|
||||
# Completion (not [ValidateSet([AuthProviderValues])]) so the module still
|
||||
# imports on Windows PowerShell 5.1 - the generator implements the PS7-only
|
||||
# IValidateSetValuesGenerator. Unknown providers are handled at runtime below.
|
||||
[ArgumentCompleter({ param($commandName, $parameterName, $wordToComplete) @(& (Get-Module IntuneManagement) { Get-AuthProviderValues }) | Where-Object { $_ -like "$wordToComplete*" } })]
|
||||
[string]$Provider
|
||||
)
|
||||
|
||||
# Resolve the provider. If -Provider is explicitly set, use it; otherwise use the
|
||||
# currently active provider from AuthenticationCore. Parameter sets that ONLY make
|
||||
# sense for the OAuth provider (workload-identity federation, ROPC PSCredential)
|
||||
# auto-route to OAuth — saves the caller from having to also pass -Provider OAuth.
|
||||
# DeviceCode used to be OAuth-only ('OAuthDeviceCode'); every provider now
|
||||
# supports it, so the auto-route no longer needs to force -Provider OAuth
|
||||
# for that case. Federated / ROPC (Credential) remain OAuth-exclusive.
|
||||
$oauthOnlySets = @('OAuthFederated','OAuthCredential')
|
||||
if(-not $Provider -and $PSCmdlet.ParameterSetName -in $oauthOnlySets) {
|
||||
$Provider = "OAuth"
|
||||
Write-LogDebug "Connect-IntuneManagement auto-selected -Provider OAuth (parameter set: $($PSCmdlet.ParameterSetName))"
|
||||
}
|
||||
|
||||
if($Provider) {
|
||||
$authProvider = Get-AuthProvider -Id $Provider
|
||||
if(-not $authProvider) {
|
||||
Write-Log "Provider '$Provider' is not registered. For MgGraph, install Microsoft.Graph.Authentication." 3
|
||||
return
|
||||
}
|
||||
}
|
||||
else {
|
||||
$authProvider = Get-AuthProvider
|
||||
if(-not $authProvider) {
|
||||
Write-Log "No authentication provider is active. Cannot continue." 3
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
Write-LogDebug "Connect-IntuneManagement routing to provider '$($authProvider.Id)' (parameter set: $($PSCmdlet.ParameterSetName))"
|
||||
|
||||
# Resolve the target cloud once, here, so every downstream branch sees a consistent
|
||||
# answer. Precedence: -Cloud wins; explicit -GraphEnvironment/-GCCType is the legacy
|
||||
# path (deprecated, warns); otherwise read the DefaultCloud setting. Also derive the
|
||||
# legacy GraphEnvironment/GCCType pair from the resolved Cloud so MSAL functions that
|
||||
# still take the old form (Phase 4 will migrate them) keep working.
|
||||
if($PSBoundParameters.ContainsKey('Cloud')) {
|
||||
$resolvedCloud = $Cloud
|
||||
}
|
||||
elseif($PSBoundParameters.ContainsKey('GraphEnvironment') -or $PSBoundParameters.ContainsKey('GCCType')) {
|
||||
Write-Log "-GraphEnvironment and -GCCType are deprecated; use -Cloud (Public/USGov/USGovDOD/China) instead. They will be removed in a future release." 2
|
||||
$resolvedCloud = Convert-LegacyToCloud -GraphEnvironment $GraphEnvironment -GCCType $GCCType
|
||||
}
|
||||
else {
|
||||
$resolvedCloud = Get-DefaultCloud
|
||||
}
|
||||
$cloudEntry = Get-CloudByValue $resolvedCloud
|
||||
$GraphEnvironment = $cloudEntry.LegacyEnv
|
||||
$GCCType = if([string]::IsNullOrWhiteSpace($cloudEntry.LegacyGCC)) { $null } else { $cloudEntry.LegacyGCC }
|
||||
Write-LogDebug "Connect-IntuneManagement resolved Cloud=$resolvedCloud (legacy GraphEnvironment='$GraphEnvironment', GCCType='$GCCType')"
|
||||
|
||||
# Path A - providers wired into the built-in Connect-* entry points
|
||||
# (UsesBuiltInConnectPath, i.e. MSAL) are driven through them directly. Their
|
||||
# Connect() ALSO forwards to these functions, but skipping the extra hop keeps
|
||||
# stack traces clean and behaviour identical to pre-refactor.
|
||||
if($authProvider.UsesBuiltInConnectPath) {
|
||||
$sharedParams = @{
|
||||
GraphEnvironment = $GraphEnvironment
|
||||
GCCType = $GCCType
|
||||
DefaultToken = $DefaultToken
|
||||
}
|
||||
|
||||
switch ($PSCmdlet.ParameterSetName) {
|
||||
'Secret' {
|
||||
return (Connect-WithClientCredentials -TenantId $TenantId -AppId $AppId -Secret $Secret @sharedParams)
|
||||
}
|
||||
'Certificate' {
|
||||
$cert = Resolve-MSALCertificate $Certificate
|
||||
if (-not $cert) {
|
||||
Write-Log "Cannot resolve certificate '$Certificate'. Provide a valid thumbprint or X509Certificate2 object." 3
|
||||
return
|
||||
}
|
||||
return (Connect-WithClientCredentials -TenantId $TenantId -AppId $AppId -Certificate $cert @sharedParams)
|
||||
}
|
||||
'CertificatePath' {
|
||||
$cert = Resolve-MSALCertificate -CertificatePath $CertificatePath -Password $CertificatePassword
|
||||
if (-not $cert) {
|
||||
Write-Log "Cannot load certificate from '$CertificatePath'." 3
|
||||
return
|
||||
}
|
||||
return (Connect-WithClientCredentials -TenantId $TenantId -AppId $AppId -Certificate $cert @sharedParams)
|
||||
}
|
||||
'Token' {
|
||||
return (Add-BYOTokenInfo -Token $Token -TenantId $TenantId @sharedParams)
|
||||
}
|
||||
'ManagedIdentity' {
|
||||
Write-Log "MSAL provider does not support -ManagedIdentity. Use -Provider MgGraph." 3
|
||||
return
|
||||
}
|
||||
'DeviceCode' {
|
||||
# Full MSAL device-code flow via Connect-EntraEnvironment's
|
||||
# -DeviceCode switch (uses MSAL.NET's AcquireTokenWithDeviceCode
|
||||
# under the hood). Token lands in the MSAL cache so subsequent
|
||||
# silent refreshes work identically to interactive sign-in.
|
||||
# Splat matches Connect-EntraEnvironment's parameter surface —
|
||||
# GraphEnvironment/GCCType are NOT its parameters (used by
|
||||
# the client-credentials helpers), so -Cloud carries the cloud.
|
||||
$dcArgs = @{
|
||||
DefaultToken = $DefaultToken
|
||||
DeviceCode = $true
|
||||
Cloud = $resolvedCloud
|
||||
}
|
||||
if($TenantId) { $dcArgs['TenantId'] = $TenantId }
|
||||
if($AppId) { $dcArgs['AppId'] = $AppId }
|
||||
return (Connect-EntraEnvironment @dcArgs)
|
||||
}
|
||||
'Interactive' {
|
||||
# Interactive MSAL flow — browser popup, or WAM broker when
|
||||
# -AuthenticationBroker is set. Delegates to Connect-EntraEnvironment
|
||||
# which owns the MSAL public-client PCA plumbing. Splat only the
|
||||
# keys Connect-EntraEnvironment declares; -Cloud drives the
|
||||
# sovereign-cloud selection there (the legacy Environment param
|
||||
# is derived from Cloud downstream). GCCType is not a
|
||||
# Connect-EntraEnvironment parameter (it's used by the
|
||||
# client-credentials helpers only).
|
||||
$iArgs = @{
|
||||
DefaultToken = $DefaultToken
|
||||
ForceInteractive = $ForceInteractive
|
||||
AuthenticationBroker = $AuthenticationBroker
|
||||
Cloud = $resolvedCloud
|
||||
}
|
||||
if($TenantId) { $iArgs['TenantId'] = $TenantId }
|
||||
if($AppId) { $iArgs['AppId'] = $AppId }
|
||||
if($User) { $iArgs['User'] = $User }
|
||||
return (Connect-EntraEnvironment @iArgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Path B — provider-aware path. Pack the parameters into a hashtable and let the
|
||||
# provider class translate. This is the route for MgGraph, OAuth, and any future
|
||||
# provider.
|
||||
$providerArgs = @{}
|
||||
if($TenantId) { $providerArgs['TenantId'] = $TenantId }
|
||||
if($AppId) { $providerArgs['AppId'] = $AppId }
|
||||
if($Secret) { $providerArgs['Secret'] = $Secret }
|
||||
if($Certificate) { $providerArgs['Certificate'] = $Certificate }
|
||||
if($CertificatePath) { $providerArgs['CertificatePath'] = $CertificatePath }
|
||||
if($CertificatePassword) { $providerArgs['CertificatePassword'] = $CertificatePassword }
|
||||
if($Token) { $providerArgs['Token'] = $Token }
|
||||
if($ManagedIdentity) { $providerArgs['ManagedIdentity'] = $true }
|
||||
if($FederatedTokenFile) { $providerArgs['FederatedTokenFile'] = $FederatedTokenFile }
|
||||
if($FederatedToken) { $providerArgs['FederatedToken'] = $FederatedToken }
|
||||
if($Credential) { $providerArgs['Credential'] = $Credential }
|
||||
if($DeviceCode) { $providerArgs['DeviceCode'] = $true }
|
||||
if($Interactive -or $PSCmdlet.ParameterSetName -eq 'Interactive') {
|
||||
$providerArgs['Interactive'] = $true
|
||||
}
|
||||
if($User) { $providerArgs['User'] = $User }
|
||||
if($ForceInteractive) { $providerArgs['ForceInteractive'] = $true }
|
||||
if($AuthenticationBroker){ $providerArgs['AuthenticationBroker'] = $true }
|
||||
if($Browser) { $providerArgs['Browser'] = $true }
|
||||
if($DeviceCode) { $providerArgs['DeviceCode'] = $true }
|
||||
$providerArgs['Cloud'] = $resolvedCloud
|
||||
$providerArgs['GraphEnvironment'] = $GraphEnvironment
|
||||
$providerArgs['GCCType'] = $GCCType
|
||||
$providerArgs['DefaultToken'] = $DefaultToken.IsPresent
|
||||
|
||||
$result = $authProvider.Connect($providerArgs)
|
||||
|
||||
# MgGraph fallback: if the non-default provider failed (user declined the SDK
|
||||
# install, or install failed), fall back to MSAL so the user is still signed in.
|
||||
# ManagedIdentity / OAuth-only parameter sets have no MSAL equivalent —
|
||||
# don't fall back for them.
|
||||
$noFallbackSets = @('ManagedIdentity','OAuthFederated','OAuthCredential','DeviceCode','Interactive')
|
||||
if(-not $result -and $authProvider.Id -ne "MSAL" -and $PSCmdlet.ParameterSetName -notin $noFallbackSets) {
|
||||
$msal = Get-AuthProvider -Id "MSAL"
|
||||
if($msal) {
|
||||
Write-Log "Provider '$($authProvider.Id)' failed to connect - falling back to MSAL"
|
||||
# Strip provider-specific fields before retrying.
|
||||
$providerArgs.Remove('ManagedIdentity') | Out-Null
|
||||
$providerArgs.Remove('FederatedTokenFile') | Out-Null
|
||||
$providerArgs.Remove('FederatedToken') | Out-Null
|
||||
$providerArgs.Remove('Credential') | Out-Null
|
||||
$result = $msal.Connect($providerArgs)
|
||||
if($result) {
|
||||
# Make MSAL the active provider for the rest of the session — otherwise
|
||||
# subsequent Invoke-MSGraphAPI calls would still target the failed
|
||||
# provider.
|
||||
Set-ActiveAuthProvider -Id "MSAL"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
function Copy-GraphPolicy {
|
||||
[CmdletBinding()]
|
||||
[OutputType([IntunePolicyBase[]])]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
|
||||
[IntunePolicyBase[]]
|
||||
$InputObject,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
# Name of new policy
|
||||
[String]
|
||||
$Name,
|
||||
|
||||
# Description of new policy
|
||||
[String]
|
||||
$Description,
|
||||
|
||||
# Specifies a name pattern to replace. Used when copying multiple policies. The Name parameter then replaces the pattern in each policy name.
|
||||
[String]
|
||||
$CopyFromPatternName,
|
||||
|
||||
# Specifies a description pattern to replace. Used when copying multiple policies. The Description parameter then replaces the pattern in each policy description.
|
||||
[String]
|
||||
$CopyFromPatternDescription,
|
||||
|
||||
# Specifies destination environment. Default is current logged on environment.
|
||||
[int]
|
||||
$TokenId = (Get-DefaultTokenId),
|
||||
|
||||
# Override the scope tag IDs the new copy will have. When omitted the copy
|
||||
# inherits whatever scope tags the source had; when provided (even as an
|
||||
# empty array) this list wins. Passed to CopyObject which writes them into
|
||||
# the cloned JSON's ScopeTagProperty (typically roleScopeTagIds) before POST.
|
||||
[Parameter(Mandatory = $false)]
|
||||
[AllowEmptyCollection()]
|
||||
[string[]]
|
||||
$ScopeTagIds
|
||||
)
|
||||
|
||||
Begin {
|
||||
Write-Log "Start Copy"
|
||||
$copiedPolicies = @()
|
||||
# Collect across Process invocations — `$policies | Copy-GraphPolicy`
|
||||
# triggers Process once per pipeline item, so per-Process batching
|
||||
# would still be per-item single GETs. Hydrate + copy run in End.
|
||||
$allInput = [System.Collections.Generic.List[object]]::new()
|
||||
}
|
||||
|
||||
Process {
|
||||
foreach ($p in $InputObject) {
|
||||
if ($p) { [void]$allInput.Add($p) }
|
||||
}
|
||||
}
|
||||
|
||||
End {
|
||||
# Pre-hydrate the source policies in one Invoke-PolicyHydrate call —
|
||||
# N>1 takes the parallel $batch path. CopyObject internally calls
|
||||
# $this.Get() with the IsFullObject guard, so already-hydrated rows
|
||||
# are a no-op there.
|
||||
$hydrateTargets = @($allInput | Where-Object {
|
||||
$_.PSObject.Properties['_IsFullObject'] -and -not $_._IsFullObject -and
|
||||
$_.Id -and $_.PolicyType -and $_.IsFromFile -ne $true
|
||||
})
|
||||
if ($hydrateTargets.Count -gt 0) {
|
||||
Invoke-PolicyHydrate -Policies $hydrateTargets
|
||||
}
|
||||
|
||||
foreach ($policyObject in $allInput) {
|
||||
$newName = $null
|
||||
$newDescription = $null
|
||||
|
||||
if ($CopyFromPatternName -and $policyObject.Name -imatch [regex]::Escape($CopyFromPatternName)) {
|
||||
$newName = $policyObject.Name -ireplace [regex]::Escape($CopyFromPatternName), $Name
|
||||
}
|
||||
elseif ($CopyFromPatternName) {
|
||||
Write-Verbose "$CopyFromPatternName did not match the pattern of the policy name '$($policyObject.Name)'. Skipping policy"
|
||||
continue
|
||||
}
|
||||
else {
|
||||
$newName = $Name
|
||||
}
|
||||
|
||||
if ($Description -and $CopyFromPatternDescription -and $policyObject.Description -imatch [regex]::Escape($CopyFromPatternDescription)) {
|
||||
$newDescription = $policyObject.Description -ireplace [regex]::Escape($CopyFromPatternDescription), $Description
|
||||
}
|
||||
elseif ($Description -and -not $CopyFromPatternDescription) {
|
||||
$newDescription = $Description
|
||||
}
|
||||
|
||||
Write-Verbose "Copy policy '$($policyObject.Name)' to '$($newName)'."
|
||||
|
||||
if($PSBoundParameters.ContainsKey('ScopeTagIds')) {
|
||||
$newPolicy = $policyObject.CopyObject($newName, $newDescription, $TokenId, $ScopeTagIds)
|
||||
}
|
||||
else {
|
||||
$newPolicy = $policyObject.CopyObject($newName, $newDescription, $TokenId)
|
||||
}
|
||||
if ($newPolicy) {
|
||||
$copiedPolicies += $newPolicy
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Copy finished. $($copiedPolicies.Count) policies copied"
|
||||
return $copiedPolicies
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
function Export-GraphPolicy {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
|
||||
[IntunePolicyBase[]]
|
||||
$InputObject,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[IntuneManagerExportSettings]
|
||||
$ExportSettings,
|
||||
# Emit the full path of each exported file to the pipeline. Opt-in; the
|
||||
# per-policy branch in Process only outputs when this is set.
|
||||
[switch]
|
||||
$PassThru
|
||||
)
|
||||
|
||||
Begin {
|
||||
Write-Log "Start Export"
|
||||
|
||||
# Honour the ClearCacheBeforeExportImport setting (bit 2 = manual
|
||||
# export). Begin runs once per pipeline invocation so a multi-policy
|
||||
# export clears at most once. Bulk export clears via its own driver —
|
||||
# the defer-flag it sets doubles as the "called from bulk" marker so
|
||||
# the per-type pipelines inside a bulk run don't re-clear.
|
||||
if (-not $script:_bulkExportDeferMigFlush) {
|
||||
Invoke-GraphCacheClearBeforeOperation -Operation ManualExport
|
||||
}
|
||||
|
||||
$exportFolderRoot = $ExportSettings.ExportFolder
|
||||
if ($ExportSettings.AddCompanyName) {
|
||||
# The organisation display name is tenant-controlled and can legally
|
||||
# contain path separators ("Contoso A/S"), which would silently split
|
||||
# the export into a nested folder. Treat it as a single path segment.
|
||||
$companyFolder = Remove-InvalidFileNameChars (Get-CurrentOrganizationName)
|
||||
if (-not [String]::IsNullOrWhiteSpace($companyFolder)) {
|
||||
$exportFolderRoot = [IO.Path]::Combine($exportFolderRoot, $companyFolder)
|
||||
}
|
||||
}
|
||||
|
||||
# Create the export root up-front so the user sees the folder even when no
|
||||
# policies are written (empty selection / all-zero matches / a downstream
|
||||
# error). Previously the directory only got created inside Process, so a
|
||||
# silent-skip in Process meant no folder appeared anywhere.
|
||||
try {
|
||||
if (-not [IO.Directory]::Exists($exportFolderRoot)) {
|
||||
[IO.Directory]::CreateDirectory($exportFolderRoot) | Out-Null
|
||||
Write-Log "Export: created folder $exportFolderRoot"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Export: failed to create export root '$exportFolderRoot'" $_.Exception
|
||||
}
|
||||
|
||||
$script:_exportSkippedNoTenantID = 0
|
||||
}
|
||||
|
||||
Process {
|
||||
foreach ($policyObject in $InputObject) {
|
||||
if (-not $policyObject.TenantID) {
|
||||
# Was silent — explicit log so "Exported N, on disk 0" doesn't repeat.
|
||||
# `continue` (not `return`) so the foreach moves to the next item: in
|
||||
# PowerShell, `return` inside a Process foreach exits that whole Process
|
||||
# invocation, dropping remaining pipeline items the caller passed in.
|
||||
$script:_exportSkippedNoTenantID++
|
||||
Write-Log "Export: skipped '$($policyObject.Name)' - TenantID not set on object" 2
|
||||
continue
|
||||
}
|
||||
|
||||
Write-Log "Export $($policyObject.Name) - $($policyObject.PolicyName)"
|
||||
|
||||
$exportFolder = $exportFolderRoot
|
||||
|
||||
if ($ExportSettings.AddObjectType) {
|
||||
$exportFolder = [IO.Path]::Combine($exportFolder, $policyObject.PolicyType.Folder)
|
||||
}
|
||||
|
||||
if ($policyObject.IsFullObject -eq $false) {
|
||||
# Bulk export pre-hydrates via Invoke-PolicyHydrate; this
|
||||
# safety-net Get() covers any policy that arrived un-hydrated
|
||||
# (single-policy export path, or a row that bulk skipped).
|
||||
# [void]: Get() is [Boolean], so a bare call emits True/False
|
||||
# onto this cmdlet's output stream and pollutes stdout.
|
||||
[void]$policyObject.Get()
|
||||
}
|
||||
|
||||
Add-GraphNavigationProperties $policyObject
|
||||
|
||||
try {
|
||||
if ([IO.Directory]::Exists($exportFolder) -eq $false) {
|
||||
[IO.Directory]::CreateDirectory($exportFolder) | Out-Null
|
||||
}
|
||||
|
||||
if ($ExportSettings.ExportAssignments -ne $true -and $policyObject.Assignments) {
|
||||
Remove-Property $policyObject "Assignments"
|
||||
}
|
||||
|
||||
$fullPath = $policyObject.ExportToFile($exportFolder)
|
||||
|
||||
if ($fullPath) {
|
||||
Set-CacheObject "CurrentExportAssignments" $ExportSettings.ExportAssignments
|
||||
$policyObject.PolicyType.PostExportCommand($policyObject, $fullPath)
|
||||
|
||||
# Pass both $exportFolder (per-policy directory; some callers
|
||||
# need it for sidecar logic) AND $exportFolderRoot as the
|
||||
# explicit MigrationRoot. The latter eliminates the
|
||||
# ".Parent.FullName" guesswork in Add-GraphMigrationObject
|
||||
# which lands MigrationTable.json + Groups/ one level too high
|
||||
# whenever $Folder is already the export root — i.e. when
|
||||
# AddObjectType=false (per-policy files written straight to
|
||||
# the org folder, no per-type subfolder).
|
||||
Add-GraphMigrationInfo $policyObject -Folder $exportFolder -MigrationRoot $exportFolderRoot -MaxGroupDepth $ExportSettings.ExportNestedGroupLevels
|
||||
|
||||
if ($PassThru -eq $true) {
|
||||
$fullPath
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to export object" $_.Exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
End {
|
||||
if ($script:_exportSkippedNoTenantID -gt 0) {
|
||||
Write-Log ("Export finished - $($script:_exportSkippedNoTenantID) policy/policies were skipped because TenantID was not set. Folder: $exportFolderRoot") 2
|
||||
}
|
||||
else {
|
||||
Write-Log "Export finished. Folder: $exportFolderRoot"
|
||||
}
|
||||
|
||||
# Flush any deferred MigrationTable.json writes — but only when not running
|
||||
# inside a bulk-export pipeline (Start-GraphBulkExport sets the guard so it
|
||||
# can flush ONCE across all types instead of once per type, which would
|
||||
# rewrite the growing migration table N times).
|
||||
if (-not $script:_bulkExportDeferMigFlush -and
|
||||
(Get-Command Save-GraphMigrationFilesPending -ErrorAction SilentlyContinue)) {
|
||||
try { Save-GraphMigrationFilesPending } catch {
|
||||
Write-LogError "Export: failed to flush migration table(s)" $_.Exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Write the whole active settings store to a JSON file.
|
||||
|
||||
.DESCRIPTION
|
||||
Exported as Export-IMSettingsStore.
|
||||
|
||||
Works whatever the store is: a registry store is walked and written out as the
|
||||
same nested JSON a settings file uses, so a machine that has been configured
|
||||
through the UI can hand its configuration to a file that automation loads with
|
||||
Import-IMSettingsStore.
|
||||
|
||||
The file includes every value in the store, tenant-specific values (nested under
|
||||
the tenant id) and unregistered keys alike.
|
||||
|
||||
.PARAMETER Path
|
||||
The file to write. Its folder is created if needed. An existing file is replaced.
|
||||
|
||||
.EXAMPLE
|
||||
Export-IMSettingsStore -Path .\intune-settings.json
|
||||
|
||||
.EXAMPLE
|
||||
Export-IMSettingsStore -Path \\share\config\prod.json
|
||||
|
||||
Capture a working configuration once, commit it, and have every run import it
|
||||
instead of relying on whatever is on the machine.
|
||||
|
||||
.LINK
|
||||
Import-IMSettingsStore
|
||||
.LINK
|
||||
Get-IMSettingsStore
|
||||
#>
|
||||
function Export-SettingsStore
|
||||
{
|
||||
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, Position = 0)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
if(-not $PSCmdlet.ShouldProcess($Path, "Export the settings store")) { return }
|
||||
|
||||
Export-SettingsStoreToFile -Path $Path | Out-Null
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
function Get-AccessibleTenant {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
List the tenants the signed-in account can reach.
|
||||
|
||||
.DESCRIPTION
|
||||
Returns one row per tenant the current account has access to - its home
|
||||
tenant plus every tenant it is a guest in - so you can confirm a tenant is
|
||||
reachable before connecting to it, or feed a tenant picker.
|
||||
|
||||
Microsoft Graph cannot answer this question. Its tenant APIs resolve a
|
||||
single tenant you already know (findTenantInformationByTenantId) or list
|
||||
the members of a configured multi-tenant organization. The list of tenants
|
||||
an account can sign in to comes from Azure Resource Manager, which means a
|
||||
token for a second audience, so the Entra app registration must hold the
|
||||
delegated permission 'Azure Service Management / user_impersonation'. When
|
||||
it does not, this command writes a warning saying so and returns nothing.
|
||||
|
||||
Only providers that can mint that second token implement this; today that
|
||||
is MSAL. With another provider active the command warns and returns
|
||||
nothing rather than failing.
|
||||
|
||||
Switching to one of these tenants does not need a separate command: pass
|
||||
its id to Connect-IntuneManagement. With a cached account the acquire is
|
||||
silent, and the new tenant is registered as an additional token, so both
|
||||
stay live and -TokenId can address either.
|
||||
|
||||
Exported as Get-IMAccessibleTenant (the module applies the 'IM' prefix).
|
||||
|
||||
.PARAMETER TokenId
|
||||
Ask the provider that owns this token. Defaults to the current token.
|
||||
|
||||
.EXAMPLE
|
||||
# Every tenant the signed-in account can reach
|
||||
Get-IMAccessibleTenant
|
||||
|
||||
.EXAMPLE
|
||||
# Confirm a guest tenant is reachable, then connect to it silently
|
||||
$guest = Get-IMAccessibleTenant | Where-Object displayName -eq 'Fabrikam'
|
||||
Connect-IMIntuneManagement -Interactive -TenantId $guest.tenantId
|
||||
|
||||
.EXAMPLE
|
||||
# Export from two tenants in one script
|
||||
Connect-IMIntuneManagement -Interactive
|
||||
$home = (Get-IMAuthToken)[0].TokenId
|
||||
Connect-IMIntuneManagement -Interactive -TenantId (Get-IMAccessibleTenant)[1].tenantId
|
||||
$guest = (Get-IMAuthToken | Sort-Object TokenId)[-1].TokenId
|
||||
Start-IMGraphBulkExport -ExportFolder 'C:\Export\Home' -TokenId $home
|
||||
Start-IMGraphBulkExport -ExportFolder 'C:\Export\Guest' -TokenId $guest
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([PSCustomObject[]])]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$TokenId = 0
|
||||
)
|
||||
|
||||
# Same owner-first routing as Invoke-MSGraphAPI, in the same order: resolve the
|
||||
# default id 0 to the token that owns the session FIRST, then find that token's
|
||||
# provider. The provider that minted the token is the one that can mint a second
|
||||
# one for the same account. Resolving 0 directly returns $null and would fall
|
||||
# back to whichever provider is active - not necessarily the default token's
|
||||
# owner, since a second login only re-points the default when asked to. An
|
||||
# unregistered id keeps the active-provider fallback.
|
||||
if ($TokenId -le 0) { $TokenId = Get-DefaultAuthTokenId }
|
||||
|
||||
$authProvider = Resolve-AuthTokenProvider $TokenId
|
||||
if (-not $authProvider) { $authProvider = Get-AuthProvider }
|
||||
|
||||
if (-not $authProvider) {
|
||||
Write-Warning "Not signed in. Run Connect-IMIntuneManagement first."
|
||||
return
|
||||
}
|
||||
|
||||
$result = $authProvider.GetAccessibleTenants($TokenId)
|
||||
|
||||
if ($null -eq $result) {
|
||||
Write-Warning "The '$($authProvider.Id)' provider cannot list tenants. Listing them needs an Azure Service Management token for the signed-in account, which only the MSAL provider acquires. Connect with -Provider MSAL, or pass a known tenant id straight to Connect-IMIntuneManagement."
|
||||
return
|
||||
}
|
||||
|
||||
if ($result.ConsentMissing) {
|
||||
Write-Warning $result.Message
|
||||
return
|
||||
}
|
||||
|
||||
if ($result.Message) { Write-Warning $result.Message }
|
||||
|
||||
return @($result.Tenants)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
function Get-AuthToken {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
List the authentication tokens currently held, across every provider.
|
||||
|
||||
.DESCRIPTION
|
||||
Returns one [IMAuthToken] per live token from the central token registry,
|
||||
regardless of which provider (MSAL / OAuth / MgGraph) acquired it. Each
|
||||
token is tagged with the environment it belongs to (Provider, TenantId,
|
||||
TenantName, Cloud) plus the account/app identity and expiry, and a global
|
||||
TokenId that uniquely identifies it.
|
||||
|
||||
Use the TokenId with -TokenId on other commands (e.g. Invoke-MSGraphAPI,
|
||||
Copy-GraphPolicy) to route a call to that specific environment. This lets
|
||||
you sign into several environments at once - even across different
|
||||
providers - and copy policies between them.
|
||||
|
||||
Exported as Get-IMAuthToken (the module applies the 'IM' command prefix).
|
||||
|
||||
.PARAMETER TokenId
|
||||
Return only the token with this global id.
|
||||
|
||||
.PARAMETER Provider
|
||||
Return only tokens owned by this provider (MSAL / OAuth / MgGraph).
|
||||
|
||||
.PARAMETER TenantId
|
||||
Return only tokens for this tenant id.
|
||||
|
||||
.EXAMPLE
|
||||
# Every environment you're signed into
|
||||
Get-IMAuthToken
|
||||
|
||||
.EXAMPLE
|
||||
# Pick the production tenant's token and document only that environment
|
||||
$prod = Get-IMAuthToken | Where-Object TenantName -eq 'Contoso Prod'
|
||||
Get-IMGraphPolicies -TokenId $prod.TokenId
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([IMAuthToken[]])]
|
||||
param(
|
||||
[int]$TokenId,
|
||||
# Completion instead of [ValidateSet([AuthProviderValues])] for PS5.1 import
|
||||
# compatibility (see Connect-IntuneManagement); unknown values just filter to none.
|
||||
[ArgumentCompleter({ param($commandName, $parameterName, $wordToComplete) @(& (Get-Module IntuneManagement) { Get-AuthProviderValues }) | Where-Object { $_ -like "$wordToComplete*" } })]
|
||||
[string]$Provider,
|
||||
[string]$TenantId
|
||||
)
|
||||
|
||||
$tokens = Get-AuthTokenList
|
||||
|
||||
if ($PSBoundParameters.ContainsKey('TokenId')) {
|
||||
$tokens = @($tokens | Where-Object { $_.TokenId -eq $TokenId })
|
||||
}
|
||||
if ($Provider) {
|
||||
$tokens = @($tokens | Where-Object { $_.Provider -eq $Provider })
|
||||
}
|
||||
if ($TenantId) {
|
||||
$tokens = @($tokens | Where-Object { $_.TenantId -eq $TenantId })
|
||||
}
|
||||
|
||||
return [IMAuthToken[]]@($tokens)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
function Get-DocumentationOutput {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Gets the registered documentation output providers.
|
||||
|
||||
.DESCRIPTION
|
||||
Returns the documentation output providers available to
|
||||
Start-GraphBulkDocumentation and the documentation user interfaces.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
[DocumentationRegistry]::Outputs |
|
||||
Sort-Object Name |
|
||||
Select-Object Name, Value
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
function Get-GraphDocumentation {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Document one Intune/Entra policy object and return the per-object result.
|
||||
|
||||
.DESCRIPTION
|
||||
Public, UI-independent entry point for documenting a single PolicyObject.
|
||||
Returns the PSCustomObject the output providers consume — does not write
|
||||
any files itself. Use Start-GraphBulkDocumentation for a batch run that
|
||||
also drives output providers.
|
||||
|
||||
Dispatch:
|
||||
1. Looks up a per-@odata.type custom handler in [DocumentationRegistry]
|
||||
2. If none claims the object, falls back to the schema-driven input-
|
||||
provider chain (Settings Catalog / ADMX / Intent / Compliance V2 /
|
||||
generic Profile)
|
||||
3. If no provider matches either, returns an empty result with
|
||||
InputType='NoProvider' rather than throwing
|
||||
|
||||
Phase 2 wires the dispatch shape; handlers and input providers are
|
||||
populated in phases 4 and 3.
|
||||
|
||||
.PARAMETER PolicyObject
|
||||
The IntunePolicyBase-derived object to document.
|
||||
|
||||
.PARAMETER Language
|
||||
Language code for translatable strings. Defaults to 'en'.
|
||||
|
||||
.PARAMETER Options
|
||||
Hashtable of engine-wide flags. Recognized keys:
|
||||
IncludeScripts [bool] include embedded script bodies (default true)
|
||||
ExcludeScriptSignature [bool] strip script signing blocks (default false)
|
||||
IncludePolicyId [bool] include policy ID in basic info (default false)
|
||||
ExcludeAssignments [bool] omit assignment rows (default false)
|
||||
PropertySeparator [string] separator for property collections
|
||||
ObjectSeparator [string] separator for object collections
|
||||
SkipNotConfigured [bool] omit empty or unconfigured settings and basic properties
|
||||
SkipDefaultValues [bool] omit default or unconfigured values
|
||||
SkipDisabled [bool] omit disabled settings
|
||||
SetUnconfiguredValue [bool] substitute declared unconfigured values
|
||||
SetDefaultValue [bool] substitute declared defaults
|
||||
NotConfiguredText [string] notConfigured | empty | asis
|
||||
ValueOutputProperty [string] value | valueWithLabel for ADMX settings
|
||||
SkipDocumentInfo [bool] omit the document-info header every output
|
||||
provider writes at the top of a Full document
|
||||
(Organization / Generated by / Generated date)
|
||||
SourceTenantUnavailable [bool] the source tenant of an export is unreachable:
|
||||
skip source-tenant-specific lookups (assignments,
|
||||
scope-tag/filter/app names, etc.). Generic Intune
|
||||
schema is still resolved from any connected tenant.
|
||||
(Legacy alias: OfflineDocumentation.)
|
||||
Outputs [hashtable] explicit per-provider output options
|
||||
|
||||
.OUTPUTS
|
||||
PSCustomObject — see [DocumentationContext]::ToResult for the contract.
|
||||
|
||||
.EXAMPLE
|
||||
$policy = Get-GraphPolicies -PolicyType ConditionalAccessType | Select-Object -First 1
|
||||
$doc = Get-GraphDocumentation -PolicyObject $policy
|
||||
$doc.FilteredSettings | Format-Table Name, Value
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory, ValueFromPipeline)] $PolicyObject,
|
||||
[string]$Language = 'en',
|
||||
[hashtable]$Options
|
||||
)
|
||||
|
||||
process {
|
||||
# Save/restore the module-wide language: Set-CurrentDocumentationContext
|
||||
# points Get-LanguageString at $Context.Language for the doc run; other
|
||||
# consumers (policy classes, compare) must not inherit it afterwards.
|
||||
$prevLang = $script:CurrentLanguage
|
||||
try {
|
||||
# Use the module-singleton context so cross-batch caches (ScopeTags,
|
||||
# CachedCfgSettings, CfgCategories, ADMXCategories) amortize across
|
||||
# successive Get-GraphDocumentation calls — same behavior as the bulk
|
||||
# path. ResetForObject inside Invoke-DocumentationForObject clears the
|
||||
# per-object accumulators; the caches persist.
|
||||
$ctx = Get-DocContextSingleton -Options $Options
|
||||
Set-DocumentationContextRunOptions -Context $ctx -Options $Options -Language $Language
|
||||
|
||||
# Ensure the object is hydrated before dispatch. Handlers / input
|
||||
# providers read fully populated JsonObject + sub-resources; a row
|
||||
# straight out of Get-GraphPolicies typically isn't full. Single-row
|
||||
# hydrate path takes the direct-GET branch in Invoke-PolicyHydrate.
|
||||
# Skip for file-loaded objects / source-tenant-unavailable docs — those
|
||||
# have no token and Invoke-PolicyHydrate would issue Graph calls under the
|
||||
# default token, hitting either an auth error or the wrong tenant.
|
||||
if ($PolicyObject -and $PolicyObject.PSObject.Properties['_IsFullObject'] -and
|
||||
-not $PolicyObject._IsFullObject -and $PolicyObject.Id -and $PolicyObject.PolicyType -and
|
||||
$PolicyObject.IsFromFile -ne $true -and -not $ctx.SourceTenantUnavailable) {
|
||||
Invoke-PolicyHydrate -Policies @($PolicyObject)
|
||||
}
|
||||
|
||||
Invoke-DocumentationForObject -PolicyObject $PolicyObject -Context $ctx
|
||||
}
|
||||
finally {
|
||||
$script:CurrentLanguage = $prevLang
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
function Get-GraphEffectivePermissions {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
What the signed-in identity can actually do per policy type: the app's
|
||||
token scopes combined with the user's Intune role permissions.
|
||||
|
||||
.DESCRIPTION
|
||||
For a delegated login the effective access to Intune is the intersection of
|
||||
two things: the scopes the APP was consented (the token's scp claim) and
|
||||
the Intune RBAC / Entra directory roles of the USER. The token only shows
|
||||
the first. This command evaluates both for every registered policy type
|
||||
and reports the combined level, so a script can find out before a bulk
|
||||
import that the user is read-only for Device configurations instead of
|
||||
collecting 403s halfway through.
|
||||
|
||||
Each row carries two views of the same answer. The concrete capability
|
||||
labels are what the Permissions popup shows: Required (Read or ReadWrite -
|
||||
what the type needs), TokenAccess / RoleAccess / EffectiveAccess (None,
|
||||
Read or ReadWrite - what each layer grants in those terms; RoleAccess is
|
||||
$null when no role layer applies) and Result (Match / Read-only / No
|
||||
access). The *Level fields below are the raw enum kept for programmatic
|
||||
callers.
|
||||
|
||||
Levels: Full (usable), Limited (readable, not writable), None (unusable).
|
||||
RbacLevel carries the user-role verdict: the Intune RBAC level for
|
||||
Intune-governed types, or - for Entra ID objects such as Conditional
|
||||
Access and Named Locations - the level implied by the directory roles in
|
||||
the token (Conditional Access / Security Administrator = write, Global /
|
||||
Security Reader = read-only). It is $null when no role governs the type:
|
||||
an app-only token (application permissions bypass Intune RBAC), a type
|
||||
neither layer governs (Terms of Use, branding), no governing directory
|
||||
role in the token, or a failed lookup. EffectiveLevel is then the TokenLevel.
|
||||
|
||||
The Intune answer is read once per token and refreshed when the token is
|
||||
re-issued (Force refresh in the Profile popup, or a new sign-in). Scope
|
||||
tags are not modelled: a user scoped to some tags can still be refused on
|
||||
individual objects.
|
||||
|
||||
Exported as Get-IMGraphEffectivePermissions (the module applies the 'IM'
|
||||
command prefix).
|
||||
|
||||
.PARAMETER TokenId
|
||||
Evaluate the token with this id (see Get-IMAuthToken) instead of the
|
||||
default token.
|
||||
|
||||
.PARAMETER PolicyType
|
||||
Only report these policy type ids (e.g. DeviceConfiguration, SettingsCatalog).
|
||||
|
||||
.PARAMETER Raw
|
||||
Return the Intune RBAC context itself - source (DirectoryRole shortcut or
|
||||
Graph), the resource actions the user is Allowed, the Catalog of actions
|
||||
Intune defines at all (from deviceManagement/resourceOperations; $null when
|
||||
that call failed) and the raw getEffectivePermissions response - instead of
|
||||
the per-type table. $null when RBAC does not apply.
|
||||
|
||||
.EXAMPLE
|
||||
# Types the signed-in user cannot change, with the reason
|
||||
Get-IMGraphEffectivePermissions | Where-Object EffectiveLevel -ne Full |
|
||||
Format-Table Id, TokenLevel, RbacLevel, EffectiveLevel, Reason
|
||||
|
||||
.EXAMPLE
|
||||
# Abort a bulk import if Settings Catalog is not writable
|
||||
$sc = Get-IMGraphEffectivePermissions -PolicyType SettingsCatalog
|
||||
if($sc.EffectiveLevel -ne 'Full') { throw "Settings Catalog: $($sc.Reason)" }
|
||||
|
||||
.EXAMPLE
|
||||
# Every Intune resource action the user is allowed
|
||||
(Get-IMGraphEffectivePermissions -Raw).Allowed | Sort-Object
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int]$TokenId = 0,
|
||||
|
||||
[ArgumentCompleter({ & (Get-Module IntuneManagement) { Get-IntunePolicyTypeValues } })]
|
||||
[string[]]$PolicyType,
|
||||
|
||||
[switch]$Raw
|
||||
)
|
||||
|
||||
$claims = Get-AccessTokenClaims -TokenId $TokenId
|
||||
if(-not $claims) {
|
||||
Write-Log "Get-GraphEffectivePermissions: no access token available. Connect first (Connect-IMIntuneManagement)." 2
|
||||
return
|
||||
}
|
||||
|
||||
$granted = Get-GrantedGraphPermissions ([PSCustomObject]@{ JWTAccessToken = [PSCustomObject]@{ Payload = $claims } })
|
||||
$rbac = Get-IntuneRbacContext -Claims $claims -TokenId $TokenId -IgnoreSetting
|
||||
|
||||
if($Raw) { return $rbac }
|
||||
|
||||
$types = @($script:IntuneTypes | Where-Object { $_ })
|
||||
if($PolicyType) { $types = @($types | Where-Object { $_.Id -in $PolicyType }) }
|
||||
|
||||
foreach($type in $types) {
|
||||
$tokenLevel = Get-PolicyTypeAccessLevel $type $granted
|
||||
$tokenInfo = Get-PolicyTypeAccessInfo $type $granted $tokenLevel
|
||||
|
||||
$grantedSet = if($granted) { ConvertTo-PermissionSet $granted } else { $null }
|
||||
$missingScopes = @()
|
||||
foreach($perm in @($type._Permissions | Where-Object { $_ })) {
|
||||
if(-not $grantedSet) { continue }
|
||||
if($grantedSet.Contains($perm)) { continue }
|
||||
# A required Read scope is covered by the granted ReadWrite superset.
|
||||
$writeVariant = Get-PermissionWriteVariant $perm
|
||||
if($writeVariant -and $grantedSet.Contains($writeVariant)) { continue }
|
||||
$missingScopes += $perm
|
||||
}
|
||||
|
||||
# Intune-governed types get the RBAC verdict; Entra-governed types
|
||||
# (Conditional Access etc.) fall back to the directory-role verdict.
|
||||
$verdict = if($rbac) { Get-PolicyTypeRbacAccess $type $rbac } else { $null }
|
||||
if(-not $verdict) { $verdict = Get-PolicyTypeEntraRoleAccess $type $claims }
|
||||
# Catch-all: Global Reader reads the whole tenant but writes nothing.
|
||||
if(-not $verdict) { $verdict = Get-PolicyTypeDirectoryRoleReadFloor $type $claims }
|
||||
$rbacLevel = if($verdict) { $verdict.Level } else { $null }
|
||||
$effective = if($verdict) { Get-WorstAccessLevel $tokenLevel $verdict.Level } else { $tokenLevel }
|
||||
$category = Get-PolicyTypeRbacCategory $type
|
||||
|
||||
# Concrete capability labels for the popup: what the type needs (Required),
|
||||
# what each layer grants in those terms (None/Read/ReadWrite), and the
|
||||
# bottom line (Match/Read-only/No access). The *Level fields above stay for
|
||||
# programmatic callers that compare against 'Full'/'Limited'/'None'.
|
||||
$required = Get-PolicyTypeRequiredAccess $type
|
||||
|
||||
# A role that allows some writes but not all is Limited, but not read-only.
|
||||
# The role column says so whenever the verdict does; the effective and
|
||||
# result columns only when the token can write too - a read-only token
|
||||
# over a partial-write role really is read-only.
|
||||
$rolePartial = [bool]($verdict -and $verdict.Partial)
|
||||
$effectivePartial = $rolePartial -and $tokenLevel -eq [APIAccess]::Full
|
||||
|
||||
[PSCustomObject]@{
|
||||
Id = $type.Id
|
||||
Title = $type.Title
|
||||
Group = if($type.PolicyGroup) { $type.PolicyGroup.Id } else { $null }
|
||||
ResourceCategory = if($category) { $category.Category } else { $null }
|
||||
Required = $required
|
||||
TokenAccess = Get-AccessCapabilityLabel $tokenLevel $required
|
||||
RoleAccess = if($verdict) { Get-AccessCapabilityLabel $verdict.Level $required -PartialWrite:$rolePartial } else { $null }
|
||||
EffectiveAccess = Get-AccessCapabilityLabel $effective $required -PartialWrite:$effectivePartial
|
||||
Result = Get-AccessResultLabel $effective -PartialWrite:$effectivePartial
|
||||
TokenLevel = [string]$tokenLevel
|
||||
RbacLevel = if($null -ne $rbacLevel) { [string]$rbacLevel } else { $null }
|
||||
EffectiveLevel = [string]$effective
|
||||
MissingScopes = $missingScopes
|
||||
MissingActions = if($verdict) { @($verdict.Missing) } else { @() }
|
||||
Reason = (@($tokenInfo, $(if($verdict) { $verdict.Info })) | Where-Object { $_ }) -join "; "
|
||||
RbacSource = if($rbac) { $rbac.Source } else { $null }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
function Get-GraphPolicies {
|
||||
[CmdletBinding(DefaultParameterSetName = 'PolicyType')]
|
||||
[OutputType([IntunePolicyBase[]])]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'PolicyType', Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
|
||||
# Tab-completion for registered PolicyType IDs. Deliberately NOT a
|
||||
# [ValidateSet([IntunePolicyTypeValues])]: a generator-backed ValidateSet
|
||||
# (1) makes the cmdlet uncallable when no types are loaded yet (the
|
||||
# generator returns an empty set and binding throws "validValues out of
|
||||
# range"), and (2) cannot be mocked by Pester 3.4. Unknown ids are already
|
||||
# filtered out below (Where-Object Id -eq), so completion is enough.
|
||||
[ArgumentCompleter({ param($commandName, $parameterName, $wordToComplete) @(& (Get-Module IntuneManagement) { Get-IntunePolicyTypeValues }) | Where-Object { $_ -like "$wordToComplete*" } })]
|
||||
[string[]]$PolicyType,
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'PolicyGroup', Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
|
||||
[ArgumentCompleter({ param($commandName, $parameterName, $wordToComplete) @(& (Get-Module IntuneManagement) { Get-IntunePolicyGroupValues }) | Where-Object { $_ -like "$wordToComplete*" } })]
|
||||
[string[]]$PolicyGroup,
|
||||
[Parameter(Mandatory = $false, ParameterSetName = 'PolicyType')]
|
||||
[Parameter(Mandatory = $false, ParameterSetName = 'PolicyGroup')]
|
||||
[switch]
|
||||
$SinglePage,
|
||||
[string]
|
||||
[ValidateSet("NextPage", "AllRemainingPages")]
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'ObjectsPaging')]
|
||||
$Paging,
|
||||
|
||||
# When set, the returned policies will have their .Object.assignments populated.
|
||||
# Policy types that don't support assignments (SupportsAssignments = $false) are filtered out.
|
||||
# For types where assignments cannot be expanded inline, /assignments is batch-fetched after listing.
|
||||
[Parameter(Mandatory = $false, ParameterSetName = 'PolicyType')]
|
||||
[Parameter(Mandatory = $false, ParameterSetName = 'PolicyGroup')]
|
||||
[switch]
|
||||
$IncludeAssignments,
|
||||
|
||||
# Name prefix to search for. Sent to Graph as startswith() on the type's
|
||||
# name property when the endpoint supports it (PolicyType.SupportsNameFilter);
|
||||
# the result set is always re-checked client-side, so types whose endpoint
|
||||
# rejects the filter still return only matching policies.
|
||||
[Parameter(Mandatory = $false, ParameterSetName = 'PolicyType')]
|
||||
[Parameter(Mandatory = $false, ParameterSetName = 'PolicyGroup')]
|
||||
[string]
|
||||
$NameFilter,
|
||||
|
||||
# Specifies environment to get policies from. Default is current logged on environment.
|
||||
[Int]
|
||||
$TokenId = (Get-DefaultTokenId)
|
||||
)
|
||||
|
||||
$params = @{}
|
||||
$graphPolicies = [System.Collections.Generic.List[IntunePolicyBase]]::new()
|
||||
$newPageObject = $false
|
||||
|
||||
# URL-keyed coalescing: when N policy types resolve to the same listing URL
|
||||
# (e.g. all 6 deviceEnrollmentConfigurations subtypes), we issue ONE list request
|
||||
# and run each row through every consumer's CheckPolicy in registration order —
|
||||
# first non-null match wins. Without this, group bulk-exports were issuing 5+
|
||||
# identical sub-requests in the $batch and discarding 80% of each response.
|
||||
#
|
||||
# Entry shapes:
|
||||
# batch entry : { BatchObject = <{id,method,url,headers}>, Types = [PolicyType...] }
|
||||
# api entry : { ListURL = <string>, Types = [PolicyType...] }
|
||||
# The Types list preserves registration order so CheckPolicy ties are deterministic.
|
||||
$batchEntries = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
$apiEntries = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
if ($PSCmdlet.ParameterSetName -ne "ObjectsPaging") {
|
||||
|
||||
$policyTypes = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
# -PolicyType and -PolicyGroup are separate parameter sets, so only one is
|
||||
# ever populated. An unknown id is logged and raised as a non-terminating
|
||||
# error by the resolver - the same contract as the bulk drivers, which this
|
||||
# cmdlet used to fall short of by dropping it in silence.
|
||||
$selection = Resolve-IntuneTargetSelectors -PolicyType $PolicyType -PolicyGroup $PolicyGroup -Caller 'Get-GraphPolicies'
|
||||
$policyTypes.AddRange([object[]]$selection.Types)
|
||||
|
||||
# IMPORTANT: -IncludeAssignments controls whether assignments get fetched
|
||||
# alongside the policies, NOT which types get listed. Types with
|
||||
# SupportsAssignments=$false (Conditional Access, Named Locations, Terms of
|
||||
# Use, Filters, Role Definitions, ADMX Files, Reusable Settings, several
|
||||
# Tenant-Admin extras, etc.) still need to be listed — the assignment-fetch
|
||||
# in Add-GraphPolicyAssignments already skips them at the per-policy level.
|
||||
# Filtering them out here used to drop the whole type silently from bulk
|
||||
# export, which is why those folders were empty.
|
||||
|
||||
# Every type that can describe its list call as a batch sub-request does so,
|
||||
# whatever the batching setting says: Invoke-GraphBatchRequest decides whether
|
||||
# those go out as one $batch or as direct calls, and either way the sub-
|
||||
# request carries the type's own Accept header. Choosing the plain-URL branch
|
||||
# here when batching was off sent the list call with the wrapper's default
|
||||
# metadata instead, so an Applications export with batching off carried
|
||||
# @odata annotations and navigation links the batched export never had.
|
||||
$useBatchAPI = $true
|
||||
|
||||
# Coalesce by URL only for types that verify each returned row. Types that
|
||||
# accept every row must keep their own request; otherwise the first broad
|
||||
# type on a shared endpoint can absorb siblings and silently misclassify
|
||||
# policies.
|
||||
$batchByUrl = @{}
|
||||
$apiByUrl = @{}
|
||||
|
||||
foreach ($policyTypeObj in $policyTypes) {
|
||||
$batchObject = $null
|
||||
if ($useBatchAPI) { $batchObject = $policyTypeObj.GetListBatchObject($NameFilter) }
|
||||
if ($batchObject) {
|
||||
$key = if($policyTypeObj.VerifyObject) { $batchObject.url } else { "$($batchObject.url)|$($policyTypeObj.Id)" }
|
||||
if ($batchByUrl.ContainsKey($key)) {
|
||||
[void]$batchByUrl[$key].Types.Add($policyTypeObj)
|
||||
}
|
||||
else {
|
||||
$entry = [PSCustomObject]@{
|
||||
BatchObject = $batchObject
|
||||
Types = [System.Collections.Generic.List[object]]@($policyTypeObj)
|
||||
}
|
||||
$batchByUrl[$key] = $entry
|
||||
[void]$batchEntries.Add($entry)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
$listURL = $policyTypeObj.GetListURL($NameFilter)
|
||||
if (-not $listURL) { continue }
|
||||
$listKey = if($policyTypeObj.VerifyObject) { $listURL } else { "$listURL|$($policyTypeObj.Id)" }
|
||||
if ($apiByUrl.ContainsKey($listKey)) {
|
||||
[void]$apiByUrl[$listKey].Types.Add($policyTypeObj)
|
||||
}
|
||||
else {
|
||||
$entry = [PSCustomObject]@{
|
||||
ListURL = $listURL
|
||||
Types = [System.Collections.Generic.List[object]]@($policyTypeObj)
|
||||
}
|
||||
$apiByUrl[$listKey] = $entry
|
||||
[void]$apiEntries.Add($entry)
|
||||
}
|
||||
}
|
||||
|
||||
if ($SinglePage -eq $true) { $newPageObject = $true }
|
||||
else { $params.Add("AllPages", $true) }
|
||||
}
|
||||
elseif ($script:GraphPagingCache) {
|
||||
# Resume paging: cache stores the same {BatchObject,Types} / {ListURL,Types}
|
||||
# entries we built above, with BatchObject.url already set to the next-page link.
|
||||
foreach ($e in $script:GraphPagingCache.BatchTypes) { [void]$batchEntries.Add($e) }
|
||||
foreach ($e in $script:GraphPagingCache.APITypes) { [void]$apiEntries.Add($e) }
|
||||
# The nextLink carries any server-side filter, but the client-side
|
||||
# re-check below needs the original search text too.
|
||||
$NameFilter = $script:GraphPagingCache.NameFilter
|
||||
if ($Paging -eq "AllRemainingPages") { $params.Add("AllPages", $true) }
|
||||
$newPageObject = $true
|
||||
}
|
||||
else {
|
||||
Write-LogDebug "No more pages"
|
||||
return
|
||||
}
|
||||
|
||||
$params.Add("TokenId", $TokenId)
|
||||
|
||||
if ($newPageObject -eq $true) {
|
||||
$script:GraphPagingCache = [PSCustomObject]@{
|
||||
BatchObjects = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
BatchTypes = [System.Collections.Generic.List[object]]::new()
|
||||
APITypes = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
NameFilter = $NameFilter
|
||||
}
|
||||
}
|
||||
else {
|
||||
$script:GraphPagingCache = $null
|
||||
}
|
||||
|
||||
# A name filter can be rejected by an endpoint we have not probed (several
|
||||
# Intune endpoints answer 400 or even 500 to any $filter). Retrying that
|
||||
# request unfiltered turns "the search silently found nothing" into "the
|
||||
# search worked, just slower" - the client-side re-check at the end still
|
||||
# narrows the result. Only the first attempt retries, and never while
|
||||
# resuming paging (those URLs are nextLinks, not ones we can rebuild).
|
||||
$canRetryUnfiltered = ($NameFilter -and $PSCmdlet.ParameterSetName -ne "ObjectsPaging")
|
||||
|
||||
$pendingBatchEntries = $batchEntries
|
||||
$batchAttempt = 0
|
||||
while ($pendingBatchEntries.Count -gt 0) {
|
||||
# Flatten entries into the batch-objects list passed to Invoke-GraphBatchRequest,
|
||||
# and build a sub-request-id -> entry lookup so we can fan rows back out.
|
||||
$batchObjects = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
$entryById = @{}
|
||||
foreach ($entry in $pendingBatchEntries) {
|
||||
[void]$batchObjects.Add($entry.BatchObject)
|
||||
$entryById["$($entry.BatchObject.id)"] = $entry
|
||||
}
|
||||
|
||||
# -IncludedFailed: a rejected name filter comes back as a failed sub-result
|
||||
# (a 400 body from $batch, or status 0 with no body from a direct call) and
|
||||
# the retry below needs to see it. Without it the dispatcher dropped failed
|
||||
# results before this loop, so the unfiltered retry never fired.
|
||||
$batchResults = Invoke-GraphBatchRequest $batchObjects "Policy objects" @params -IncludedFailed
|
||||
$retryBatchEntries = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
foreach ($batchResult in $batchResults) {
|
||||
$entry = $entryById["$($batchResult.ID)"]
|
||||
if (-not $entry) { continue }
|
||||
|
||||
# Failed = no body at all (a direct call that returned nothing), an error body
|
||||
# (a $batch sub-result), or an explicit non-2xx status. A body with no status
|
||||
# property is a success - some result shapes never carried one.
|
||||
$listFailed = ((-not $batchResult.body) -or
|
||||
($batchResult.body.PSObject.Properties['error']) -or
|
||||
($batchResult.PSObject.Properties['Status'] -and [int]$batchResult.Status -ge 300))
|
||||
if ($canRetryUnfiltered -and $batchAttempt -eq 0 -and $listFailed) {
|
||||
$unfilteredUrl = $entry.Types[0].GetListBatchObject().url
|
||||
if ($unfilteredUrl -ne $entry.BatchObject.url) {
|
||||
Write-Log "$($entry.Types[0].ID): endpoint rejected the name filter - retrying without it and filtering locally" 2
|
||||
$entry.BatchObject.url = $unfilteredUrl
|
||||
[void]$retryBatchEntries.Add($entry)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if ($listFailed) {
|
||||
Write-Log "$($entry.Types[0].ID): list request failed (status $($batchResult.Status)) - no objects for this type" 2
|
||||
continue
|
||||
}
|
||||
|
||||
if ($batchResult.body.value) {
|
||||
foreach ($v in $batchResult.body.value) {
|
||||
# Walk consumers in registration order; first CheckPolicy
|
||||
# that accepts the row (GetObject returns non-null) wins.
|
||||
foreach ($pt in $entry.Types) {
|
||||
$tmpPolicy = $pt.GetObject($v)
|
||||
if ($tmpPolicy) {
|
||||
[void]$graphPolicies.Add($tmpPolicy)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($entry.Types[0].SingleObject -and $batchResult.body -and -not $batchResult.body.PSObject.Properties['error']) {
|
||||
# Single-object endpoint: the body IS the object — no value array.
|
||||
foreach ($pt in $entry.Types) {
|
||||
$tmpPolicy = $pt.GetObject($batchResult.body)
|
||||
if ($tmpPolicy) {
|
||||
[void]$graphPolicies.Add($tmpPolicy)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($batchResult.body.'@odata.nextLink' -and -not $params.Contains("AllPages")) {
|
||||
$nextLink = $batchResult.body.'@odata.nextLink'
|
||||
# Use the first sibling's API for the offset slice — siblings share _API by definition.
|
||||
$apiPrefix = $entry.Types[0].API
|
||||
$entry.BatchObject.url = $nextLink.Substring($nextLink.IndexOf($apiPrefix))
|
||||
[void]$script:GraphPagingCache.BatchObjects.Add($entry.BatchObject)
|
||||
[void]$script:GraphPagingCache.BatchTypes.Add($entry)
|
||||
}
|
||||
}
|
||||
|
||||
$pendingBatchEntries = $retryBatchEntries
|
||||
$batchAttempt++
|
||||
}
|
||||
|
||||
# For APIs not supported in batch requests
|
||||
foreach ($entry in $apiEntries) {
|
||||
$responseContent = Invoke-MSGraphAPI -Url $entry.ListURL @params
|
||||
|
||||
if ($canRetryUnfiltered -and -not $responseContent) {
|
||||
$unfilteredUrl = $entry.Types[0].GetListURL()
|
||||
if ($unfilteredUrl -ne $entry.ListURL) {
|
||||
Write-Log "$($entry.Types[0].ID): endpoint rejected the name filter - retrying without it and filtering locally" 2
|
||||
$responseContent = Invoke-MSGraphAPI -Url $unfilteredUrl @params
|
||||
}
|
||||
}
|
||||
|
||||
$primaryId = $entry.Types[0].ID
|
||||
$extra = if ($entry.Types.Count -gt 1) { " (+$($entry.Types.Count - 1) sibling type(s))" } else { "" }
|
||||
Write-Log "Value return count for $primaryId$extra $(($responseContent.value | Measure-Object).Count)"
|
||||
foreach ($listObject in $responseContent.value) {
|
||||
foreach ($pt in $entry.Types) {
|
||||
$tmpPolicy = $pt.GetObject($listObject)
|
||||
if ($tmpPolicy) {
|
||||
[void]$graphPolicies.Add($tmpPolicy)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $responseContent.value -and $entry.Types[0].SingleObject -and $responseContent) {
|
||||
# Single-object endpoint: the response IS the object — no value array.
|
||||
foreach ($pt in $entry.Types) {
|
||||
$tmpPolicy = $pt.GetObject($responseContent)
|
||||
if ($tmpPolicy) {
|
||||
[void]$graphPolicies.Add($tmpPolicy)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($responseContent.'@odata.nextLink' -and -not $params.Contains("AllPages")) {
|
||||
$entry.ListURL = $responseContent.'@odata.nextLink'
|
||||
[void]$script:GraphPagingCache.APITypes.Add($entry)
|
||||
}
|
||||
}
|
||||
|
||||
if ($script:GraphPagingCache -and $script:GraphPagingCache.BatchObjects.Count -eq 0 -and $script:GraphPagingCache.APITypes.Count -eq 0) {
|
||||
$script:GraphPagingCache = $null
|
||||
}
|
||||
|
||||
if ($NameFilter) {
|
||||
# Re-check every row client-side. Not every endpoint honours the
|
||||
# server-side clause (PolicyType.SupportsNameFilter is $false for
|
||||
# deviceCompliancePolicies v1 and assignmentFilters), and a type whose
|
||||
# CheckPolicy claims a row from a coalesced sibling request may not have
|
||||
# been filtered at all.
|
||||
$matching = [System.Collections.Generic.List[IntunePolicyBase]]::new()
|
||||
foreach ($p in $graphPolicies) {
|
||||
# Same semantics as the server-side clause: case-insensitive substring.
|
||||
if ("$($p.Name)".IndexOf($NameFilter, [System.StringComparison]::InvariantCultureIgnoreCase) -ge 0) {
|
||||
[void]$matching.Add($p)
|
||||
}
|
||||
}
|
||||
$graphPolicies = $matching
|
||||
}
|
||||
|
||||
if ($graphPolicies.Count -gt 0) {
|
||||
# Resolve the tenant id provider-agnostically. Get-TokenInfo only sees the
|
||||
# MSAL token registry, so on the MgGraph provider $tokenInfo is $null and
|
||||
# every policy ended up with TenantID = $null — which Export-GraphPolicy
|
||||
# then silently skipped at the `-not $policyObject.TenantID` guard, leading
|
||||
# to "Exported N policies" telemetry with zero files on disk.
|
||||
#
|
||||
# Get-OperationTokenInfo, not Get-TokenInfo: Graph runs this call against ONE
|
||||
# token, and 0 (the default parameter value, and the caller's spelling for
|
||||
# "the default token") means "no filter, every token" to Get-TokenInfo. With a
|
||||
# second tenant signed in, every listed policy was stamped with an ARRAY of
|
||||
# tenant ids and an array _TokenID, so the export masked the wrong tenant and
|
||||
# the per-tenant settings lookups keyed off a joined string.
|
||||
$tokenInfo = Get-OperationTokenInfo $TokenId
|
||||
$tenantId = if ($tokenInfo) { $tokenInfo.TenantID } else { $null }
|
||||
$tokenIdVal = if ($tokenInfo) { $tokenInfo.Id } else { 0 }
|
||||
if (-not $tenantId) {
|
||||
try {
|
||||
$provider = Get-AuthProvider
|
||||
if ($provider) {
|
||||
$userInfo = $provider.GetUserInfo($TokenId)
|
||||
if ($userInfo -and $userInfo.TenantId) { $tenantId = $userInfo.TenantId }
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
if (-not $tenantId) {
|
||||
Write-Log "Get-GraphPolicies: could not resolve TenantID for export - policies will be missing this property" 2
|
||||
}
|
||||
foreach ($p in $graphPolicies) {
|
||||
$p.TenantID = $tenantId
|
||||
$p._TokenID = $tokenIdVal
|
||||
}
|
||||
|
||||
if ($IncludeAssignments -eq $true) {
|
||||
Add-GraphPolicyAssignments -Policies $graphPolicies -TokenId $TokenId
|
||||
}
|
||||
}
|
||||
|
||||
return $graphPolicies.ToArray()
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
function Get-GraphPolicyFromFile {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
|
||||
[Alias("FileInfo")]
|
||||
[IO.FileInfo[]]
|
||||
$InputObject,
|
||||
[IntunePolicyTypeBase[]]
|
||||
$FromPolicyTypes,
|
||||
[String]
|
||||
$TenantId
|
||||
)
|
||||
|
||||
Begin {
|
||||
Write-LogDebug "Get Policies from file(s)"
|
||||
|
||||
$policyObjects = @()
|
||||
}
|
||||
|
||||
Process {
|
||||
foreach ($fi in $InputObject) {
|
||||
if ($fi.Exists -eq $false) {
|
||||
Write-Log "File $($fi.FullName) not found. Cannot load policy" 3
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
Write-LogDebug "Get policy from file $($fi.FullName)"
|
||||
$jsonObj = ConvertFrom-Json ([IO.File]::ReadAllText($fi.FullName)) -ErrorAction Stop
|
||||
$policyType = Get-PoliciesTypeFromObject $jsonObj $FromPolicyTypes
|
||||
if ($policyType) {
|
||||
$policyObject = $policyType.GetObject($fi)
|
||||
if ($policyObject) {
|
||||
if ($TenantId) {
|
||||
$policyObject.TenantId = $TenantId
|
||||
}
|
||||
$policyObjects += $policyObject
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Expected not to find some policies if policy types is passed in
|
||||
Write-LogDebug "Could not get policy type from file $($fi.FullName)" 3
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogDebug "Could get policy from file $($fi.FullName)" $_.Exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
End {
|
||||
Write-LogDebug "Get policy from file finished"
|
||||
if ($policyObjects.Count -gt 0) {
|
||||
return $policyObjects
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Read an IntuneManagement setting by key.
|
||||
|
||||
.DESCRIPTION
|
||||
Exported as Get-IMSetting.
|
||||
|
||||
The public read side of the settings system. A key is all that is needed - the
|
||||
storage path is taken from the setting's registration, so automation never has
|
||||
to know that "GraphPageSize" lives under "IntuneManager" or that the store is a
|
||||
registry key on Windows and a JSON file everywhere else.
|
||||
|
||||
Resolution order is the same one the application itself uses: the value for the
|
||||
connected tenant, then the global value, then the value the setting was
|
||||
registered with. -Scope pins it to one level.
|
||||
|
||||
With no -Key, every registered setting is returned.
|
||||
|
||||
.PARAMETER Key
|
||||
The setting key. Accepts pipeline input. Omit to return all registered settings.
|
||||
|
||||
.PARAMETER Scope
|
||||
Effective (default) resolves tenant, then global, then the registered default.
|
||||
Global reads the global value only. Tenant reads the tenant value only.
|
||||
|
||||
A scoped read returns $null when nothing is stored at that scope - it does NOT
|
||||
fall back to the registered default, so "not overridden here" stays
|
||||
distinguishable from "overridden to the same value as the default". With
|
||||
-Detailed the Source of such a read is 'NotSet'. Tenant scope needs a tenant:
|
||||
it reports an error rather than a value when none is connected and no -TenantID
|
||||
is given.
|
||||
|
||||
.PARAMETER TenantID
|
||||
Which tenant to resolve against. Defaults to the connected tenant.
|
||||
|
||||
.PARAMETER SubPath
|
||||
Storage path for a key that is not a registered setting (the hidden keys such as
|
||||
ExportReplaceTokens, and per-feature state). Required in that case - an
|
||||
unregistered key has no registration to take a path from. Reported and ignored
|
||||
for a registered one, exactly as on Set-IMSetting.
|
||||
|
||||
An unregistered key has no registered default, so its Source is Tenant, Global
|
||||
or NotSet - never Default - and no type normalization is applied: the value
|
||||
comes back as the string the store holds.
|
||||
|
||||
.PARAMETER Detailed
|
||||
Return the value together with where it came from (Tenant, Global, Default or
|
||||
NotSet), its type, its storage path and its registered default, instead of just
|
||||
the value.
|
||||
|
||||
.EXAMPLE
|
||||
Get-IMSetting ExportFolder
|
||||
|
||||
.EXAMPLE
|
||||
Get-IMSetting UseBatchAPI -Detailed
|
||||
|
||||
Shows whether the value in effect was set for this tenant, set globally, or is
|
||||
just the default - which is what to check before changing it.
|
||||
|
||||
.EXAMPLE
|
||||
'AddCompanyName','AddObjectType' | Get-IMSetting
|
||||
|
||||
.EXAMPLE
|
||||
Get-IMSetting | Where-Object Source -ne 'Default'
|
||||
|
||||
Every setting that has actually been configured.
|
||||
|
||||
.EXAMPLE
|
||||
Set-IMSetting ExportReplaceTokens 'TenantId' -SubPath 'IntuneManager'
|
||||
Get-IMSetting ExportReplaceTokens -SubPath 'IntuneManager'
|
||||
|
||||
Reading back a hidden key needs the same path the write used.
|
||||
|
||||
.LINK
|
||||
Set-IMSetting
|
||||
.LINK
|
||||
Get-IMSettingDefinition
|
||||
.LINK
|
||||
Get-IMSettingsStore
|
||||
#>
|
||||
function Get-Setting
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
|
||||
[string]$Key,
|
||||
[ValidateSet("Effective", "Global", "Tenant")]
|
||||
[string]$Scope = "Effective",
|
||||
[string]$TenantID,
|
||||
# $null, not "": "" is the root of the store, so "not supplied" and "the
|
||||
# root" have to stay distinguishable. Same convention as Set-IMSetting.
|
||||
$SubPath = $null,
|
||||
[switch]$Detailed
|
||||
)
|
||||
|
||||
process
|
||||
{
|
||||
# No key = every registered setting. A bare list of values would be
|
||||
# meaningless without the keys beside them, so that form is always detailed.
|
||||
$keys = @($Key)
|
||||
if(-not $Key)
|
||||
{
|
||||
$keys = @(Get-SettingsSections | ForEach-Object { $_.Values } | ForEach-Object Key)
|
||||
$Detailed = $true
|
||||
|
||||
# Enumerating the registered settings takes every path from its own
|
||||
# registration; there is nothing for a single -SubPath to apply to.
|
||||
if($null -ne $SubPath) { Write-Log "Ignoring -SubPath '$SubPath': it applies to a single unregistered -Key, not to a listing of registered settings" 2 }
|
||||
$SubPath = $null
|
||||
}
|
||||
|
||||
foreach($settingKey in $keys)
|
||||
{
|
||||
$resolved = Resolve-SettingValue -Key $settingKey -TenantID $TenantID -SubPath $SubPath `
|
||||
-GlobalOnly:($Scope -eq "Global") -TenantOnly:($Scope -eq "Tenant")
|
||||
|
||||
if(-not $resolved) { continue }
|
||||
|
||||
if($Detailed) { $resolved }
|
||||
else { $resolved.Value }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
List the settings the module knows about - keys, types, defaults and where they
|
||||
are stored.
|
||||
|
||||
.DESCRIPTION
|
||||
Exported as Get-IMSettingDefinition.
|
||||
|
||||
Discovery for the settings API. Automation should not have to read the source or
|
||||
the settings dialog to find out that the export folder key is called
|
||||
"ExportFolder", that it is a Folder setting, or that it is stored under
|
||||
"IntuneManagerExportSettings".
|
||||
|
||||
Only registered settings are listed - the ones the settings dialog shows. A few
|
||||
keys are deliberately unregistered (per-feature state, and hidden keys such as
|
||||
ExportReplaceTokens); those need an explicit -SubPath when read or written.
|
||||
|
||||
.PARAMETER Key
|
||||
Return one setting. Wildcards are supported.
|
||||
|
||||
.PARAMETER Section
|
||||
Return only the settings in one section (General, ImportExport, IntuneManager,
|
||||
...). Wildcards are supported.
|
||||
|
||||
.EXAMPLE
|
||||
Get-IMSettingDefinition | Format-Table Key, Section, Type, DefaultValue
|
||||
|
||||
.EXAMPLE
|
||||
Get-IMSettingDefinition -Key *Export*
|
||||
|
||||
.EXAMPLE
|
||||
Get-IMSettingDefinition -Section General | Select-Object Key, Title, Description
|
||||
|
||||
.LINK
|
||||
Get-IMSetting
|
||||
.LINK
|
||||
Set-IMSetting
|
||||
#>
|
||||
function Get-SettingDefinition
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Position = 0)]
|
||||
[string]$Key,
|
||||
[string]$Section
|
||||
)
|
||||
|
||||
foreach($settingSection in (Get-SettingsSections | Sort-Object Order, Title))
|
||||
{
|
||||
if($Section -and $settingSection.Id -notlike $Section -and $settingSection.Title -notlike $Section) { continue }
|
||||
|
||||
foreach($definition in $settingSection.Values)
|
||||
{
|
||||
if($Key -and $definition.Key -notlike $Key) { continue }
|
||||
|
||||
[PSCustomObject]@{
|
||||
Key = $definition.Key
|
||||
Title = $definition.Title
|
||||
Section = $settingSection.Id
|
||||
SectionTitle = $settingSection.Title
|
||||
Type = $definition.Type
|
||||
DefaultValue = $definition.DefaultValue
|
||||
SubPath = $definition.SubPath
|
||||
Description = $definition.Description
|
||||
# The allowed values for a list setting, so a caller can validate a
|
||||
# value before writing it.
|
||||
ItemsSource = $definition.ItemsSource
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Report which settings store is active, and whether it persists.
|
||||
|
||||
.DESCRIPTION
|
||||
Exported as Get-IMSettingsStore.
|
||||
|
||||
Set-IMSetting writes to the persistent store by default, so a script that must
|
||||
not change the machine it runs on can assert on this first:
|
||||
|
||||
if((Get-IMSettingsStore).Persisted) { throw 'Refusing to write to a persistent store' }
|
||||
|
||||
Mode is the store in effect, which is not always the store that was asked for:
|
||||
a settings file that cannot be read falls back, and off Windows there is no
|
||||
registry to fall back to. RequestedMode is what was asked for.
|
||||
|
||||
.PARAMETER IncludeValues
|
||||
Also return every value currently in the store, as SubPath/Key/Value rows. This
|
||||
reads the whole store, including a recursive registry walk in registry mode.
|
||||
|
||||
.EXAMPLE
|
||||
Get-IMSettingsStore
|
||||
|
||||
.EXAMPLE
|
||||
(Get-IMSettingsStore -IncludeValues).Values | Format-Table
|
||||
|
||||
.EXAMPLE
|
||||
Get-IMSettingsStore | Select-Object Mode, Persisted, Path
|
||||
|
||||
.LINK
|
||||
Use-IMSettingsStore
|
||||
.LINK
|
||||
Export-IMSettingsStore
|
||||
#>
|
||||
function Get-SettingsStore
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param([switch]$IncludeValues)
|
||||
|
||||
$info = Get-SettingsStoreInfo
|
||||
|
||||
if($IncludeValues)
|
||||
{
|
||||
$info | Add-Member -MemberType NoteProperty -Name "Values" -Value @(Get-SettingsStoreEntries) -PassThru
|
||||
}
|
||||
else
|
||||
{
|
||||
$info
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
function Import-GraphPolicy {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
|
||||
[IntunePolicyBase[]]
|
||||
$InputObject,
|
||||
|
||||
[int]
|
||||
$TokenId = (Get-DefaultTokenId)
|
||||
)
|
||||
|
||||
Begin {
|
||||
Write-Log "Start Import of $(($InputObject | Measure-Object).Count) object(s)"
|
||||
$importedPolicies = @()
|
||||
$navigationPropObjects = @()
|
||||
|
||||
$policyObjectList = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
$bulkImport = $null
|
||||
if (Test-GraphBatchEnabled) {
|
||||
$bulkImport = @{}
|
||||
}
|
||||
|
||||
### !!! ToDo: Fix support for sorting based on priority + PreFilesImportCommand
|
||||
}
|
||||
|
||||
Process {
|
||||
foreach ($policyObject in $InputObject) {
|
||||
$policyObjectList.Add($policyObject)
|
||||
}
|
||||
}
|
||||
|
||||
End {
|
||||
# Group first, THEN sort the groups. Group-Object orders its output by
|
||||
# key, so a Sort-Object placed before it is thrown away and every import
|
||||
# ran alphabetically by type Id - ImportOrder had no effect at all. That
|
||||
# put App Config ahead of the Applications it targets and Policy Sets
|
||||
# ahead of the Settings Catalog they bundle; it only ever went unnoticed
|
||||
# because a same-tenant import finds every reference already in place.
|
||||
$policyTypeGroups = $policyObjectList |
|
||||
Group-Object -Property { $_.PolicyType.Id } |
|
||||
Sort-Object { [int]$_.Group[0].PolicyType.ImportOrder }
|
||||
foreach ($policyTypeGroup in $policyTypeGroups) {
|
||||
$policyType = Get-PolicyTypeFromID $policyTypeGroup.Name
|
||||
Write-Log "Import $($policyTypeGroup.Count) $($policyType.Title) policy object(s)"
|
||||
|
||||
$policyTypeObjects = $policyType.PreImportPolicies($policyTypeGroup.Group)
|
||||
|
||||
$bulkImport = $null
|
||||
if (Test-GraphBatchEnabled) {
|
||||
$bulkImport = @{}
|
||||
}
|
||||
|
||||
foreach ($policyObject in $policyTypeObjects) {
|
||||
# One failing policy must not abort the whole batch - log it
|
||||
# and keep importing the rest.
|
||||
$importedPolicy = $null
|
||||
try {
|
||||
$importedPolicy = $policyObject.ImportObject($TokenId, $bulkImport)
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Import failed for $($policyType.Title) object '$($policyObject.Name)'" $_.Exception
|
||||
}
|
||||
if ($importedPolicy) {
|
||||
$importedPolicies += [PSCustomObject]@{
|
||||
ImportedObject = $importedPolicy
|
||||
FromObject = $policyObject
|
||||
}
|
||||
|
||||
if ($importedPolicy.PolicyType.NavigationProperties -eq $true) {
|
||||
$navigationPropObjects += [PSCustomObject]@{
|
||||
ImportedObject = $importedPolicy
|
||||
FromObject = $policyObject
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if($null -ne $bulkImport) {
|
||||
# ToDo: Fix support for dependencies to make sure dependency objkects are imported first
|
||||
|
||||
$batchObjects = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
$bulkImport.Keys | ForEach-Object { $batchObjects.Add($_) }
|
||||
$batchResults = Invoke-GraphBatchRequest -BatchObject $batchObjects -TokenId $TokenId -BatchType "Import"
|
||||
|
||||
$batchResults | ForEach-Object {
|
||||
$result = $_
|
||||
$result | Add-Member -MemberType NoteProperty -Name "Success" -Value ($result.Status -lt 300) -Force
|
||||
$result | Add-Member -MemberType NoteProperty -Name "Content" -Value ($result.body | ConvertTo-Json -Depth 50) -Force
|
||||
$key = $bulkImport.Keys | Where-Object id -eq $result.id
|
||||
$bulkEntry = $bulkImport[$key]
|
||||
if($bulkEntry) {
|
||||
$policy = $bulkEntry.ImportObject
|
||||
$sourcePolicy = $bulkEntry.FromObject
|
||||
$importedPolicy = $policy.ProcessImportResponse($TokenId, $result, $key.Method)
|
||||
if($importedPolicy) {
|
||||
$importedPolicies += [PSCustomObject]@{
|
||||
ImportedObject = $importedPolicy
|
||||
FromObject = $sourcePolicy
|
||||
}
|
||||
|
||||
if ($importedPolicy.PolicyType.NavigationProperties -eq $true) {
|
||||
$navigationPropObjects += [PSCustomObject]@{
|
||||
ImportedObject = $importedPolicy
|
||||
FromObject = $sourcePolicy
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($importedPolicies.Count -gt 0) {
|
||||
foreach ($importedPolicy in $importedPolicies) {
|
||||
$importedPolicy.ImportedObject.PolicyType.PostBulkImportCommand($importedPolicy.ImportedObject, $importedPolicy.FromObject)
|
||||
}
|
||||
|
||||
foreach ($navPropObj in $navigationPropObjects) {
|
||||
Set-GraphNavigationProperties $navPropObj.ImportedObject $navPropObj.FromObject
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Import finished. $($importedPolicies.Count) policies imported"
|
||||
return $importedPolicies
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Load a JSON settings file into the active settings store.
|
||||
|
||||
.DESCRIPTION
|
||||
Exported as Import-IMSettingsStore.
|
||||
|
||||
Values are written one at a time through the normal write path, so the import
|
||||
behaves like the store it is going into: values persist when the store is a file
|
||||
or the registry, and stay in memory when it is not.
|
||||
|
||||
The file MERGES into what is already there rather than replacing it. For a clean
|
||||
slate, start from an empty in-memory store:
|
||||
|
||||
Use-IMSettingsStore -Memory # no -Seed, so nothing is inherited
|
||||
Import-IMSettingsStore -Path .\runbook-settings.json
|
||||
|
||||
Keys in the file that are not registered settings are imported and reported -
|
||||
some are legitimate (hidden keys, per-feature state) and a typo looks the same.
|
||||
|
||||
.PARAMETER Path
|
||||
The JSON settings file to load, in the shape Export-IMSettingsStore writes.
|
||||
|
||||
.EXAMPLE
|
||||
Use-IMSettingsStore -Memory
|
||||
Import-IMSettingsStore -Path .\runbook-settings.json
|
||||
Get-IMSettingsStore -IncludeValues
|
||||
|
||||
.EXAMPLE
|
||||
Import-IMSettingsStore -Path .\baseline.json -WhatIf
|
||||
|
||||
Check what store the import would land in before doing it.
|
||||
|
||||
.LINK
|
||||
Export-IMSettingsStore
|
||||
.LINK
|
||||
Use-IMSettingsStore
|
||||
#>
|
||||
function Import-SettingsStore
|
||||
{
|
||||
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, Position = 0)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$store = Get-SettingsStoreInfo
|
||||
$target = if($store.Path) { "$($store.Mode) store at $($store.Path)" } else { "$($store.Mode) store" }
|
||||
|
||||
if(-not $PSCmdlet.ShouldProcess($target, "Import settings from $Path")) { return }
|
||||
|
||||
Import-SettingsStoreFromFile -Path $Path | Out-Null
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
function Invoke-MSGraphAPI {
|
||||
param (
|
||||
[Parameter(Mandatory)]
|
||||
[String]
|
||||
$Url,
|
||||
|
||||
[Alias("Body")]
|
||||
[String]
|
||||
$Content,
|
||||
|
||||
[HashTable]
|
||||
$Headers,
|
||||
|
||||
[ValidateSet("GET", "POST", "OPTIONS", "DELETE", "PATCH", "PUT")]
|
||||
[Alias("Method")]
|
||||
[String]
|
||||
$HttpMethod = "GET",
|
||||
|
||||
[HashTable]
|
||||
$AdditionalHeaders,
|
||||
|
||||
[string]
|
||||
$Outfile = "",
|
||||
|
||||
[Switch]
|
||||
$SkipAuthentication,
|
||||
|
||||
[ValidateSet("full", "minimal", "none", "skip")]
|
||||
[String]
|
||||
$ODataMetadata = "full",
|
||||
|
||||
[ValidateSet("beta", "v1.0")]
|
||||
[String]
|
||||
$GraphVersion = "",
|
||||
|
||||
[switch]
|
||||
$AllPages,
|
||||
|
||||
[int]
|
||||
$PageSize = -1,
|
||||
|
||||
[switch]
|
||||
$Batch,
|
||||
|
||||
[switch]
|
||||
$NoError,
|
||||
|
||||
[Int]
|
||||
$TokenId = 0,
|
||||
|
||||
[Switch]
|
||||
$FullResponseObject
|
||||
)
|
||||
|
||||
if ($null -eq $tokenId -or $tokenId -eq 0) {
|
||||
$TokenId = Get-DefaultTokenId
|
||||
}
|
||||
|
||||
# Token acquisition goes through the token's OWNING provider, resolved from the
|
||||
# central registry by id. This is what lets several environments be live at once
|
||||
# across different providers (tenant A on MSAL, tenant B on OAuth) and have each
|
||||
# call reach the right one - routing by the active provider alone could not. For
|
||||
# id 0 / an unregistered id we fall back to the active provider (headless
|
||||
# -SkipAuthentication internal calls and pre-registry states rely on this).
|
||||
# GetAccessToken handles its own session lookup, expiry pre-flight, and silent
|
||||
# refresh; each provider has its own session model.
|
||||
$authProvider = Resolve-AuthTokenProvider $TokenId
|
||||
if (-not $authProvider) {
|
||||
$authProvider = Get-AuthProvider
|
||||
}
|
||||
if (-not $authProvider) {
|
||||
Write-Log "No authentication provider is active. Cannot invoke Graph API." 3
|
||||
return
|
||||
}
|
||||
|
||||
$graphDomain = Get-GraphDomain $TokenId
|
||||
$graphResource = "https://$graphDomain"
|
||||
|
||||
# Always ask the provider for an access token so the Authorization header is set.
|
||||
# The historical $SkipAuthentication flag means "I'm already inside an auth flow,
|
||||
# do not trigger another active re-auth" — NOT "do not send a token." When that
|
||||
# flag is set we still need the cached token; we just don't bail if it's missing.
|
||||
$accessToken = $authProvider.GetAccessToken($TokenId, $graphResource)
|
||||
|
||||
# An expired token the provider could not silently refresh is as useless as no
|
||||
# token - sending it just produces a 401 (and, worse, a doomed /ME during the
|
||||
# AuthenticationFailed handler). Unless this is an internal auth-flow call
|
||||
# (-SkipAuthentication), treat "expired" the same as "missing": null it out so the
|
||||
# guard below aborts cleanly instead of firing the request. The provider already
|
||||
# fires AuthenticationFailed on the refresh miss, so the UI reverts to Sign-in on
|
||||
# its own. GetAccessTokenExpiry returns MaxValue for SDK-managed providers
|
||||
# (MgGraph) and unknown expiries, so this never blocks those.
|
||||
if ($accessToken -and $SkipAuthentication -ne $true) {
|
||||
try {
|
||||
$tokenExpiry = $authProvider.GetAccessTokenExpiry($TokenId, $graphResource)
|
||||
if ($tokenExpiry -ne [datetime]::MaxValue -and $tokenExpiry -le (Get-Date)) {
|
||||
Write-Log "Access token for TokenId $TokenId is expired and could not be refreshed - skipping Graph call ($Url)" 2
|
||||
$accessToken = $null
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
if (-not $accessToken -and $SkipAuthentication -ne $true) {
|
||||
Write-Log "Could not obtain a valid access token from provider '$($authProvider.Id)' for TokenId $TokenId" 3
|
||||
return
|
||||
}
|
||||
|
||||
if (-not $GraphVersion) {
|
||||
if (-not $script:defaultVersion) {
|
||||
if ((Get-SettingValue "UseGraphV1") -eq $true) {
|
||||
$script:defaultVersion = "v1.0"
|
||||
}
|
||||
else {
|
||||
$script:defaultVersion = "beta"
|
||||
}
|
||||
}
|
||||
$GraphVersion = $script:defaultVersion
|
||||
}
|
||||
|
||||
$Params = @{}
|
||||
|
||||
$requestId = [Guid]::NewGuid().guid
|
||||
|
||||
if (-not $Headers) {
|
||||
$Headers = @{
|
||||
'Content-Type' = 'application/json; charset=utf-8'
|
||||
'x-ms-client-request-id' = $requestId
|
||||
}
|
||||
if ($accessToken) {
|
||||
$Headers['Authorization'] = "Bearer $accessToken"
|
||||
}
|
||||
}
|
||||
|
||||
if ($HttpMethod -eq "GET" -and $ODataMetadata -ne "Skip") {
|
||||
# Note: odata.metadata=full in Accept
|
||||
# @odata.type is not always included with default (minimum).
|
||||
# That is required to identify the object type in some functions
|
||||
# It does include a lot of info we don't need...
|
||||
$Headers.Add("Accept", "application/json;odata.metadata=$ODataMetadata")
|
||||
}
|
||||
#elseif($Content)
|
||||
#{
|
||||
# # Upload content as UTF8 to support international and extended characters
|
||||
# $Content = [System.Text.Encoding]::UTF8.GetBytes($Content)
|
||||
#}
|
||||
|
||||
if ($AdditionalHeaders -is [HashTable]) {
|
||||
foreach ($key in $AdditionalHeaders.Keys) {
|
||||
if ($Headers.ContainsKey($key)) { continue }
|
||||
|
||||
$Headers.Add($key, $AdditionalHeaders[$key])
|
||||
}
|
||||
}
|
||||
|
||||
# Multi Admin Approval: tenants with an access policy hold app-auth writes for a
|
||||
# second admin. Graph wants a base64 justification header on the first attempt;
|
||||
# a caller resubmitting an already-approved request passes 'x-msft-approval-code'
|
||||
# through -AdditionalHeaders instead. Never send both - the approval code wins.
|
||||
# GET is never gated, so this only touches write verbs.
|
||||
if ($HttpMethod -in @("POST", "PATCH", "PUT", "DELETE") -and
|
||||
-not $Headers.ContainsKey($script:MSGraphApprovalCodeHeader) -and
|
||||
-not $Headers.ContainsKey($script:MSGraphApprovalJustifyHeader)) {
|
||||
$maaJustification = ConvertTo-MSGraphApprovalJustification (Get-SettingValue "MultiAdminApprovalJustification")
|
||||
if ($maaJustification) {
|
||||
$Headers[$script:MSGraphApprovalJustifyHeader] = $maaJustification
|
||||
}
|
||||
}
|
||||
|
||||
if ($Content) { $Params.Add("Body", [System.Text.Encoding]::UTF8.GetBytes($Content)) }
|
||||
if ($Headers) { $Params.Add("Headers", $Headers) }
|
||||
if ($Outfile) {
|
||||
$dirName = [IO.Path]::GetDirectoryName($Outfile)
|
||||
try {
|
||||
[IO.Directory]::CreateDirectory($dirName) | Out-Null
|
||||
}
|
||||
catch {
|
||||
|
||||
}
|
||||
if ([IO.Directory]::Exists($dirName)) {
|
||||
$Params.Add("OutFile", $OutFile)
|
||||
$Params.Add("PassThru", $true)
|
||||
}
|
||||
else {
|
||||
Write-Log "Failed to create directory for OutFile $Outfile" 3
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (($Url -notmatch "^http://|^https://")) {
|
||||
$Url = "https://$graphDomain/$GraphVersion/" + $Url.TrimStart('/')
|
||||
}
|
||||
|
||||
# Resolve %OrganizationId% from the active provider's view of this token's tenant.
|
||||
# Phase 3: was reading MSAL globals directly; now goes through GetUserInfo so the
|
||||
# MgGraph provider works correctly too. Falls back to the script global (default
|
||||
# tenant snapshot) only if the provider returns nothing.
|
||||
if ($Url -match "%OrganizationId%") {
|
||||
$callTenantOrgId = $null
|
||||
try {
|
||||
$userInfo = $authProvider.GetUserInfo($TokenId)
|
||||
if ($userInfo -and $userInfo.TenantId) { $callTenantOrgId = $userInfo.TenantId }
|
||||
}
|
||||
catch { }
|
||||
if (-not $callTenantOrgId) { $callTenantOrgId = (Get-CurrentTenantId) }
|
||||
$Url = $Url -replace "%OrganizationId%", $callTenantOrgId
|
||||
}
|
||||
|
||||
$uri = [uri]$Url
|
||||
|
||||
if ($PageSize -gt 0 -and $uri.Query.IndexOf("`$top=") -eq -1 -and $uri.Segments[-1] -eq '$batch') {
|
||||
if (($url.IndexOf('?')) -eq -1) {
|
||||
$url = "$($url.Trim())?"
|
||||
}
|
||||
else {
|
||||
$url = "$($url.Trim())&"
|
||||
}
|
||||
$url = "$($url.Trim())`$top=$($PageSize)"
|
||||
Write-LogDebug "Use page size $PageSize"
|
||||
}
|
||||
|
||||
$proxyURI = Get-ProxyURI
|
||||
if ($proxyURI) {
|
||||
$Params.Add("proxy", $proxyURI)
|
||||
#$Params.Add("UseBasicParsing", $true)
|
||||
}
|
||||
|
||||
# Each counter is incremented ONLY by its own status class, in the retry branch
|
||||
# below. An earlier version also bumped $retryCount for every caught exception,
|
||||
# which made each 429 cost two of the ten and silently halved the budget.
|
||||
# Compared with -lt, so the budget is exactly $retryMax retries - the same
|
||||
# arithmetic Register-GraphRetryAttempt uses for the batch path.
|
||||
$retryCount = 0
|
||||
$retryMax = 10
|
||||
# Server errors are capped far below throttling - see the same split in
|
||||
# Invoke-GraphBatchRequest. Ten rounds of 10 s back-off on a 500 that will
|
||||
# never clear is 100 s of frozen app for a request that cannot succeed.
|
||||
$serverErrorRetryMax = 3
|
||||
$serverErrorRetryCount = 0
|
||||
# CAE claims-challenge retry is one-shot — if the new token still gets a 401 we
|
||||
# let the error surface instead of spinning. Per-call flag, not per-loop.
|
||||
$claimsRetryDone = $false
|
||||
|
||||
$returnValue = [PSCustomObject]@{
|
||||
Content = $null
|
||||
StatusCode = $null
|
||||
StatusDescription = $null
|
||||
Success = $false
|
||||
ErrorCode = $null
|
||||
ErrorMessage = $null
|
||||
ErrorRequestId = $null
|
||||
ErrorClientRequestId = $null
|
||||
ErrorDate = $null
|
||||
ErrorContent = $null
|
||||
ErrorRawContent = $null
|
||||
# Multi Admin Approval outcome. ApprovalPending means the write was accepted
|
||||
# and is queued for a second admin - callers should report "pending", not
|
||||
# "failed". ApprovalCode is the id to resubmit with once approved.
|
||||
ApprovalPending = $false
|
||||
ApprovalCode = $null
|
||||
ApprovalAdvice = $null
|
||||
}
|
||||
|
||||
do {
|
||||
$retryRequest = $false
|
||||
|
||||
if(-not $script:AllGraphCalls) {
|
||||
$script:AllGraphCalls = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
}
|
||||
|
||||
$webRequestInfo = [PSCustomObject]@{
|
||||
ID = $requestId
|
||||
URL = $Url
|
||||
Method = $HttpMethod
|
||||
IsBatch = ($uri.Segments[-1] -eq '$batch')
|
||||
BatchRequests = @()
|
||||
StatusCode = $null
|
||||
Time = Get-Date
|
||||
Duration = $null
|
||||
KB = 0.0
|
||||
ObjectCount = 0
|
||||
PageCount = 0
|
||||
ErrorMessage = $null
|
||||
ErrorCode = $null
|
||||
ErrorRequestId = $null
|
||||
ErrorClientRequestId = $null
|
||||
# Phase 3: track which auth provider minted the token for this call so the
|
||||
# Graph Calls log can show MSAL vs MgGraph at a glance.
|
||||
Provider = $authProvider.Id
|
||||
}
|
||||
$totalBytes = [long]0
|
||||
|
||||
# Cap the in-memory call log to avoid unbounded growth (was O(n²) with array `+=`).
|
||||
if($script:AllGraphCalls.Count -ge 2000) { $script:AllGraphCalls.RemoveAt(0) }
|
||||
[void]$script:AllGraphCalls.Add($webRequestInfo)
|
||||
|
||||
if($null -eq $script:IsPSv7) { $script:IsPSv7 = $PSVersionTable.PSVersion.Major -ge 7 }
|
||||
|
||||
try {
|
||||
Write-LogDebug "Invoke graph API: $Url (Request ID: $requestId)"
|
||||
$allValues = [System.Collections.Generic.List[object]]::new()
|
||||
$stopwatch = [System.Diagnostics.Stopwatch]::new()
|
||||
do {
|
||||
if($script:IsPSv7 -and -not $Params.ContainsKey("ProgressAction")) {
|
||||
$Params.Add("ProgressAction", "SilentlyContinue")
|
||||
}
|
||||
|
||||
$Params["Uri"] = $Url
|
||||
$Params["Method"] = $HttpMethod
|
||||
# One-per-second endpoints (Conditional Access, named locations, identity
|
||||
# protection): wait out the tenant's interval before this request goes out.
|
||||
$paceClass = Get-GraphRateClass -Url $Url
|
||||
if($paceClass) { Wait-GraphRatePace -Class $paceClass -TokenId $TokenId }
|
||||
# Bound every request so a hung/stalled call can't freeze the UI thread
|
||||
# indefinitely (the whole app is single-threaded-with-pump). Applies to
|
||||
# the -OutFile photo download too, since that shares $Params.
|
||||
if(-not $Params.ContainsKey("TimeoutSec")) {
|
||||
$reqTimeout = Get-SettingValue "MSGraphRequestTimeoutSec"
|
||||
if(-not $reqTimeout -or [int]$reqTimeout -le 0) { $reqTimeout = 100 }
|
||||
$Params["TimeoutSec"] = [int]$reqTimeout
|
||||
}
|
||||
$response = $null
|
||||
|
||||
$stopwatch.Restart()
|
||||
# Provider-routed request: when the owning provider couldn't yield a raw
|
||||
# bearer (e.g. an SDK-backed provider whose in-memory token cache is
|
||||
# opaque), let it run the request itself and return a response shaped like
|
||||
# Invoke-WebRequest's. $null means "not routed - use the raw token". This
|
||||
# replaces a hardcoded provider-Id check with the InvokeWebRequest capability.
|
||||
if ($authProvider -and (-not $accessToken -or $authProvider.RoutesAllRequests)) {
|
||||
$response = $authProvider.InvokeWebRequest($Url, $HttpMethod, $Content, $Headers)
|
||||
}
|
||||
if ($null -eq $response) {
|
||||
$response = Invoke-WebRequest @Params -UseBasicParsing -ErrorAction Stop
|
||||
}
|
||||
$stopwatch.Stop()
|
||||
$webRequestInfo.Duration = $stopwatch.Elapsed.TotalMilliseconds
|
||||
|
||||
# Track bytes received. Invoke-WebRequest exposes RawContentLength as a long.
|
||||
# Fall back to Content.Length if RawContentLength isn't set (rare).
|
||||
try {
|
||||
if($response.RawContentLength -gt 0) { $totalBytes += [long]$response.RawContentLength }
|
||||
elseif($response.Content) { $totalBytes += [long]$response.Content.Length }
|
||||
} catch {}
|
||||
|
||||
$contentObject = $response.Content | ConvertFrom-Json -ErrorAction Stop
|
||||
# Count successful pages only — incrementing before the request would
|
||||
# double-count when 429/CAE retries re-enter the outer retry loop and
|
||||
# re-run the inner pagination loop. Increment lives after the parse so a
|
||||
# parse failure also doesn't inflate the count.
|
||||
$webRequestInfo.PageCount++
|
||||
|
||||
$returnValue.Content = $contentObject
|
||||
$returnValue.StatusDescription = $response.StatusDescription
|
||||
$returnValue.StatusCode = $response.StatusCode
|
||||
$returnValue.Success = $true
|
||||
$webRequestInfo.StatusCode = $response.StatusCode
|
||||
|
||||
# Count returned objects from this page.
|
||||
# - List endpoints return { "value": [...] } -> count the array.
|
||||
# - Single-entity endpoints like /me or /users/{id} return the object directly -> count as 1.
|
||||
# - Batch envelopes are handled separately below (don't double-count here).
|
||||
if($contentObject.value -is [Array]) {
|
||||
$webRequestInfo.ObjectCount += $contentObject.value.Count
|
||||
}
|
||||
elseif(-not $webRequestInfo.IsBatch -and $null -ne $contentObject) {
|
||||
$webRequestInfo.ObjectCount += 1
|
||||
}
|
||||
|
||||
# For batch calls, populate BatchRequests with each request item + its response
|
||||
# status code, KB, and ObjectCount so the Graph log UI can show the per-item breakdown.
|
||||
if($webRequestInfo.IsBatch -and $Content) {
|
||||
try {
|
||||
$requestEnvelope = $Content | ConvertFrom-Json -Depth 20
|
||||
if($requestEnvelope.requests) {
|
||||
$perItemById = @{}
|
||||
$batchObjectTotal = 0
|
||||
foreach($r in $contentObject.responses) {
|
||||
$rid = "$($r.id)"
|
||||
$itemBytes = 0
|
||||
$itemCount = 0
|
||||
if($null -ne $r.body) {
|
||||
try {
|
||||
# Approximate per-item byte size by serializing the body
|
||||
# (response is JSON; raw bytes-over-wire aren't broken out
|
||||
# per item by the $batch envelope).
|
||||
$itemBytes = ($r.body | ConvertTo-Json -Depth 20 -Compress).Length
|
||||
} catch {}
|
||||
# Count: value array -> array length; single-object success -> 1; error body -> 0.
|
||||
if($r.body.value -is [Array]) {
|
||||
$itemCount = $r.body.value.Count
|
||||
}
|
||||
elseif($r.status -ge 200 -and $r.status -lt 300) {
|
||||
$itemCount = 1
|
||||
}
|
||||
}
|
||||
$batchObjectTotal += $itemCount
|
||||
$perItemById[$rid] = [PSCustomObject]@{
|
||||
StatusCode = $r.status
|
||||
KB = [Math]::Round($itemBytes / 1024.0, 1)
|
||||
ObjectCount = $itemCount
|
||||
PageCount = 1
|
||||
}
|
||||
}
|
||||
$webRequestInfo.ObjectCount += $batchObjectTotal
|
||||
|
||||
$items = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
foreach($req in $requestEnvelope.requests) {
|
||||
$rid = "$($req.id)"
|
||||
$resp = $perItemById[$rid]
|
||||
if(-not $resp) { $resp = [PSCustomObject]@{ StatusCode = $null; KB = 0.0; ObjectCount = 0 } }
|
||||
[void]$items.Add([PSCustomObject]@{
|
||||
Id = $req.id
|
||||
Method = $req.method
|
||||
URL = $req.url
|
||||
Response = $resp
|
||||
})
|
||||
}
|
||||
$webRequestInfo.BatchRequests = $items.ToArray()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Telemetry-only; never let it break the API call.
|
||||
}
|
||||
}
|
||||
|
||||
$webRequestInfo.KB = [Math]::Round($totalBytes / 1024.0, 1)
|
||||
Write-LogDebug "Invoke-WebRequest took $($webRequestInfo.Duration) ms, $($webRequestInfo.KB) KB, $($webRequestInfo.ObjectCount) objects, page $($webRequestInfo.PageCount) ($Url)"
|
||||
|
||||
if ($AllPages -eq $true -and $HttpMethod -eq "GET" -and $contentObject.value -is [Array]) {
|
||||
foreach($v in $contentObject.value) { [void]$allValues.Add($v) }
|
||||
if ($contentObject.'@odata.nextLink') {
|
||||
$Url = $contentObject.'@odata.nextLink'
|
||||
}
|
||||
else { break }
|
||||
}
|
||||
else {
|
||||
break
|
||||
}
|
||||
|
||||
} while ($contentObject.'@odata.nextLink')
|
||||
|
||||
# Assign the accumulated pages back to the returned object.
|
||||
# The previous code checked `$returnValue.Content -is [Array]`, which is never true
|
||||
# because Content is the parsed JSON object {value, @odata.nextLink}, not an array.
|
||||
if ($allValues.Count -gt 0 -and $null -ne $returnValue.Content -and $null -ne $returnValue.Content.PSObject.Properties['value']) {
|
||||
$returnValue.Content.value = $allValues.ToArray()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$webRequestInfo.Duration = ((Get-Date) - $webRequestInfo.Time).TotalMilliseconds
|
||||
try {
|
||||
$webRequestInfo.StatusCode = [int]$_.Exception.Response.StatusCode
|
||||
}
|
||||
catch{ $webRequestInfo.StatusCode = $_.Exception.Response.StatusCode }
|
||||
|
||||
if ($NoError -eq $true) { return }
|
||||
# CAE claims challenge: Graph returns 401 with WWW-Authenticate containing
|
||||
# claims="..." when the token must be re-acquired to satisfy a tenant policy
|
||||
# change. Re-mint the token with that challenge and retry once. Only providers
|
||||
# that manage claims challenges themselves (SupportsClaimsChallenge) need this
|
||||
# manual round-trip; SDK-managed providers handle CAE internally and report
|
||||
# $false, so the 401 surfaces unchanged.
|
||||
$claims = $null
|
||||
if (-not $claimsRetryDone -and $authProvider.SupportsClaimsChallenge -and
|
||||
[int]$_.Exception.Response.StatusCode -eq 401) {
|
||||
try {
|
||||
$wwwAuth = $_.Exception.Response.Headers["WWW-Authenticate"]
|
||||
if ($wwwAuth) {
|
||||
# Header is one or more Bearer challenges separated by commas. We
|
||||
# only care about a claims="..." parameter; capture between the
|
||||
# first set of quotes after claims=. CIAM/ESTS may also emit it
|
||||
# unquoted, so accept both.
|
||||
$m = [regex]::Match($wwwAuth, 'claims="([^"]+)"')
|
||||
if (-not $m.Success) {
|
||||
$m = [regex]::Match($wwwAuth, 'claims=([^,\s]+)')
|
||||
}
|
||||
if ($m.Success) { $claims = $m.Groups[1].Value }
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
if ($claims) {
|
||||
# Delegate the re-acquire to the owning provider so this caller stays
|
||||
# provider-agnostic. $allowInteractive is a caller-context decision: a
|
||||
# provider MAY prompt only when the main app window is up and this isn't a
|
||||
# nested auth-flow call; headless / automation stays silent-only and the
|
||||
# 401 surfaces if the challenge can't be satisfied silently.
|
||||
Write-Log "401 with CAE claims challenge. Re-acquiring token once." 2
|
||||
$claimsRetryDone = $true
|
||||
try {
|
||||
$allowInteractive = [bool]($script:MainAppStarted -and $SkipAuthentication -ne $true)
|
||||
$accessToken = $authProvider.GetClaimsToken($TokenId, $graphResource, $claims, $allowInteractive)
|
||||
if ($accessToken) {
|
||||
$Headers['Authorization'] = "Bearer $accessToken"
|
||||
if ($Params.ContainsKey('Headers')) { $Params['Headers'] = $Headers }
|
||||
$retryRequest = $true
|
||||
}
|
||||
else {
|
||||
Write-Log "Claims re-acquire did not produce a token; surfacing 401 to caller (user must re-login)." 2
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to re-acquire token with CAE claims" $_.Exception
|
||||
}
|
||||
}
|
||||
elseif ((([int]$_.Exception.Response.StatusCode -eq 429) -and $retryCount -lt $retryMax) -or
|
||||
(([int]$_.Exception.Response.StatusCode -in @(500, 502, 503, 504)) -and $serverErrorRetryCount -lt $serverErrorRetryMax)) {
|
||||
# 429 = throttling; 500/502/503/504 = transient server / gateway errors
|
||||
# (backend hiccup or upstream timeout). Both are safe to re-issue, but on
|
||||
# separate budgets: throttling clears when the window rolls over, while a
|
||||
# 5xx that repeats is usually permanent. Honor the Retry-After header when
|
||||
# Graph sends one, else default to 10s so we back off instead of hammering
|
||||
# a struggling backend, then retry the same request.
|
||||
$transientCode = [int]$_.Exception.Response.StatusCode
|
||||
# Retry-After header shape differs by PS host: PS7 surfaces a typed
|
||||
# HttpResponseHeaders.RetryAfter (RetryConditionHeaderValue); PS5.1's
|
||||
# WebException exposes a string-indexable WebHeaderCollection. Try both.
|
||||
$retryAfterRaw = $null
|
||||
try { $retryAfterRaw = $_.Exception.Response.Headers.RetryAfter.Delta.TotalSeconds } catch { }
|
||||
if($null -eq $retryAfterRaw) { try { $retryAfterRaw = $_.Exception.Response.Headers['Retry-After'] } catch { } }
|
||||
$wait = Get-GraphRetryAfterSeconds $retryAfterRaw
|
||||
if($transientCode -eq 429) { $retryCount++ } else { $serverErrorRetryCount++ }
|
||||
$retryRequest = $true
|
||||
Write-Log "$transientCode - transient error (throttling or gateway). Wait $wait s before retry" 2
|
||||
# Sliced, pumped wait so the window keeps painting (and shows the
|
||||
# countdown) instead of freezing for the whole back-off.
|
||||
Wait-UIAware -Seconds $wait -DetailFormat ("Graph {0} - retrying in {{0}}s" -f $transientCode)
|
||||
}
|
||||
else {
|
||||
$graphError = ConvertFrom-MSGraphErrorResponse $_
|
||||
$extMessage = if($graphError.Message) { ". Response message: $($graphError.Message)" } else { $null }
|
||||
|
||||
$webRequestInfo.ErrorMessage = $graphError.Message
|
||||
$webRequestInfo.ErrorCode = $graphError.Code
|
||||
$webRequestInfo.ErrorRequestId = $graphError.RequestId
|
||||
$webRequestInfo.ErrorClientRequestId = $graphError.ClientRequestId
|
||||
|
||||
# Classify 400/403/412 before logging. Multi Admin Approval reports a
|
||||
# queued write as 412 with outer code "BadRequest", which reads like a
|
||||
# hard failure but is the documented success path - log it as a warning
|
||||
# and hand the approval code back to the caller.
|
||||
$approvalInfo = Get-MSGraphApprovalInfo $_ $graphError $HttpMethod $Url
|
||||
$returnValue.ApprovalPending = $approvalInfo.IsApprovalPending
|
||||
$returnValue.ApprovalCode = $approvalInfo.ApprovalCode
|
||||
$returnValue.ApprovalAdvice = $approvalInfo.Advice
|
||||
|
||||
if ($approvalInfo.IsApprovalPending) {
|
||||
Write-Log "$Url - $($approvalInfo.Advice)" 2
|
||||
}
|
||||
else {
|
||||
if ($approvalInfo.Advice) { Write-Log $approvalInfo.Advice 2 }
|
||||
Write-LogError "Failed to invoke MS Graph with URL $Url (Request ID: $requestId). Status code: $($_.Exception.Response.StatusCode)$extMessage" $_.Exception
|
||||
}
|
||||
$returnValue.StatusCode = $_.Exception.Response.StatusCode
|
||||
$returnValue.StatusDescription = $extMessage
|
||||
$returnValue.ErrorCode = $graphError.Code
|
||||
$returnValue.ErrorMessage = $graphError.Message
|
||||
$returnValue.ErrorRequestId = $graphError.RequestId
|
||||
$returnValue.ErrorClientRequestId = $graphError.ClientRequestId
|
||||
$returnValue.ErrorDate = $graphError.Date
|
||||
$returnValue.ErrorContent = $graphError.Content
|
||||
$returnValue.ErrorRawContent = $graphError.RawContent
|
||||
}
|
||||
}
|
||||
} while ($retryRequest -eq $true)
|
||||
|
||||
#Write-Debug "$(($ret | Select-Object *))"
|
||||
|
||||
if ($FullResponseObject -eq $true) {
|
||||
$returnValue
|
||||
}
|
||||
else {
|
||||
$returnValue.Content
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
function Remove-GraphPolicy {
|
||||
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
|
||||
[OutputType([IntunePolicyBase[]])]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
|
||||
[IntunePolicyBase[]]
|
||||
$InputObject,
|
||||
|
||||
# Specifies destination environment. Default is current logged on environment.
|
||||
[int]
|
||||
$TokenId = 0
|
||||
)
|
||||
|
||||
Begin {
|
||||
Write-LogDebug "Start deleting policies"
|
||||
$deleted = @()
|
||||
$bulkDelete = $null
|
||||
if (Test-GraphBatchEnabled) {
|
||||
$bulkDelete = @{}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
foreach ($policyObject in $InputObject) {
|
||||
if ($PSCmdlet.ShouldProcess($policyObject.Name, 'DELETE')) {
|
||||
if ($policyObject.Delete($bulkDelete) -and -not $bulkDelete) {
|
||||
$deleted += $policyObject
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
End {
|
||||
if($bulkDelete.Count -gt 0) {
|
||||
$batchObjects = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
$bulkDelete.Keys | ForEach-Object { $batchObjects.Add($_) }
|
||||
$batchResults = Invoke-GraphBatchRequest -BatchObject $batchObjects -TokenId $TokenId -BatchType "Delete"
|
||||
$batchResults | ForEach-Object {
|
||||
$result = $_
|
||||
$policy = $bulkDelete.Values | Where-Object Id -eq $_.Id
|
||||
if ($result.Status -ge 200 -and $result.Status -lt 300) {
|
||||
$deleted += $policy
|
||||
Write-Log "Policy $($policy.Name) ($($policy.Id)) deleted successfully"
|
||||
}
|
||||
else {
|
||||
Write-LogError "Failed to delete policy $($policy.Name) ($($policy.Id)). Status code: $($result.Status) $($result.ErrorMessage)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-LogDebug "Delete policy finished"
|
||||
return $deleted
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Remove a stored IntuneManagement setting so it falls back to the next level.
|
||||
|
||||
.DESCRIPTION
|
||||
Exported as Remove-IMSetting.
|
||||
|
||||
Removing a value is not the same as setting it to nothing: a removed tenant value
|
||||
reverts to the global value, and a removed global value reverts to the default
|
||||
the setting was registered with. That is how the settings dialog's per-tenant
|
||||
checkbox works, and this is the same operation.
|
||||
|
||||
Like Set-IMSetting this changes the persistent store unless the session is using
|
||||
an in-memory one.
|
||||
|
||||
.PARAMETER Key
|
||||
The setting key.
|
||||
|
||||
.PARAMETER Scope
|
||||
Global (default) removes the value every tenant sees, so the setting falls back
|
||||
to its registered default. Tenant removes the tenant-specific value only,
|
||||
leaving the global one in place.
|
||||
|
||||
.PARAMETER TenantID
|
||||
The tenant to remove for. Defaults to the connected tenant.
|
||||
|
||||
.PARAMETER SubPath
|
||||
Storage path for a key that is not a registered setting. Required in that case,
|
||||
reported and ignored for a registered one (its registration decides the path).
|
||||
|
||||
.EXAMPLE
|
||||
Remove-IMSetting ExportFolder -Scope Tenant
|
||||
|
||||
Stop overriding the export folder for this tenant; the global value applies again.
|
||||
|
||||
.EXAMPLE
|
||||
Get-IMSetting UseBatchAPI -Detailed
|
||||
Remove-IMSetting UseBatchAPI
|
||||
Get-IMSetting UseBatchAPI -Detailed
|
||||
|
||||
Source goes from Global back to Default.
|
||||
|
||||
.LINK
|
||||
Set-IMSetting
|
||||
.LINK
|
||||
Get-IMSetting
|
||||
#>
|
||||
function Remove-Setting
|
||||
{
|
||||
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, Position = 0)]
|
||||
[string]$Key,
|
||||
[ValidateSet("Global", "Tenant")]
|
||||
[string]$Scope = "Global",
|
||||
[string]$TenantID,
|
||||
$SubPath = $null
|
||||
)
|
||||
|
||||
$store = Get-SettingsStoreInfo
|
||||
$target = if($store.Path) { "$($store.Mode) store at $($store.Path)" } else { "$($store.Mode) store" }
|
||||
$scopeText = if($Scope -eq "Tenant") { " for tenant scope" } else { "" }
|
||||
|
||||
if(-not $PSCmdlet.ShouldProcess($target, "Remove setting '$Key'$scopeText")) { return }
|
||||
|
||||
Write-Log "Remove setting '$Key' from the $target$scopeText"
|
||||
|
||||
Remove-SettingValue -Key $Key -Tenant:($Scope -eq "Tenant") -TenantID $TenantID -SubPath $SubPath
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
function Save-GraphBulkExportSettings {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Save bulk-export form state to a JSON file usable by Start-GraphBulkExport.
|
||||
.DESCRIPTION
|
||||
Round-trips an [IntuneManagerExportSettings] instance plus the selected
|
||||
PolicyGroup / PolicyType IDs to disk. Loaded back via
|
||||
Start-GraphBulkExport -SettingsFile <path>.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[IntuneManagerExportSettings]
|
||||
$ExportSettings,
|
||||
|
||||
[string[]]$PolicyGroup,
|
||||
[string[]]$PolicyType
|
||||
)
|
||||
|
||||
$payload = [ordered]@{
|
||||
ExportFolder = $ExportSettings.ExportFolder
|
||||
Filter = $ExportSettings.Filter
|
||||
ExportAssignments = $ExportSettings.ExportAssignments
|
||||
AddCompanyName = $ExportSettings.AddCompanyName
|
||||
AddObjectType = $ExportSettings.AddObjectType
|
||||
ExportNestedGroupLevels = $ExportSettings.ExportNestedGroupLevels
|
||||
PolicyGroup = @($PolicyGroup)
|
||||
PolicyType = @($PolicyType)
|
||||
}
|
||||
|
||||
$dir = [IO.Path]::GetDirectoryName($Path)
|
||||
if ($dir -and -not (Test-Path -LiteralPath $dir)) {
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
}
|
||||
ConvertTo-Json $payload -Depth 5 | Out-File -LiteralPath $Path -Encoding utf8 -Force
|
||||
Write-Log "Bulk-export settings saved to $Path"
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
function Set-GraphBulkAssignments
|
||||
{
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bulk-update Intune policy assignments.
|
||||
|
||||
.DESCRIPTION
|
||||
Public, UI-independent driver for the Bulk Assignments tool. Lists
|
||||
policies for the requested type(s) / group(s), applies the chosen
|
||||
Action (Add / Replace / Remove), and POSTs the new assignments list
|
||||
to the policy's /assign endpoint (the canonical "replace all
|
||||
assignments" operation Intune uses internally).
|
||||
|
||||
Supported shapes:
|
||||
* Simple — `{target}` only. Default for most types
|
||||
(DeviceConfiguration, Compliance, EndpointSecurity, Scripts,
|
||||
EnrollmentConfiguration, Autopilot, PolicySet, etc.).
|
||||
* App — `{target, intent}`. Used by types whose AssignmentsType
|
||||
is `mobileAppAssignments`. Per-platform `settings` is NOT
|
||||
populated yet (Phase 3); Graph defaults are accepted.
|
||||
|
||||
Targets supported in this phase:
|
||||
* Group, exclusion-group, all-devices, all-users
|
||||
* Optional assignment filter (include/exclude) on group targets
|
||||
|
||||
Health-script assignments (`runSchedule` + `runRemediationScript`)
|
||||
are filtered out — they need a per-type schedule editor that lands
|
||||
in a later phase.
|
||||
|
||||
The /assign endpoint REPLACES all assignments for the policy, so
|
||||
Add and Remove modes GET each policy's current assignments first
|
||||
(via Get-GraphPolicies -IncludeAssignments), compute the new list,
|
||||
and POST the merged result. Replace mode skips the merge step.
|
||||
|
||||
.PARAMETER AssignmentSettings
|
||||
An [IntuneManagerAssignmentSettings] instance. The UI binds to one;
|
||||
callers can construct one directly.
|
||||
|
||||
.PARAMETER Action
|
||||
Overrides AssignmentSettings.Action when supplied.
|
||||
|
||||
.PARAMETER Assignments
|
||||
Overrides AssignmentSettings.Assignments when supplied.
|
||||
|
||||
.PARAMETER Filter
|
||||
Name filter (literal substring, case-insensitive) matched against each
|
||||
policy's Name. Empty = no filter. Overrides AssignmentSettings.Filter
|
||||
when supplied.
|
||||
|
||||
.PARAMETER PolicyType
|
||||
Restrict the run to these PolicyType IDs.
|
||||
|
||||
.PARAMETER PolicyGroup
|
||||
Restrict the run to these PolicyGroup IDs (expanded to their member types).
|
||||
|
||||
.PARAMETER TokenId
|
||||
Authentication token id. Defaults to the current default token.
|
||||
|
||||
.EXAMPLE
|
||||
$s = [IntuneManagerAssignmentSettings]::new()
|
||||
$s.Action = 'Add'
|
||||
$s.Assignments = @([PSCustomObject]@{
|
||||
TargetType = 'groupAssignmentTarget'
|
||||
GroupId = '<aad-group-id>'
|
||||
GroupName = 'All Helpdesk Devices'
|
||||
})
|
||||
Set-GraphBulkAssignments -AssignmentSettings $s -PolicyGroup DeviceConfiguration
|
||||
|
||||
.OUTPUTS
|
||||
PSCustomObject @{
|
||||
Types, PoliciesScanned, PoliciesMatched, PoliciesUpdated,
|
||||
PoliciesSkipped, PoliciesFailed, PoliciesUnsupported, Duration
|
||||
}
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[IntuneManagerAssignmentSettings]
|
||||
$AssignmentSettings,
|
||||
|
||||
[ValidateSet("Add","Replace","Remove")]
|
||||
[string]
|
||||
$Action,
|
||||
|
||||
[PSCustomObject[]]
|
||||
$Assignments,
|
||||
|
||||
[string]
|
||||
$Filter,
|
||||
|
||||
[string[]]
|
||||
$PolicyType,
|
||||
|
||||
[string[]]
|
||||
$PolicyGroup,
|
||||
|
||||
[Int]
|
||||
$TokenId = (Get-DefaultTokenId)
|
||||
)
|
||||
|
||||
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
if(-not $AssignmentSettings) { $AssignmentSettings = [IntuneManagerAssignmentSettings]::new() }
|
||||
|
||||
if($PSBoundParameters.ContainsKey('Action')) { $AssignmentSettings.Action = $Action }
|
||||
if($PSBoundParameters.ContainsKey('Assignments')) { $AssignmentSettings.Assignments = @($Assignments) }
|
||||
if($PSBoundParameters.ContainsKey('Filter')) { $AssignmentSettings.Filter = $Filter }
|
||||
|
||||
if($AssignmentSettings.Action -notin @("Add","Replace","Remove")) {
|
||||
throw "Invalid Action '$($AssignmentSettings.Action)'. Expected Add, Replace, or Remove."
|
||||
}
|
||||
|
||||
$chosen = @($AssignmentSettings.Assignments | Where-Object { $_ })
|
||||
if($chosen.Count -eq 0) {
|
||||
throw "No assignments selected. Specify at least one target in AssignmentSettings.Assignments."
|
||||
}
|
||||
|
||||
# Phase 1 supports only the simple target-only shape. Anything else (apps
|
||||
# with intent + settings, health scripts with runSchedule) would silently
|
||||
# drop user fields during the Graph round-trip, so reject up-front.
|
||||
$simpleTargetTypes = @($chosen | Where-Object {
|
||||
$_.TargetType -in @(
|
||||
"groupAssignmentTarget",
|
||||
"exclusionGroupAssignmentTarget",
|
||||
"allDevicesAssignmentTarget",
|
||||
"allLicensedUsersAssignmentTarget")
|
||||
})
|
||||
if($simpleTargetTypes.Count -ne $chosen.Count) {
|
||||
$unknown = @($chosen | Where-Object { $_ -notin $simpleTargetTypes } | ForEach-Object { $_.TargetType }) -join ', '
|
||||
throw "Unsupported assignment target type(s): $unknown. Phase 1 supports groupAssignmentTarget, exclusionGroupAssignmentTarget, allDevicesAssignmentTarget, allLicensedUsersAssignmentTarget."
|
||||
}
|
||||
|
||||
foreach($a in $chosen) {
|
||||
if($a.TargetType -in @("groupAssignmentTarget","exclusionGroupAssignmentTarget") -and -not $a.GroupId) {
|
||||
throw "Assignment of type '$($a.TargetType)' is missing GroupId."
|
||||
}
|
||||
}
|
||||
|
||||
# installIntent enum values accepted by Graph for mobileAppAssignment.
|
||||
# Non-app shapes ignore Intent — see Build-AssignmentBody below.
|
||||
$validIntents = @("available","notAvailable","required","uninstall","availableWithoutEnrollment")
|
||||
foreach($a in $chosen) {
|
||||
if($a.Intent -and $a.Intent -notin $validIntents) {
|
||||
throw "Invalid Intent '$($a.Intent)' for target '$($a.GroupName)'. Expected one of: $($validIntents -join ', ')."
|
||||
}
|
||||
}
|
||||
|
||||
# ---- 1. Resolve target policy types ----
|
||||
# Filter to types that support the bulk assignment action shape. Some
|
||||
# Intune objects expose an `assignments` navigation property for export or
|
||||
# documentation, but do not support the replace-all /assign action used by
|
||||
# this tool.
|
||||
$selection = Resolve-IntuneTargetSelectors -PolicyType $PolicyType -PolicyGroup $PolicyGroup -Caller 'Set-GraphBulkAssignments'
|
||||
$unknownSelectors = $selection.Unknown # ids from -PolicyType/-PolicyGroup that matched nothing
|
||||
$targetTypes = [System.Collections.Generic.List[object]]::new()
|
||||
$targetTypes.AddRange([object[]]$selection.Types)
|
||||
$unsupportedSelected = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
if(-not $PolicyType -and -not $PolicyGroup) {
|
||||
foreach($grp in $script:IntuneGroups) {
|
||||
if(-not $grp.Title) { continue }
|
||||
foreach($pt in $grp.PolicyTypes) { [void]$targetTypes.Add($pt) }
|
||||
}
|
||||
}
|
||||
|
||||
$targetTypes = @($targetTypes | Sort-Object -Property Id -Unique)
|
||||
|
||||
$eligibleTypes = [System.Collections.Generic.List[object]]::new()
|
||||
foreach($pt in $targetTypes) {
|
||||
if(-not $pt.SupportsAssignments) { continue }
|
||||
if(-not $pt.AssignmentsType) { continue }
|
||||
if(-not (Test-BulkAssignmentSupported $pt)) {
|
||||
[void]$unsupportedSelected.Add($pt)
|
||||
continue
|
||||
}
|
||||
[void]$eligibleTypes.Add($pt)
|
||||
}
|
||||
|
||||
if($eligibleTypes.Count -eq 0) {
|
||||
Write-Log "Set-GraphBulkAssignments: no eligible policy types selected (need SupportsAssignments=true and default target-only shape)" 2
|
||||
return [PSCustomObject]@{
|
||||
Types = 0
|
||||
PoliciesScanned = 0
|
||||
PoliciesMatched = 0
|
||||
PoliciesUpdated = 0
|
||||
PoliciesSkipped = 0
|
||||
PoliciesFailed = 0
|
||||
PoliciesUnsupported = 0
|
||||
UnsupportedTypes = @($unsupportedSelected | ForEach-Object { $_.Title })
|
||||
UnknownSelectors = $unknownSelectors
|
||||
Duration = $stopwatch.Elapsed
|
||||
}
|
||||
}
|
||||
|
||||
# ---- 2. Pre-compile name filter ----
|
||||
# A literal substring, like 3.x and every other bulk driver. This command
|
||||
# writes assignments, so '[Test]' must mean the text [Test] and never a
|
||||
# character class that matches most of the tenant.
|
||||
$filterRegex = $null
|
||||
if($AssignmentSettings.Filter) {
|
||||
$filterRegex = [Regex]::new([Regex]::Escape($AssignmentSettings.Filter), 'IgnoreCase')
|
||||
}
|
||||
|
||||
Write-Log "Set-GraphBulkAssignments: action=$($AssignmentSettings.Action), assignments=$($chosen.Count), eligible=$($eligibleTypes.Count), unsupported=$($unsupportedSelected.Count)"
|
||||
|
||||
# Surface PolicyTypes whose assignment @odata.type the bulk tool can't
|
||||
# resolve. These will still PATCH (Graph sometimes accepts an entry
|
||||
# without an explicit @odata.type, sometimes 400s) but the user should
|
||||
# know — once per type per run, not once per policy. The fix is to set
|
||||
# _AssignmentObjectType on the PolicyType class (see IntunePolicyBase).
|
||||
foreach($pt in $eligibleTypes) {
|
||||
if(-not (Get-BulkAssignmentObjectType $pt)) {
|
||||
Write-Log "Set-GraphBulkAssignments: no assignment @odata.type resolved for PolicyType '$($pt.Id)' ($($pt.Title)). Set _AssignmentObjectType on the class or extend Get-BulkAssignmentObjectType." 2
|
||||
}
|
||||
}
|
||||
|
||||
# ---- 3. List policies (with assignments) for every eligible type ----
|
||||
# Two-line status: action context pinned on the primary line for the whole
|
||||
# run; the detail line rotates between listing and the PATCH phase.
|
||||
Write-Status `
|
||||
-Text ("Bulk assignments - {0}" -f $AssignmentSettings.Action) `
|
||||
-Detail ("Listing policies for {0} policy type(s)" -f $eligibleTypes.Count)
|
||||
$allPolicies = @()
|
||||
try {
|
||||
$allPolicies = @(Get-GraphPolicies -PolicyType @($eligibleTypes | ForEach-Object { $_.Id }) -TokenId $TokenId -IncludeAssignments -ErrorAction Stop)
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Set-GraphBulkAssignments: failed to list policies" $_.Exception
|
||||
}
|
||||
|
||||
# The cached policy list can carry STALE assignments from an earlier pass
|
||||
# in the same session (Add-GraphPolicyAssignments skips policies whose
|
||||
# assignments are already populated). The Add/Remove merge must see the
|
||||
# LIVE state - with stale data a Remove computes "nothing changed" and
|
||||
# silently no-ops. Force a refresh for every policy this run will touch.
|
||||
$refresh = @($allPolicies | Where-Object {
|
||||
$_ -and $_.PolicyType -and $_.Object -and
|
||||
(Test-BulkAssignmentSupported $_.PolicyType) -and
|
||||
(-not $filterRegex -or $filterRegex.IsMatch([string]$_.Name))
|
||||
})
|
||||
foreach($p in $refresh) { Remove-Property $p.Object 'assignments' }
|
||||
if($refresh.Count -gt 0) {
|
||||
Add-GraphPolicyAssignments -Policies $refresh -TokenId $TokenId
|
||||
}
|
||||
|
||||
# ---- 4. Build the per-policy POST batch ----
|
||||
# Tuples carry both Target (final POST shape) and Intent (only set for
|
||||
# app-shape types). Tuple → assignment-object conversion happens once
|
||||
# right before serialisation so the shape branch lives in one place.
|
||||
$scanned = 0
|
||||
$matched = 0
|
||||
$skipped = 0
|
||||
$batch = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
$byBatchId = @{}
|
||||
|
||||
foreach($p in $allPolicies)
|
||||
{
|
||||
$scanned++
|
||||
if(-not $p -or -not $p.PolicyType) { continue }
|
||||
if(-not (Test-BulkAssignmentSupported $p.PolicyType)) { continue }
|
||||
|
||||
if($filterRegex -and -not $filterRegex.IsMatch([string]$p.Name)) { continue }
|
||||
$matched++
|
||||
|
||||
$shape = Get-BulkAssignmentShape $p.PolicyType
|
||||
|
||||
# For app shape: figure out which per-platform settings entry the
|
||||
# user's chosen list applies to THIS app (source) and which Graph
|
||||
# @odata.type to stamp on the resulting `settings` object (target).
|
||||
# Inheritance shortcut: win32CatalogApp reuses Win32 LOB settings
|
||||
# since the inheriting type has no extra fields of its own.
|
||||
$sourceSettingsType = $null
|
||||
$targetSettingsType = $null
|
||||
if($shape -eq "app" -and $p.Object -and $p.Object.'@odata.type') {
|
||||
$targetSettingsType = Get-AppSettingsTypeForPolicy $p.Object.'@odata.type'
|
||||
$sourceSettingsType = if($targetSettingsType -eq 'win32CatalogAppAssignmentSettings') {
|
||||
'win32LobAppAssignmentSettings'
|
||||
} else {
|
||||
$targetSettingsType
|
||||
}
|
||||
}
|
||||
|
||||
# Project current assignments to (Target, Intent, Settings) tuples.
|
||||
# Settings on current assignments are already in Graph form (have
|
||||
# @odata.type), so pass through verbatim — they're only re-emitted
|
||||
# when the assignment survives Add/Remove.
|
||||
$current = @()
|
||||
if($p.Object -and $p.Object.PSObject.Properties['assignments']) {
|
||||
$current = @($p.Object.assignments | Where-Object { $_ -and $_.target })
|
||||
}
|
||||
$currentTuples = @()
|
||||
$currentKeys = [System.Collections.Generic.HashSet[string]]::new()
|
||||
foreach($a in $current) {
|
||||
# Script shape: preserve runSchedule (PSCustomObject from Graph
|
||||
# with @odata.type already set) and runRemediationScript
|
||||
# ([Nullable[bool]]) so Add/Remove keeps them on surviving rows.
|
||||
$runSchedule = $null
|
||||
$runRemediation = $null
|
||||
if($shape -eq "script") {
|
||||
if($a.PSObject.Properties['runSchedule'] -and $a.runSchedule) { $runSchedule = $a.runSchedule }
|
||||
if($a.PSObject.Properties['runRemediationScript']) { $runRemediation = [Nullable[bool]]$a.runRemediationScript }
|
||||
}
|
||||
$tuple = [PSCustomObject]@{
|
||||
Target = ConvertTo-AssignmentTarget $a.target
|
||||
Intent = if($shape -eq "app") { [string]$a.intent } else { $null }
|
||||
Settings = if($shape -eq "app" -and $a.settings) { $a.settings } else { $null }
|
||||
RunSchedule = $runSchedule
|
||||
RunRemediation = $runRemediation
|
||||
}
|
||||
$currentTuples += $tuple
|
||||
[void]$currentKeys.Add((Get-AssignmentSignature $tuple.Target $tuple.Intent))
|
||||
}
|
||||
|
||||
# Build chosen tuples for THIS shape. Intent only on apps; Settings
|
||||
# only when (a) shape is app AND (b) the chosen descriptor has a
|
||||
# per-platform hashtable matching this app's settings type. Same
|
||||
# chosen list flows across mixed-type runs unchanged.
|
||||
$chosenTuples = @()
|
||||
$chosenKeys = [System.Collections.Generic.HashSet[string]]::new()
|
||||
foreach($c in $chosen) {
|
||||
$settings = $null
|
||||
$runSchedule = $null
|
||||
$runRemediation = $null
|
||||
if($shape -eq "app" -and $sourceSettingsType -and $c.Settings -is [Hashtable] -and $c.Settings.ContainsKey($sourceSettingsType)) {
|
||||
$h = $c.Settings[$sourceSettingsType]
|
||||
if($h -is [Hashtable] -and $h.Count -gt 0) {
|
||||
$settings = ConvertTo-AppSettingsObject -Hash $h -GraphType $targetSettingsType
|
||||
}
|
||||
}
|
||||
if($shape -eq "script" -and $c.Settings -is [Hashtable] -and $c.Settings.ContainsKey('deviceHealthScriptAssignment')) {
|
||||
$h = $c.Settings['deviceHealthScriptAssignment']
|
||||
if($h -is [Hashtable]) {
|
||||
if($h.ContainsKey('runRemediationScript')) { $runRemediation = [Nullable[bool]]$h['runRemediationScript'] }
|
||||
if($h.ContainsKey('scheduleType')) { $runSchedule = Build-HealthScriptSchedule -Spec $h }
|
||||
}
|
||||
}
|
||||
# Some app types cannot take an assignment filter - Graph answers
|
||||
# "The Assignment Filters are not supported for this app type" and the
|
||||
# whole policy failed. The portal simply does not offer a filter for
|
||||
# them, so assign the group as asked and drop the filter, with a log
|
||||
# line naming the policy. Live-verified for webApp (2026-09-13).
|
||||
$descriptorForTarget = $c
|
||||
if($shape -eq "app" -and $c.FilterId -and $p.Object -and
|
||||
([string]$p.Object.'@odata.type') -in $script:BulkAssignmentAppTypesWithoutFilters) {
|
||||
Write-Log "Set-GraphBulkAssignments: '$($p.Name)' is a $($p.Object.'@odata.type' -replace '^#microsoft\.graph\.','') - assignment filters are not supported for this app type, assigning without the filter" 2
|
||||
$descriptorForTarget = [PSCustomObject]@{
|
||||
TargetType = $c.TargetType
|
||||
GroupId = $c.GroupId
|
||||
GroupName = $c.GroupName
|
||||
}
|
||||
}
|
||||
$tuple = [PSCustomObject]@{
|
||||
Target = ConvertTo-AssignmentTarget (Build-AssignmentTarget $descriptorForTarget)
|
||||
Intent = if($shape -eq "app") { if($c.Intent) { [string]$c.Intent } else { "required" } } else { $null }
|
||||
Settings = $settings
|
||||
RunSchedule = $runSchedule
|
||||
RunRemediation = $runRemediation
|
||||
}
|
||||
$chosenTuples += $tuple
|
||||
[void]$chosenKeys.Add((Get-AssignmentSignature $tuple.Target $tuple.Intent))
|
||||
}
|
||||
|
||||
# Compute new tuple set per Action.
|
||||
$newTuples = @()
|
||||
switch ($AssignmentSettings.Action) {
|
||||
"Replace" {
|
||||
$newTuples = $chosenTuples
|
||||
}
|
||||
"Add" {
|
||||
$seen = [System.Collections.Generic.HashSet[string]]::new()
|
||||
foreach($t in $currentTuples) {
|
||||
if($seen.Add((Get-AssignmentSignature $t.Target $t.Intent))) { $newTuples += $t }
|
||||
}
|
||||
foreach($t in $chosenTuples) {
|
||||
if($seen.Add((Get-AssignmentSignature $t.Target $t.Intent))) { $newTuples += $t }
|
||||
}
|
||||
}
|
||||
"Remove" {
|
||||
foreach($t in $currentTuples) {
|
||||
$sig = Get-AssignmentSignature $t.Target $t.Intent
|
||||
if($chosenKeys.Contains($sig)) { continue }
|
||||
$newTuples += $t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# No-op if the resulting assignments are identical to current
|
||||
# (order-independent). Use the full tuple signature here, not just
|
||||
# target+intent, so Replace can update app settings and health-script
|
||||
# schedule/remediation fields for an existing target.
|
||||
$currentFullKeys = [System.Collections.Generic.HashSet[string]]::new()
|
||||
foreach($t in $currentTuples) { [void]$currentFullKeys.Add((Get-AssignmentFullSignature $t)) }
|
||||
$newFullKeys = [System.Collections.Generic.HashSet[string]]::new()
|
||||
foreach($t in $newTuples) { [void]$newFullKeys.Add((Get-AssignmentFullSignature $t)) }
|
||||
if($newFullKeys.Count -eq $currentFullKeys.Count -and
|
||||
(@($newFullKeys | Where-Object { -not $currentFullKeys.Contains($_) }).Count -eq 0)) {
|
||||
$skipped++
|
||||
# A Remove that finds nothing to remove is the case worth explaining:
|
||||
# either the target was never there, or the refresh read came back
|
||||
# empty and the assignment is about to be left behind.
|
||||
if($AssignmentSettings.Action -eq 'Remove') {
|
||||
Write-Log "Set-GraphBulkAssignments: '$($p.Name)' [$($p.PolicyType.Id)] - nothing to remove (current=$($currentTuples.Count), chosen=$($chosenTuples.Count))"
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
# Tuples → POST objects (shape-aware).
|
||||
# app → {target, intent, settings?}
|
||||
# script → {target, runRemediationScript?, runSchedule?}
|
||||
# simple → {target}
|
||||
# All optional fields are omitted entirely when null/absent so Graph
|
||||
# keeps its defaults rather than seeing $null / empty objects.
|
||||
$assignmentObjectType = Get-BulkAssignmentObjectType $p.PolicyType
|
||||
$newAssignments = foreach($t in $newTuples) {
|
||||
if($shape -eq "app") {
|
||||
$obj = [ordered]@{}
|
||||
if($assignmentObjectType) { $obj['@odata.type'] = $assignmentObjectType }
|
||||
$obj.target = $t.Target
|
||||
$obj.intent = (?? $t.Intent "required")
|
||||
if($t.Settings) { $obj.settings = $t.Settings }
|
||||
[PSCustomObject]$obj
|
||||
}
|
||||
elseif($shape -eq "script") {
|
||||
$obj = [ordered]@{}
|
||||
if($assignmentObjectType) { $obj['@odata.type'] = $assignmentObjectType }
|
||||
$obj.target = $t.Target
|
||||
if($null -ne $t.RunRemediation) { $obj.runRemediationScript = [bool]$t.RunRemediation }
|
||||
if($t.RunSchedule) { $obj.runSchedule = $t.RunSchedule }
|
||||
[PSCustomObject]$obj
|
||||
}
|
||||
else {
|
||||
$obj = [ordered]@{}
|
||||
if($assignmentObjectType) { $obj['@odata.type'] = $assignmentObjectType }
|
||||
$obj.target = $t.Target
|
||||
[PSCustomObject]$obj
|
||||
}
|
||||
}
|
||||
|
||||
$body = [PSCustomObject]@{
|
||||
$p.PolicyType.AssignmentsType = @($newAssignments)
|
||||
}
|
||||
$bodyJson = $body | ConvertTo-Json -Depth 20 -Compress
|
||||
|
||||
$batchId = [string]$batch.Count
|
||||
[void]$batch.Add([PSCustomObject]@{
|
||||
id = $batchId
|
||||
method = "POST"
|
||||
# AssignAction is "assign" for almost every type; policySets have
|
||||
# no /assign segment and take the replacement list via /update.
|
||||
url = "$($p.PolicyType.API)/$($p.Id)/$($p.PolicyType.AssignAction)"
|
||||
body = ($bodyJson | ConvertFrom-Json)
|
||||
headers = @{ "Content-Type" = "application/json" }
|
||||
})
|
||||
$byBatchId[$batchId] = [PSCustomObject]@{ Policy = $p; NewAssignments = @($newAssignments) }
|
||||
}
|
||||
|
||||
# ---- 6. Dispatch the batch ----
|
||||
$updated = 0
|
||||
$failed = 0
|
||||
if($batch.Count -gt 0) {
|
||||
Write-Status -Detail ("Updating assignments on {0} policy(ies)" -f $batch.Count) -SkipLog
|
||||
$results = @(Invoke-GraphBatchRequest -BatchObjects $batch -BatchType "BulkAssignments" -TokenId $TokenId -IncludedFailed -SkipWarnings)
|
||||
|
||||
if($results.Count -eq 0) {
|
||||
$failed += $batch.Count
|
||||
Write-Log "Set-GraphBulkAssignments: batch returned no responses; treating all $($batch.Count) request(s) as failed" 3
|
||||
}
|
||||
else {
|
||||
foreach($r in $results) {
|
||||
$entry = $byBatchId["$($r.Id)"]
|
||||
if(-not $entry) { continue }
|
||||
$statusCode = 0
|
||||
try { $statusCode = [int]$r.status } catch { }
|
||||
|
||||
if($statusCode -ge 200 -and $statusCode -lt 300) {
|
||||
$updated++
|
||||
# Mirror so a subsequent UI refresh sees the new list
|
||||
# without a fresh round-trip.
|
||||
try {
|
||||
if($entry.Policy.Object.PSObject.Properties['assignments']) {
|
||||
$entry.Policy.Object.assignments = $entry.NewAssignments
|
||||
}
|
||||
else {
|
||||
$entry.Policy.Object | Add-Member -MemberType NoteProperty -Name 'assignments' -Value $entry.NewAssignments -Force
|
||||
}
|
||||
}
|
||||
catch { Write-LogDebug "Set-GraphBulkAssignments: in-memory mirror failed for $($entry.Policy.Name): $($_.Exception.Message)" }
|
||||
}
|
||||
else {
|
||||
$failed++
|
||||
$msg = ""
|
||||
if($r.body -and $r.body.error -and $r.body.error.message) { $msg = $r.body.error.message }
|
||||
Write-Log "Set-GraphBulkAssignments: /assign failed for '$($entry.Policy.Name)' [$($entry.Policy.PolicyType.Title)] - $statusCode $msg" 3
|
||||
}
|
||||
}
|
||||
|
||||
# Pending entries with no response → treat as failed (matches
|
||||
# the codex fix pattern from Set-GraphBulkScopeTags).
|
||||
$seenIds = [System.Collections.Generic.HashSet[string]]::new()
|
||||
foreach($r in $results) { [void]$seenIds.Add([string]$r.Id) }
|
||||
foreach($pending in $byBatchId.Keys) {
|
||||
if(-not $seenIds.Contains([string]$pending)) {
|
||||
$failed++
|
||||
$entry = $byBatchId[$pending]
|
||||
Write-Log "Set-GraphBulkAssignments: no batch response for '$($entry.Policy.Name)' [$($entry.Policy.PolicyType.Title)]" 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Status ""
|
||||
|
||||
[PSCustomObject]@{
|
||||
Types = $eligibleTypes.Count
|
||||
PoliciesScanned = $scanned
|
||||
PoliciesMatched = $matched
|
||||
PoliciesUpdated = $updated
|
||||
PoliciesSkipped = $skipped
|
||||
PoliciesFailed = $failed
|
||||
PoliciesUnsupported = 0 # placeholder — unsupported TYPES are reported separately below
|
||||
UnsupportedTypes = @($unsupportedSelected | ForEach-Object { $_.Title })
|
||||
UnknownSelectors = $unknownSelectors
|
||||
Duration = $stopwatch.Elapsed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
function Set-GraphBulkScopeTags
|
||||
{
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bulk-update Intune policy scope tag assignments.
|
||||
|
||||
.DESCRIPTION
|
||||
Public, UI-independent driver for the Bulk Scope Tags tool. Lists policies
|
||||
for the requested type(s) / group(s), applies the chosen action (Add /
|
||||
Replace / Remove), optionally strips orphan tag ids (ids that no longer
|
||||
resolve to a real scope tag in the tenant), and PATCHes the new
|
||||
roleScopeTagIds value back to each policy.
|
||||
|
||||
PATCH calls are batched 20 at a time via Invoke-GraphBatchRequest, so a
|
||||
full-tenant update is one round-trip per 20 policies rather than one per
|
||||
policy.
|
||||
|
||||
.PARAMETER ScopeTagSettings
|
||||
An [IntuneManagerScopeTagSettings] instance. The UI binds to one; callers
|
||||
can construct one directly.
|
||||
|
||||
.PARAMETER Action
|
||||
Overrides ScopeTagSettings.Action when supplied.
|
||||
|
||||
.PARAMETER ScopeTagIds
|
||||
Overrides ScopeTagSettings.ScopeTagIds when supplied.
|
||||
|
||||
.PARAMETER Filter
|
||||
Name filter (literal substring, case-insensitive) matched against each
|
||||
policy's Name. Empty = no filter. Overrides ScopeTagSettings.Filter when
|
||||
supplied.
|
||||
|
||||
.PARAMETER CleanupOrphans
|
||||
Strip tag ids that don't resolve to a real scope tag. Overrides
|
||||
ScopeTagSettings.CleanupOrphans when supplied.
|
||||
|
||||
.PARAMETER PolicyType
|
||||
Restrict the run to these PolicyType IDs.
|
||||
|
||||
.PARAMETER PolicyGroup
|
||||
Restrict the run to these PolicyGroup IDs (expanded to their member types).
|
||||
|
||||
.PARAMETER TokenId
|
||||
Authentication token id. Defaults to the current default token.
|
||||
|
||||
.EXAMPLE
|
||||
$s = [IntuneManagerScopeTagSettings]::new()
|
||||
$s.Action = "Add"
|
||||
$s.ScopeTagIds = @("3","4")
|
||||
Set-GraphBulkScopeTags -ScopeTagSettings $s -PolicyGroup DeviceConfiguration
|
||||
|
||||
.EXAMPLE
|
||||
# Pure orphan cleanup across every type that supports scope tags.
|
||||
Set-GraphBulkScopeTags -CleanupOrphans
|
||||
|
||||
.OUTPUTS
|
||||
PSCustomObject with summary statistics
|
||||
(Types, PoliciesScanned, PoliciesMatched, PoliciesUpdated, PoliciesSkipped, PoliciesFailed, Duration).
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[IntuneManagerScopeTagSettings]
|
||||
$ScopeTagSettings,
|
||||
|
||||
[ValidateSet("Add","Replace","Remove")]
|
||||
[string]
|
||||
$Action,
|
||||
|
||||
[string[]]
|
||||
$ScopeTagIds,
|
||||
|
||||
[string]
|
||||
$Filter,
|
||||
|
||||
[Nullable[bool]]
|
||||
$CleanupOrphans,
|
||||
|
||||
[string[]]
|
||||
$PolicyType,
|
||||
|
||||
[string[]]
|
||||
$PolicyGroup,
|
||||
|
||||
[Int]
|
||||
$TokenId = (Get-DefaultTokenId)
|
||||
)
|
||||
|
||||
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
if(-not $ScopeTagSettings) { $ScopeTagSettings = [IntuneManagerScopeTagSettings]::new() }
|
||||
|
||||
if($PSBoundParameters.ContainsKey('Action')) { $ScopeTagSettings.Action = $Action }
|
||||
if($PSBoundParameters.ContainsKey('ScopeTagIds')) { $ScopeTagSettings.ScopeTagIds = @($ScopeTagIds) }
|
||||
if($PSBoundParameters.ContainsKey('Filter')) { $ScopeTagSettings.Filter = $Filter }
|
||||
if($PSBoundParameters.ContainsKey('CleanupOrphans')) { $ScopeTagSettings.CleanupOrphans = [bool]$CleanupOrphans }
|
||||
|
||||
if($ScopeTagSettings.Action -notin @("Add","Replace","Remove")) {
|
||||
throw "Invalid Action '$($ScopeTagSettings.Action)'. Expected Add, Replace, or Remove."
|
||||
}
|
||||
|
||||
# ---- 1. Resolve target policy types ----
|
||||
# Only types that actually advertise a ScopeTagProperty are eligible — the rest
|
||||
# don't support roleScopeTagIds and the PATCH would 400.
|
||||
$selection = Resolve-IntuneTargetSelectors -PolicyType $PolicyType -PolicyGroup $PolicyGroup -Caller 'Set-GraphBulkScopeTags'
|
||||
$unknownSelectors = $selection.Unknown # ids from -PolicyType/-PolicyGroup that matched nothing
|
||||
$targetTypes = [System.Collections.Generic.List[object]]::new()
|
||||
$targetTypes.AddRange([object[]]$selection.Types)
|
||||
|
||||
if(-not $PolicyType -and -not $PolicyGroup) {
|
||||
foreach($grp in $script:IntuneGroups) {
|
||||
if(-not $grp.Title) { continue }
|
||||
foreach($pt in $grp.PolicyTypes) { [void]$targetTypes.Add($pt) }
|
||||
}
|
||||
}
|
||||
|
||||
$targetTypes = @($targetTypes |
|
||||
Where-Object { $_.ScopeTagProperty } |
|
||||
Sort-Object -Property Id -Unique)
|
||||
|
||||
if($targetTypes.Count -eq 0) {
|
||||
Write-Log "Set-GraphBulkScopeTags: no policy types with ScopeTagProperty selected" 2
|
||||
return [PSCustomObject]@{
|
||||
Types = 0
|
||||
PoliciesScanned = 0
|
||||
PoliciesMatched = 0
|
||||
PoliciesUpdated = 0
|
||||
PoliciesSkipped = 0
|
||||
PoliciesFailed = 0
|
||||
UnknownSelectors = $unknownSelectors
|
||||
Duration = $stopwatch.Elapsed
|
||||
}
|
||||
}
|
||||
|
||||
# ---- 2. Pre-compile name filter ----
|
||||
# A literal substring, like 3.x and every other bulk driver. This command
|
||||
# writes scope tags, so '[Test]' must mean the text [Test] and never a
|
||||
# character class that matches most of the tenant.
|
||||
$filterRegex = $null
|
||||
if($ScopeTagSettings.Filter) {
|
||||
$filterRegex = [Regex]::new([Regex]::Escape($ScopeTagSettings.Filter), 'IgnoreCase')
|
||||
}
|
||||
|
||||
# ---- 3. Load current scope tag catalogue (for orphan detection) ----
|
||||
# Always loaded — even when CleanupOrphans is off — because the summary log
|
||||
# and downstream UI may report orphans encountered.
|
||||
# Local effective copy so a tag-load failure doesn't mutate the caller's
|
||||
# settings instance (the UI re-uses the same object across runs).
|
||||
$effectiveCleanup = [bool]$ScopeTagSettings.CleanupOrphans
|
||||
$validTagIds = [System.Collections.Generic.HashSet[string]]::new()
|
||||
try {
|
||||
$tags = @(Get-GraphPolicies -PolicyType "ScopeTags" -TokenId $TokenId -ErrorAction Stop)
|
||||
foreach($t in $tags) {
|
||||
if($null -ne $t.Id) { [void]$validTagIds.Add([string]$t.Id) }
|
||||
}
|
||||
# The synthetic "Default" tag (Id 0) is always treated as valid even when
|
||||
# not surfaced by the live list — Intune requires it on most policies.
|
||||
[void]$validTagIds.Add("0")
|
||||
}
|
||||
catch {
|
||||
Write-Log "Set-GraphBulkScopeTags: failed to load scope tag catalogue - orphan cleanup disabled for this run. $($_.Exception.Message)" 2
|
||||
$effectiveCleanup = $false
|
||||
}
|
||||
|
||||
# Selected tag ids → string set for fast membership tests in Add/Remove logic.
|
||||
$selectedIds = [System.Collections.Generic.HashSet[string]]::new()
|
||||
foreach($id in @($ScopeTagSettings.ScopeTagIds)) {
|
||||
if($null -ne $id -and "$id".Length -gt 0) { [void]$selectedIds.Add([string]$id) }
|
||||
}
|
||||
|
||||
if($selectedIds.Count -eq 0) {
|
||||
if(-not ($ScopeTagSettings.Action -eq "Add" -and $effectiveCleanup)) {
|
||||
throw "No scope tag IDs were selected. Only Add with CleanupOrphans can run without selected tags."
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Set-GraphBulkScopeTags: action=$($ScopeTagSettings.Action), tags=$($selectedIds.Count), cleanup=$effectiveCleanup, types=$($targetTypes.Count)"
|
||||
|
||||
# ---- 4. List policies for every selected type ----
|
||||
# Two-line status: action context on the primary line stays pinned across
|
||||
# the listing and the per-policy PATCH phases; sub-step lives on the detail line.
|
||||
Write-Status `
|
||||
-Text ("Bulk scope tags - {0}" -f $ScopeTagSettings.Action) `
|
||||
-Detail ("Listing policies for {0} policy type(s)" -f $targetTypes.Count)
|
||||
$allPolicies = @()
|
||||
try {
|
||||
$allPolicies = @(Get-GraphPolicies -PolicyType @($targetTypes | ForEach-Object { $_.Id }) -TokenId $TokenId -ErrorAction Stop)
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Set-GraphBulkScopeTags: failed to list policies" $_.Exception
|
||||
}
|
||||
|
||||
# ---- 5. Build the PATCH batch ----
|
||||
$scanned = 0
|
||||
$matched = 0
|
||||
$skipped = 0
|
||||
$batch = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
$byBatchId = @{} # batch id -> PSCustomObject @{Policy=...; NewIds=...}
|
||||
|
||||
foreach($p in $allPolicies)
|
||||
{
|
||||
$scanned++
|
||||
if(-not $p -or -not $p.PolicyType -or -not $p.PolicyType.ScopeTagProperty) { continue }
|
||||
|
||||
if($filterRegex -and -not $filterRegex.IsMatch([string]$p.Name)) { continue }
|
||||
|
||||
$matched++
|
||||
|
||||
$prop = [string]$p.PolicyType.ScopeTagProperty
|
||||
if(-not $p.Object -or -not $p.Object.PSObject.Properties[$prop]) {
|
||||
$skipped++
|
||||
Write-LogDebug "Set-GraphBulkScopeTags: '$($p.Name)' [$($p.PolicyType.Title)] does not expose '$prop' in Graph response - skipped"
|
||||
continue
|
||||
}
|
||||
|
||||
# Current ids — defensive: property may not exist on the JSON yet, or
|
||||
# may be empty. Stringify everything for consistent set ops.
|
||||
$currentIds = [System.Collections.Generic.HashSet[string]]::new()
|
||||
foreach($id in @($p.Object.$prop)) {
|
||||
if($null -ne $id) { [void]$currentIds.Add([string]$id) }
|
||||
}
|
||||
|
||||
$newIds = [System.Collections.Generic.HashSet[string]]::new($currentIds)
|
||||
switch ($ScopeTagSettings.Action) {
|
||||
"Add" { foreach($id in $selectedIds) { [void]$newIds.Add($id) } }
|
||||
"Remove" { foreach($id in $selectedIds) { [void]$newIds.Remove($id) } }
|
||||
"Replace" { $newIds = [System.Collections.Generic.HashSet[string]]::new($selectedIds) }
|
||||
}
|
||||
|
||||
if($effectiveCleanup) {
|
||||
$stale = @($newIds | Where-Object { -not $validTagIds.Contains($_) })
|
||||
foreach($id in $stale) { [void]$newIds.Remove($id) }
|
||||
}
|
||||
|
||||
# Stable comparison (order-independent): same membership → no PATCH.
|
||||
if($newIds.Count -eq $currentIds.Count -and
|
||||
(@($newIds | Where-Object { -not $currentIds.Contains($_) }).Count -eq 0)) {
|
||||
$skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
$ht = [ordered]@{}
|
||||
$ht[$prop] = @($newIds)
|
||||
# Some types require @odata.type on PATCH (subtype-discriminated entities).
|
||||
# Mirror the Details-view behaviour: include it when the source object has it.
|
||||
if($p.Object -and $p.Object.'@odata.type') {
|
||||
$ht['@odata.type'] = $p.Object.'@odata.type'
|
||||
}
|
||||
|
||||
$bodyObj = [PSCustomObject]$ht
|
||||
$bodyJson = $bodyObj | ConvertTo-Json -Depth 20 -Compress
|
||||
|
||||
$batchId = [string]$batch.Count
|
||||
|
||||
# Graph $batch sub-requests require the body to be an OBJECT (not a string).
|
||||
# Invoke-GraphBatchRequest currently passes BatchObjects.body through as-is,
|
||||
# so we attach the parsed object directly.
|
||||
$batchObj = [PSCustomObject]@{
|
||||
id = $batchId
|
||||
method = "PATCH"
|
||||
url = Get-GraphBulkScopeTagPatchUrl -Policy $p
|
||||
body = ($bodyJson | ConvertFrom-Json)
|
||||
headers = @{
|
||||
"Content-Type" = "application/json"
|
||||
"If-Match" = "*"
|
||||
}
|
||||
}
|
||||
[void]$batch.Add($batchObj)
|
||||
$byBatchId[$batchId] = [PSCustomObject]@{ Policy = $p; NewIds = @($newIds) }
|
||||
}
|
||||
|
||||
# ---- 6. Dispatch the batch ----
|
||||
$updated = 0
|
||||
$failed = 0
|
||||
|
||||
if($batch.Count -gt 0) {
|
||||
Write-Status -Detail ("Updating scope tags on {0} policy(ies)" -f $batch.Count) -SkipLog
|
||||
$results = @(Invoke-GraphBatchRequest -BatchObjects $batch -BatchType "BulkScopeTags" -TokenId $TokenId -IncludedFailed -SkipWarnings)
|
||||
if($results.Count -eq 0) {
|
||||
$failed += $batch.Count
|
||||
Write-Log "Set-GraphBulkScopeTags: batch returned no responses; treating all $($batch.Count) PATCH request(s) as failed" 3
|
||||
}
|
||||
else {
|
||||
foreach($r in $results) {
|
||||
$entry = $byBatchId["$($r.Id)"]
|
||||
if(-not $entry) { continue }
|
||||
$statusCode = 0
|
||||
try { $statusCode = [int]$r.status } catch { }
|
||||
|
||||
if($statusCode -ge 200 -and $statusCode -lt 300) {
|
||||
$updated++
|
||||
# Mirror to in-memory PSCustomObject so a subsequent UI refresh
|
||||
# shows the new tag list without an extra GET.
|
||||
try {
|
||||
$prop = [string]$entry.Policy.PolicyType.ScopeTagProperty
|
||||
if($entry.Policy.Object.PSObject.Properties[$prop]) {
|
||||
$entry.Policy.Object.$prop = $entry.NewIds
|
||||
}
|
||||
else {
|
||||
$entry.Policy.Object | Add-Member -MemberType NoteProperty -Name $prop -Value $entry.NewIds -Force
|
||||
}
|
||||
$entry.Policy._ScopeTags = $null
|
||||
$entry.Policy._ScopeTagsString = $null
|
||||
}
|
||||
catch { Write-LogDebug "Set-GraphBulkScopeTags: in-memory mirror failed for $($entry.Policy.Name): $($_.Exception.Message)" }
|
||||
}
|
||||
else {
|
||||
$failed++
|
||||
$msg = ""
|
||||
if($r.body -and $r.body.error -and $r.body.error.message) { $msg = $r.body.error.message }
|
||||
Write-Log "Set-GraphBulkScopeTags: PATCH failed for '$($entry.Policy.Name)' [$($entry.Policy.PolicyType.Title)] - $statusCode $msg" 3
|
||||
}
|
||||
}
|
||||
|
||||
$seenIds = [System.Collections.Generic.HashSet[string]]::new()
|
||||
foreach($r in $results) { [void]$seenIds.Add([string]$r.Id) }
|
||||
foreach($pending in $byBatchId.Keys) {
|
||||
if(-not $seenIds.Contains([string]$pending)) {
|
||||
$failed++
|
||||
$entry = $byBatchId[$pending]
|
||||
Write-Log "Set-GraphBulkScopeTags: no batch response for '$($entry.Policy.Name)' [$($entry.Policy.PolicyType.Title)]" 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Status ""
|
||||
|
||||
[PSCustomObject]@{
|
||||
Types = $targetTypes.Count
|
||||
PoliciesScanned = $scanned
|
||||
PoliciesMatched = $matched
|
||||
PoliciesUpdated = $updated
|
||||
PoliciesSkipped = $skipped
|
||||
PoliciesFailed = $failed
|
||||
UnknownSelectors = $unknownSelectors
|
||||
Duration = $stopwatch.Elapsed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Write an IntuneManagement setting by key.
|
||||
|
||||
.DESCRIPTION
|
||||
Exported as Set-IMSetting.
|
||||
|
||||
A key and a value is all that is needed. The storage path comes from the
|
||||
setting's registration, so the value always lands where Get-IMSetting (and the
|
||||
application) will look for it - which hand-written paths repeatedly did not.
|
||||
|
||||
THIS WRITES TO THE PERSISTENT STORE by default: the registry on Windows, the
|
||||
settings file elsewhere. That is deliberate - configuring the tool from a script
|
||||
should not need an extra opt-in switch - but it means a script run on a shared
|
||||
machine changes that machine's configuration. Two ways to avoid it:
|
||||
|
||||
Use-IMSettingsStore -Memory nothing is written to disk for this session
|
||||
Get-IMSettingsStore assert which store is active before writing
|
||||
|
||||
Every write is logged with the store and the path it went to.
|
||||
|
||||
Values are stored in exactly the shape the settings dialog stores them in, so a
|
||||
value written here is indistinguishable from one set in the UI.
|
||||
|
||||
.PARAMETER Key
|
||||
The setting key. Use Get-IMSettingDefinition to discover the available keys.
|
||||
|
||||
.PARAMETER Value
|
||||
The value to store. $null removes the value (see also Remove-IMSetting).
|
||||
Booleans accept $true/$false or 'true'/'false' in any case.
|
||||
|
||||
.PARAMETER Scope
|
||||
Global (default) writes the value every tenant sees. Tenant writes it for one
|
||||
tenant only, which takes precedence over the global value when that tenant is
|
||||
connected; it fails rather than quietly falling back to a global write when no
|
||||
tenant id can be resolved.
|
||||
|
||||
.PARAMETER TenantID
|
||||
The tenant to write for. Defaults to the connected tenant. Implies nothing on
|
||||
its own - pass -Scope Tenant to select the tenant scope.
|
||||
|
||||
.PARAMETER SubPath
|
||||
Storage path for a key that is not a registered setting (the hidden keys, and
|
||||
per-feature state). Required in that case. For a registered setting it is
|
||||
reported and ignored - the registration decides where the value is stored, so a
|
||||
write cannot be aimed at a path the application never reads.
|
||||
|
||||
.PARAMETER PassThru
|
||||
Return the resolved setting after writing it.
|
||||
|
||||
.EXAMPLE
|
||||
Set-IMSetting ExportFolder 'C:\Intune\Export'
|
||||
|
||||
.EXAMPLE
|
||||
Use-IMSettingsStore -Memory -Seed
|
||||
Set-IMSetting UseBatchAPI $true
|
||||
Set-IMSetting GraphPageSize 500
|
||||
|
||||
Configure a run without touching the machine's stored settings - the pattern for
|
||||
a runbook on a shared worker.
|
||||
|
||||
.EXAMPLE
|
||||
Set-IMSetting ExportFolder '\\server\intune\contoso' -Scope Tenant
|
||||
|
||||
Set the export folder for the connected tenant only.
|
||||
|
||||
.EXAMPLE
|
||||
Set-IMSetting ExportReplaceTokens 'TenantId' -SubPath 'IntuneManager'
|
||||
|
||||
A key with no entry in the settings dialog needs its path spelled out.
|
||||
|
||||
.LINK
|
||||
Get-IMSetting
|
||||
.LINK
|
||||
Remove-IMSetting
|
||||
.LINK
|
||||
Use-IMSettingsStore
|
||||
#>
|
||||
function Set-Setting
|
||||
{
|
||||
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, Position = 0)]
|
||||
[string]$Key,
|
||||
[Parameter(Mandatory = $true, Position = 1)]
|
||||
[AllowNull()]
|
||||
[AllowEmptyString()]
|
||||
$Value,
|
||||
# Same vocabulary as Get-IMSetting -Scope, minus Effective: there is no such
|
||||
# thing as writing the effective value.
|
||||
[ValidateSet("Global", "Tenant")]
|
||||
[string]$Scope = "Global",
|
||||
[string]$TenantID,
|
||||
$SubPath = $null,
|
||||
[switch]$PassThru
|
||||
)
|
||||
|
||||
$store = Get-SettingsStoreInfo
|
||||
$target = if($store.Path) { "$($store.Mode) store at $($store.Path)" } else { "$($store.Mode) store" }
|
||||
|
||||
$scopeText = if($Scope -eq "Tenant") { " for tenant scope" } else { "" }
|
||||
|
||||
if(-not $PSCmdlet.ShouldProcess($target, "Set setting '$Key' to '$Value'$scopeText")) { return }
|
||||
|
||||
# Logged, not just debug-logged: a write that persisted to a machine's registry
|
||||
# or settings file should be visible in the log afterwards.
|
||||
Write-Log "Set setting '$Key' in the $target$scopeText"
|
||||
|
||||
Set-SettingValue -Key $Key -Value $Value -Tenant:($Scope -eq "Tenant") -TenantID $TenantID -SubPath $SubPath -PassThru:$PassThru
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
function Start-GraphBulkCopy {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bulk-copy Intune policy objects by name pattern.
|
||||
|
||||
.DESCRIPTION
|
||||
Public, UI-independent driver for bulk copy (ported from the original
|
||||
project's Copy extension). For every selected policy type, each object
|
||||
whose name contains CopyFromPattern is copied with the pattern replaced
|
||||
by CopyToPattern (e.g. "Test - Baseline" -> "Prod - Baseline").
|
||||
|
||||
Objects are skipped when an object of the same @odata.type already has
|
||||
the target name, so the operation is safe to re-run.
|
||||
|
||||
The WPF and Avalonia UIs are thin callers of this function; the same
|
||||
function can be invoked from a scheduled task or pipeline.
|
||||
|
||||
.PARAMETER CopyFromPattern
|
||||
Substring identifying the source objects (matched case-insensitively
|
||||
against the policy name).
|
||||
|
||||
.PARAMETER CopyToPattern
|
||||
Replacement text for CopyFromPattern in the copied object's name.
|
||||
|
||||
.PARAMETER PolicyType
|
||||
Restrict the copy to these PolicyType IDs. If both PolicyType and
|
||||
PolicyGroup are omitted, every type whose group allows Copy is processed.
|
||||
|
||||
.PARAMETER PolicyGroup
|
||||
Restrict the copy to these PolicyGroup IDs (expanded to their member types).
|
||||
|
||||
.PARAMETER TokenId
|
||||
Authentication token id. Defaults to the current default token.
|
||||
|
||||
.EXAMPLE
|
||||
Start-GraphBulkCopy -CopyFromPattern "Test - " -CopyToPattern "Prod - "
|
||||
|
||||
.EXAMPLE
|
||||
Start-GraphBulkCopy -CopyFromPattern "Pilot" -CopyToPattern "Rollout" -PolicyGroup DeviceConfiguration
|
||||
|
||||
.OUTPUTS
|
||||
PSCustomObject with summary statistics (Types, Copied, Skipped, FailedTypes, Duration).
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]
|
||||
$CopyFromPattern,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]
|
||||
$CopyToPattern,
|
||||
|
||||
[string[]]
|
||||
$PolicyType,
|
||||
|
||||
[string[]]
|
||||
$PolicyGroup,
|
||||
|
||||
[Int]
|
||||
$TokenId = (Get-DefaultTokenId)
|
||||
)
|
||||
|
||||
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
# ---- 1. Determine target policy types (same shape as Start-GraphBulkExport) ----
|
||||
$selection = Resolve-IntuneTargetSelectors -PolicyType $PolicyType -PolicyGroup $PolicyGroup -Caller 'Start-GraphBulkCopy'
|
||||
$unknownSelectors = $selection.Unknown # ids from -PolicyType/-PolicyGroup that matched nothing
|
||||
$targetTypes = [System.Collections.Generic.List[object]]::new()
|
||||
$targetTypes.AddRange([object[]]$selection.Types)
|
||||
|
||||
if (-not $PolicyType -and -not $PolicyGroup) {
|
||||
foreach ($grp in $script:IntuneGroups) {
|
||||
if (-not $grp.Title) { continue }
|
||||
if ($grp.ShowButtons -is [Object[]] -and $grp.ShowButtons -notcontains "Copy") { continue }
|
||||
foreach ($pt in $grp.PolicyTypes) { [void]$targetTypes.Add($pt) }
|
||||
}
|
||||
}
|
||||
|
||||
$targetTypes = @($targetTypes | Sort-Object -Property Id -Unique)
|
||||
|
||||
if ($targetTypes.Count -eq 0) {
|
||||
Write-Log "Bulk copy: no policy types selected" 2
|
||||
return [PSCustomObject]@{ Types = 0; Copied = 0; Skipped = 0; FailedTypes = 0; UnknownSelectors = $unknownSelectors; Duration = $stopwatch.Elapsed }
|
||||
}
|
||||
|
||||
Write-Log "****************************************************************"
|
||||
Write-Log "Start bulk copy ('$CopyFromPattern' -> '$CopyToPattern', $($targetTypes.Count) policy type(s))"
|
||||
Write-Log "****************************************************************"
|
||||
|
||||
$escapedFrom = [regex]::Escape($CopyFromPattern)
|
||||
$totalCopied = 0
|
||||
$totalSkipped = 0
|
||||
$failedTypes = 0
|
||||
$typeIndex = 0
|
||||
|
||||
foreach ($pt in $targetTypes) {
|
||||
$typeIndex++
|
||||
Write-Status -Text ("Bulk copy - {0} ({1} of {2})" -f $pt.Title, $typeIndex, $targetTypes.Count) -Detail "Listing policies" -SkipLog -Force
|
||||
|
||||
try {
|
||||
$policies = @(Get-GraphPolicies -PolicyType $pt.Id -TokenId $TokenId -ErrorAction Stop)
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Bulk copy: failed to list $($pt.Title) objects" $_.Exception
|
||||
$failedTypes++
|
||||
continue
|
||||
}
|
||||
if ($policies.Count -eq 0) { continue }
|
||||
|
||||
$sources = @($policies | Where-Object { $_.Name -imatch $escapedFrom })
|
||||
if ($sources.Count -eq 0) { continue }
|
||||
|
||||
Write-Log "----------------------------------------------------------------"
|
||||
Write-Log "Copy $($pt.Title) objects"
|
||||
Write-Log "----------------------------------------------------------------"
|
||||
|
||||
# Existing-name index so re-runs don't create duplicates. Keyed on
|
||||
# @odata.type + name, matching the original implementation.
|
||||
$existing = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($p in $policies) {
|
||||
[void]$existing.Add("$($p.JsonObject.'@odata.type')|$($p.Name)")
|
||||
}
|
||||
|
||||
$toCopy = @()
|
||||
foreach ($src in $sources) {
|
||||
$targetName = $src.Name -ireplace $escapedFrom, $CopyToPattern
|
||||
if ($existing.Contains("$($src.JsonObject.'@odata.type')|$targetName")) {
|
||||
Write-Log "Object with name '$targetName' already exists. '$($src.Name)' will not be copied" 2
|
||||
$totalSkipped++
|
||||
continue
|
||||
}
|
||||
$toCopy += $src
|
||||
}
|
||||
if ($toCopy.Count -eq 0) { continue }
|
||||
|
||||
Write-Status -Detail ("Copying {0} object(s)" -f $toCopy.Count) -SkipLog -Force
|
||||
try {
|
||||
$copied = @($toCopy | Copy-GraphPolicy -CopyFromPatternName $CopyFromPattern -Name $CopyToPattern -TokenId $TokenId)
|
||||
$totalCopied += $copied.Count
|
||||
if ($copied.Count -lt $toCopy.Count) {
|
||||
Write-Log "Bulk copy: $($toCopy.Count - $copied.Count) $($pt.Title) object(s) failed to copy" 2
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Bulk copy: failed while copying $($pt.Title) objects" $_.Exception
|
||||
$failedTypes++
|
||||
}
|
||||
}
|
||||
|
||||
Write-Status $null
|
||||
Write-Log "****************************************************************"
|
||||
Write-Log "Bulk copy finished. Copied: $totalCopied, skipped (name exists): $totalSkipped"
|
||||
Write-Log "****************************************************************"
|
||||
|
||||
return [PSCustomObject]@{
|
||||
Types = $targetTypes.Count
|
||||
Copied = $totalCopied
|
||||
Skipped = $totalSkipped
|
||||
FailedTypes = $failedTypes
|
||||
UnknownSelectors = $unknownSelectors
|
||||
Duration = $stopwatch.Elapsed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
function Start-GraphBulkDelete {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bulk-delete Intune policy objects.
|
||||
|
||||
.DESCRIPTION
|
||||
Public, UI-independent driver for bulk delete. The WPF and Avalonia
|
||||
bulk-delete forms are thin callers of this function (they own the
|
||||
confirmation prompt); the same function can be invoked from a
|
||||
scheduled task or pipeline.
|
||||
|
||||
DESTRUCTIVE: every object of the selected groups (matching the
|
||||
optional name filter) is deleted from the signed-in tenant. There is
|
||||
no confirmation in this driver - callers confirm before invoking.
|
||||
|
||||
Groups are processed in DESCENDING ImportOrder so dependents are
|
||||
deleted before their dependencies.
|
||||
|
||||
.PARAMETER Filter
|
||||
Name filter (literal substring, case-insensitive). Empty = delete
|
||||
every object of the selected groups.
|
||||
|
||||
.PARAMETER PolicyGroup
|
||||
Restrict the delete to these PolicyGroup IDs. Mandatory - bulk
|
||||
deleting every group implicitly is too dangerous for a default.
|
||||
|
||||
.PARAMETER TokenId
|
||||
Authentication token id. Defaults to the current default token.
|
||||
|
||||
.EXAMPLE
|
||||
Start-GraphBulkDelete -PolicyGroup DeviceConfiguration -Filter "[Test]"
|
||||
|
||||
.OUTPUTS
|
||||
PSCustomObject with summary statistics (Groups, Deleted, Duration).
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]
|
||||
$Filter,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]
|
||||
$PolicyGroup,
|
||||
|
||||
[Int]
|
||||
$TokenId = (Get-DefaultTokenId)
|
||||
)
|
||||
|
||||
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
$selection = Resolve-IntuneTargetSelectors -PolicyGroup $PolicyGroup -Caller 'Start-GraphBulkDelete'
|
||||
$unknownSelectors = $selection.Unknown # ids from -PolicyGroup that matched nothing
|
||||
$targetGroups = [System.Collections.Generic.List[object]]::new()
|
||||
$targetGroups.AddRange([object[]]$selection.Groups)
|
||||
if ($targetGroups.Count -eq 0) {
|
||||
Write-Log "Bulk delete: no policy groups selected" 2
|
||||
return [PSCustomObject]@{ Groups = 0; Deleted = 0; UnknownSelectors = $unknownSelectors; Duration = $stopwatch.Elapsed }
|
||||
}
|
||||
|
||||
Write-Log "****************************************************************"
|
||||
Write-Log "Start bulk delete"
|
||||
Write-Log "****************************************************************"
|
||||
|
||||
$totalDeleted = 0
|
||||
# Reverse import-order so dependents are deleted before their dependencies.
|
||||
$orderedGroups = @($targetGroups | Sort-Object { ($_.PolicyTypes | Measure-Object ImportOrder -Minimum).Minimum } -Descending)
|
||||
$groupTotal = $orderedGroups.Count
|
||||
$groupIndex = 0
|
||||
|
||||
foreach ($grp in $orderedGroups) {
|
||||
$groupIndex++
|
||||
Write-Log "----------------------------------------------------------------"
|
||||
Write-Log "Delete $($grp.Title) objects"
|
||||
Write-Log "----------------------------------------------------------------"
|
||||
|
||||
try {
|
||||
Write-Status `
|
||||
-Text ("Bulk delete - {0} ({1} of {2})" -f $grp.Title, $groupIndex, $groupTotal) `
|
||||
-Detail "Listing policies" `
|
||||
-Force
|
||||
$policies = @(Get-GraphPolicies -PolicyGroup $grp.ID -TokenId $TokenId)
|
||||
|
||||
if ($Filter) {
|
||||
$policies = @($policies | Where-Object { $_.Name -match [RegEx]::Escape($Filter) })
|
||||
}
|
||||
|
||||
if ($policies.Count -eq 0) {
|
||||
Write-Log "No $($grp.Title) objects found"
|
||||
continue
|
||||
}
|
||||
|
||||
Write-Log "Deleting $($policies.Count) $($grp.Title) object(s)"
|
||||
Write-Status -Detail ("Deleting {0} object(s)" -f $policies.Count) -SkipLog -Force
|
||||
$policies | Remove-GraphPolicy -Confirm:$false | Out-Null
|
||||
$totalDeleted += $policies.Count
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed when deleting $($grp.Title) objects" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
Write-Status $null
|
||||
Write-Log "****************************************************************"
|
||||
Write-Log "Bulk delete finished"
|
||||
Write-Log "****************************************************************"
|
||||
|
||||
return [PSCustomObject]@{
|
||||
Groups = $orderedGroups.Count
|
||||
Deleted = $totalDeleted
|
||||
UnknownSelectors = $unknownSelectors
|
||||
Duration = $stopwatch.Elapsed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bulk-document Intune/Entra policies through one or more output providers.
|
||||
|
||||
.DESCRIPTION
|
||||
Public, UI-independent driver for bulk documentation. The WPF UI is a
|
||||
thin caller of this function; the same function can be invoked from a
|
||||
scheduled task or CI/CD pipeline without loading any XAML.
|
||||
|
||||
Iterates the supplied PolicyObjects (or, if -PolicyType/-PolicyGroup are
|
||||
used, the policies fetched via Get-GraphPolicies), runs each through
|
||||
Invoke-DocumentationForObject to build a per-object result, and drives
|
||||
each selected output provider's lifecycle hooks:
|
||||
Activate -> PreProcess
|
||||
-> (NewObjectGroup -> NewObjectType -> Process* -> ProcessAllObjects)*
|
||||
-> PostProcess
|
||||
|
||||
.PARAMETER OutputFormat
|
||||
Comma-separated registered output Values (e.g. 'json,md'). Each one
|
||||
must be in [DocumentationRegistry]::Outputs (run Get-DocumentationOutput
|
||||
to list).
|
||||
|
||||
.PARAMETER PolicyObject
|
||||
Pre-fetched policy objects. If omitted, the function fetches via
|
||||
Get-GraphPolicies using -PolicyType / -PolicyGroup.
|
||||
|
||||
.PARAMETER PolicyType
|
||||
Restrict iteration to these PolicyType IDs.
|
||||
|
||||
.PARAMETER PolicyGroup
|
||||
Restrict iteration to these PolicyGroup IDs.
|
||||
|
||||
.PARAMETER Language
|
||||
Language code for translatable strings. Defaults to 'en'.
|
||||
|
||||
.PARAMETER SourceFolder
|
||||
Load policies from an exported folder instead of querying Graph.
|
||||
|
||||
.PARAMETER Options
|
||||
Hashtable of engine-wide flags. See Get-GraphDocumentation.
|
||||
|
||||
.EXAMPLE
|
||||
Start-GraphBulkDocumentation -OutputFormat json -PolicyType ConditionalAccessType
|
||||
|
||||
.EXAMPLE
|
||||
$policies | Start-GraphBulkDocumentation -OutputFormat 'md'
|
||||
#>
|
||||
function Start-GraphBulkDocumentation {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory, Position = 0)]
|
||||
# Completion instead of [ValidateSet([DocumentationOutputProvider])] for PS5.1
|
||||
# import compatibility (the generator is PS7-only). Also lets comma-separated
|
||||
# values ('json,md') through, which a single-value ValidateSet would reject.
|
||||
# Unknown formats are ignored at runtime (matched against the output registry).
|
||||
[ArgumentCompleter({ param($commandName, $parameterName, $wordToComplete) @(& (Get-Module IntuneManagement) { Get-DocumentationOutputValues }) | Where-Object { $_ -like "$wordToComplete*" } })]
|
||||
[string]
|
||||
$OutputFormat,
|
||||
[Parameter(ValueFromPipeline, Mandatory = $true, ParameterSetName = 'PolicyObject', Position = 1)]
|
||||
$PolicyObject,
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'PolicyType', Position = 1)]
|
||||
[ArgumentCompleter({ param($commandName, $parameterName, $wordToComplete) @(& (Get-Module IntuneManagement) { Get-IntunePolicyTypeValues }) | Where-Object { $_ -like "$wordToComplete*" } })]
|
||||
[string[]]
|
||||
$PolicyType,
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'PolicyGroup', Position = 1)]
|
||||
[ArgumentCompleter({ param($commandName, $parameterName, $wordToComplete) @(& (Get-Module IntuneManagement) { Get-IntunePolicyGroupValues }) | Where-Object { $_ -like "$wordToComplete*" } })]
|
||||
[string[]]
|
||||
$PolicyGroup,
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'Folder', Position = 1)]
|
||||
[string]
|
||||
$SourceFolder,
|
||||
[string]
|
||||
$Language = 'en',
|
||||
[hashtable]
|
||||
$Options
|
||||
)
|
||||
|
||||
begin {
|
||||
$collected = [System.Collections.Generic.List[object]]::new()
|
||||
}
|
||||
|
||||
process {
|
||||
if ($PolicyObject) {
|
||||
foreach ($p in $PolicyObject) { $collected.Add($p) }
|
||||
}
|
||||
}
|
||||
|
||||
end {
|
||||
if ($SourceFolder) {
|
||||
if (-not $Options) { $Options = @{} }
|
||||
$Options.SourceTenantUnavailable = $true
|
||||
Initialize-DocumentationSourceTenantContext -SourceFolder $SourceFolder
|
||||
if ($collected.Count -eq 0) {
|
||||
$fetched = Get-DocumentationPoliciesFromSourceFolder -SourceFolder $SourceFolder -PolicyType $PolicyType -PolicyGroup $PolicyGroup
|
||||
foreach ($p in $fetched) { $collected.Add($p) }
|
||||
}
|
||||
}
|
||||
elseif ($collected.Count -eq 0 -and ($PolicyType -or $PolicyGroup)) {
|
||||
$params = @{}
|
||||
if ($PolicyType) { $params['PolicyType'] = $PolicyType }
|
||||
if ($PolicyGroup) { $params['PolicyGroup'] = $PolicyGroup }
|
||||
$fetched = Get-GraphPolicies @params
|
||||
foreach ($p in $fetched) { $collected.Add($p) }
|
||||
}
|
||||
|
||||
if ($collected.Count -eq 0) {
|
||||
Write-Log "Start-GraphBulkDocumentation: no policies to document" 2
|
||||
return
|
||||
}
|
||||
Write-Log "$($collected.Count) policies found"
|
||||
|
||||
$items = $collected.ToArray()
|
||||
# Save/restore module-wide language: generation points Get-LanguageString at
|
||||
# the requested language; other consumers must not inherit it afterwards.
|
||||
$prevLang = $script:CurrentLanguage
|
||||
try {
|
||||
Set-DocumentationContextRunOptions -Context (Get-DocContextSingleton) -Options $Options -Language $Language
|
||||
|
||||
# NameFilter can be a legacy string (plain substring / scope: / tag:) and/or
|
||||
# scriptblock stages: LIST{ } runs pre-hydrate (cheap, drops items before they
|
||||
# are hydrated); ITEM{ } runs post-hydrate (has full body incl. scope tags —
|
||||
# required for types whose list endpoint omits roleScopeTagIds, e.g. Applications).
|
||||
if ($Options -and $Options.NameFilter) {
|
||||
$parsed = ConvertFrom-DocumentationNameFilter ([string]$Options.NameFilter)
|
||||
|
||||
# LIST stage (pre-hydrate): legacy match + LIST scriptblock.
|
||||
if ($parsed.Legacy) {
|
||||
# A scope:/tag: filter needs scope tags. Some types (e.g. Applications,
|
||||
# _ScopeTagsReturnedInList = $false) don't return roleScopeTagIds in the
|
||||
# list response - only on the full per-object GET - so filtering them
|
||||
# pre-hydrate would wrongly drop every one. Filter the types that DO
|
||||
# expose tags in the list now (cheap), and defer the rest to a post-
|
||||
# hydrate pass. A plain-substring name filter matches .Name (always
|
||||
# present pre-hydrate), so it keeps the single cheap pass.
|
||||
if ($parsed.Legacy.Trim() -match '^(?i:scope|tag):') {
|
||||
# NOTE: ScopeTagsReturnedInList is a property of the PolicyType
|
||||
# (IntunePolicyTypeBase), not the policy instance - so it must be
|
||||
# read via .PolicyType. Reading it off the instance returns $null
|
||||
# and would route every item to the ready bucket.
|
||||
$deferred = @($items | Where-Object { $_.PolicyType -and $_.PolicyType.ScopeTagsReturnedInList -eq $false })
|
||||
$ready = @($items | Where-Object { -not ($_.PolicyType -and $_.PolicyType.ScopeTagsReturnedInList -eq $false) })
|
||||
|
||||
$ready = @($ready | Where-Object { Test-DocumentationPolicyFilter -PolicyObject $_ -Filter $parsed.Legacy })
|
||||
|
||||
if ($deferred.Count -gt 0) {
|
||||
# Hydrate the deferred survivors so their scope tags populate.
|
||||
# Skipped offline (SourceFolder): those file objects carry
|
||||
# roleScopeTags already and have no token to hydrate against.
|
||||
if (-not $Options.SourceTenantUnavailable) {
|
||||
$hydrateTargets = @($deferred | Where-Object {
|
||||
$_ -and $_.PSObject.Properties['_IsFullObject'] -and -not $_._IsFullObject -and
|
||||
$_.Id -and $_.PolicyType -and $_.IsFromFile -ne $true
|
||||
})
|
||||
if ($hydrateTargets.Count -gt 0) {
|
||||
Write-Status "Documentation - hydrating policies for scope filter" -SkipLog -Force
|
||||
Invoke-PolicyHydrate -Policies $hydrateTargets
|
||||
}
|
||||
}
|
||||
$deferred = @($deferred | Where-Object { Test-DocumentationPolicyFilter -PolicyObject $_ -Filter $parsed.Legacy })
|
||||
}
|
||||
|
||||
$items = @($ready) + @($deferred)
|
||||
}
|
||||
else {
|
||||
$items = @($items | Where-Object { Test-DocumentationPolicyFilter -PolicyObject $_ -Filter $parsed.Legacy })
|
||||
}
|
||||
}
|
||||
if ($parsed.List) {
|
||||
$items = @($items | Where-Object { Test-DocumentationFilterScriptBlock -PolicyObject $_ -ScriptBlock $parsed.List })
|
||||
}
|
||||
|
||||
# ITEM stage (post-hydrate): hydrate the survivors, then filter. Skipped
|
||||
# for SourceFolder (offline) runs — those objects have no token to hydrate
|
||||
# against and rely on whatever the export already carries. Invoke-PolicyHydrate
|
||||
# is idempotent, so the engine's own later hydrate call is a no-op for these.
|
||||
if ($parsed.Item -and -not ($Options.SourceTenantUnavailable)) {
|
||||
$hydrateTargets = @($items | Where-Object {
|
||||
$_ -and $_.PSObject.Properties['_IsFullObject'] -and -not $_._IsFullObject -and
|
||||
$_.Id -and $_.PolicyType -and $_.IsFromFile -ne $true
|
||||
})
|
||||
if ($hydrateTargets.Count -gt 0) {
|
||||
Write-Status "Documentation - hydrating policies for ITEM filter" -SkipLog -Force
|
||||
Invoke-PolicyHydrate -Policies $hydrateTargets
|
||||
}
|
||||
$items = @($items | Where-Object { Test-DocumentationFilterScriptBlock -PolicyObject $_ -ScriptBlock $parsed.Item })
|
||||
}
|
||||
}
|
||||
Write-Log "$($items.Count) policies to document"
|
||||
Invoke-DocumentationOutputs -OutputValue $OutputFormat -PolicyObjects $items -Options $Options -Language $Language
|
||||
}
|
||||
finally {
|
||||
$script:CurrentLanguage = $prevLang
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
function Start-GraphBulkExport {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bulk-export Intune policy objects to disk.
|
||||
|
||||
.DESCRIPTION
|
||||
Public, UI-independent driver for bulk export. The WPF UI is a thin caller of
|
||||
this function; the same function can be invoked from a scheduled task or
|
||||
CI/CD pipeline without loading any XAML.
|
||||
|
||||
Parameter precedence: SettingsFile < ExportSettings instance < explicit params.
|
||||
|
||||
.PARAMETER ExportSettings
|
||||
An [IntuneManagerExportSettings] instance. If omitted, a fresh instance is
|
||||
created (which loads defaults from user settings).
|
||||
|
||||
.PARAMETER SettingsFile
|
||||
Path to a JSON file produced by the "Save settings for batch job" button.
|
||||
Loaded first; any other explicit parameters override its values.
|
||||
|
||||
.PARAMETER ExportFolder
|
||||
Root folder for the export. Overrides ExportSettings.ExportFolder.
|
||||
|
||||
.PARAMETER Filter
|
||||
Name filter (literal substring, case-insensitive) matched against each
|
||||
policy's Name. Empty = no filter.
|
||||
|
||||
.PARAMETER ExportAssignments
|
||||
Include assignments in each exported file.
|
||||
|
||||
.PARAMETER AddCompanyName
|
||||
Append the current tenant's organization display name as an additional folder
|
||||
level under ExportFolder.
|
||||
|
||||
.PARAMETER PolicyType
|
||||
Restrict export to these PolicyType IDs. If both PolicyType and PolicyGroup
|
||||
are omitted, every group that allows export is processed.
|
||||
|
||||
.PARAMETER PolicyGroup
|
||||
Restrict export to these PolicyGroup IDs (expanded to their member types).
|
||||
|
||||
.PARAMETER TokenId
|
||||
Authentication token id. Defaults to the current default token.
|
||||
|
||||
.EXAMPLE
|
||||
Start-GraphBulkExport -ExportFolder C:\IntuneExport -PolicyGroup DeviceConfiguration
|
||||
|
||||
.EXAMPLE
|
||||
Start-GraphBulkExport -SettingsFile .\nightly-export.json
|
||||
|
||||
.OUTPUTS
|
||||
PSCustomObject with summary statistics (Types, Policies, Failed, Duration).
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[IntuneManagerExportSettings]
|
||||
$ExportSettings,
|
||||
|
||||
[string]
|
||||
$SettingsFile,
|
||||
|
||||
[string]
|
||||
$ExportFolder,
|
||||
|
||||
[string]
|
||||
$Filter,
|
||||
|
||||
[Nullable[bool]]
|
||||
$ExportAssignments,
|
||||
|
||||
[Nullable[bool]]
|
||||
$AddCompanyName,
|
||||
|
||||
[string[]]
|
||||
$PolicyType,
|
||||
|
||||
[string[]]
|
||||
$PolicyGroup,
|
||||
|
||||
[Int]
|
||||
$TokenId = (Get-DefaultTokenId)
|
||||
)
|
||||
|
||||
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
# ---- 1. Resolve settings ----
|
||||
$fileSettings = $null
|
||||
if ($SettingsFile) {
|
||||
if (-not (Test-Path -LiteralPath $SettingsFile)) {
|
||||
throw "Settings file '$SettingsFile' not found"
|
||||
}
|
||||
try {
|
||||
$fileSettings = [IO.File]::ReadAllText($SettingsFile) | ConvertFrom-Json
|
||||
}
|
||||
catch {
|
||||
throw "Failed to parse settings file '$SettingsFile': $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $ExportSettings) {
|
||||
$ExportSettings = [IntuneManagerExportSettings]::new()
|
||||
}
|
||||
|
||||
# ExportFullMembershipPrefixes was removed. A value can survive that in a
|
||||
# settings file OR in the settings store (global and per-tenant), and none of
|
||||
# them can be honoured any more - report them before the export starts rather
|
||||
# than silently dropping membership data from the output. Runs whether or not
|
||||
# a settings file was given, because a stored value needs no file to exist.
|
||||
Clear-BulkExportLegacyMembershipSetting -FileSettings $fileSettings -SettingsFile $SettingsFile -TokenId $TokenId
|
||||
|
||||
# File overrides defaults; explicit params override file.
|
||||
if ($fileSettings) {
|
||||
if ($null -ne $fileSettings.ExportFolder) { $ExportSettings.ExportFolder = $fileSettings.ExportFolder }
|
||||
if ($null -ne $fileSettings.Filter) { $ExportSettings.Filter = $fileSettings.Filter }
|
||||
if ($null -ne $fileSettings.ExportAssignments) { $ExportSettings.ExportAssignments = [bool]$fileSettings.ExportAssignments }
|
||||
if ($null -ne $fileSettings.AddCompanyName) { $ExportSettings.AddCompanyName = [bool]$fileSettings.AddCompanyName }
|
||||
if ($null -ne $fileSettings.AddObjectType) { $ExportSettings.AddObjectType = [bool]$fileSettings.AddObjectType }
|
||||
if ($null -ne $fileSettings.ExportNestedGroupLevels) {
|
||||
$n = 0
|
||||
if([int]::TryParse([string]$fileSettings.ExportNestedGroupLevels, [ref]$n) -and $n -ge 1) {
|
||||
$ExportSettings.ExportNestedGroupLevels = $n
|
||||
}
|
||||
}
|
||||
if (-not $PolicyType -and $fileSettings.PolicyType) { $PolicyType = @($fileSettings.PolicyType) }
|
||||
if (-not $PolicyGroup -and $fileSettings.PolicyGroup) { $PolicyGroup = @($fileSettings.PolicyGroup) }
|
||||
}
|
||||
|
||||
if ($PSBoundParameters.ContainsKey('ExportFolder')) { $ExportSettings.ExportFolder = $ExportFolder }
|
||||
if ($PSBoundParameters.ContainsKey('Filter')) { $ExportSettings.Filter = $Filter }
|
||||
if ($PSBoundParameters.ContainsKey('ExportAssignments')) { $ExportSettings.ExportAssignments = [bool]$ExportAssignments }
|
||||
if ($PSBoundParameters.ContainsKey('AddCompanyName')) { $ExportSettings.AddCompanyName = [bool]$AddCompanyName }
|
||||
|
||||
if (-not $ExportSettings.ExportFolder) {
|
||||
throw "ExportFolder is required (set on ExportSettings, -ExportFolder, or SettingsFile)"
|
||||
}
|
||||
|
||||
# ---- 2. Determine target policy types ----
|
||||
$selection = Resolve-IntuneTargetSelectors -PolicyType $PolicyType -PolicyGroup $PolicyGroup -Caller 'Start-GraphBulkExport'
|
||||
$unknownSelectors = $selection.Unknown # ids from -PolicyType/-PolicyGroup that matched nothing
|
||||
$targetTypes = [System.Collections.Generic.List[object]]::new()
|
||||
$targetTypes.AddRange([object[]]$selection.Types)
|
||||
|
||||
if (-not $PolicyType -and -not $PolicyGroup) {
|
||||
foreach ($grp in $script:IntuneGroups) {
|
||||
if (-not $grp.Title) { continue }
|
||||
if ($grp.ShowButtons -is [Object[]] -and $grp.ShowButtons -notcontains "Export") { continue }
|
||||
foreach ($pt in $grp.PolicyTypes) { [void]$targetTypes.Add($pt) }
|
||||
}
|
||||
}
|
||||
|
||||
# De-duplicate (a type can belong to >1 group)
|
||||
$targetTypes = @($targetTypes | Sort-Object -Property Id -Unique)
|
||||
|
||||
if ($targetTypes.Count -eq 0) {
|
||||
Write-Log "Bulk export: no policy types selected" 2
|
||||
return [PSCustomObject]@{ Types = 0; Policies = 0; Failed = 0; UnknownSelectors = $unknownSelectors; Duration = $stopwatch.Elapsed }
|
||||
}
|
||||
|
||||
# Pre-compile the name filter once. A literal substring, like 3.x and every
|
||||
# other bulk driver: '[Test]' means the text [Test], not a character class.
|
||||
$filterRegex = $null
|
||||
if ($ExportSettings.Filter) {
|
||||
$filterRegex = [Regex]::new([Regex]::Escape($ExportSettings.Filter), 'IgnoreCase')
|
||||
}
|
||||
|
||||
Write-Log "Bulk export: $($targetTypes.Count) policy type(s) → $($ExportSettings.ExportFolder)"
|
||||
|
||||
# Honour the ClearCacheBeforeExportImport setting (bit 1 = bulk export, on
|
||||
# by default) so re-exports always fetch fresh dependency objects.
|
||||
Invoke-GraphCacheClearBeforeOperation -Operation BulkExport -TokenId $TokenId
|
||||
|
||||
# Reset migration-table and AppConfig target-app caches at the start of every
|
||||
# bulk export. The caches are designed to dedupe work WITHIN a single export
|
||||
# (1 disk write per file at end, 1 Graph call per unique target app), so a
|
||||
# stale carry-over from a previous export to a different folder/tenant would
|
||||
# produce wrong contents in MigrationTable.json.
|
||||
$script:_migFileCache = @{}
|
||||
$script:_migFileObjectsIndex = @{}
|
||||
$script:_migFileDirty = @{}
|
||||
$script:_migFilePathCache = @{}
|
||||
$script:_appConfigTargetAppCache = @{}
|
||||
$script:_migPendingFetch = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
# Tell Export-GraphPolicy.End NOT to flush migration files per type — we batch
|
||||
# the writes across the entire bulk export and flush once at the very end.
|
||||
# Wrapped in try/finally so the flag always clears even if Phase 3 throws,
|
||||
# otherwise the next standalone Export-GraphPolicy call would also skip its flush.
|
||||
$script:_bulkExportDeferMigFlush = $true
|
||||
$script:_skipDirectGet = $true
|
||||
try {
|
||||
|
||||
# ---- 3. Export ----
|
||||
$totalPolicies = 0
|
||||
$failedTypes = 0
|
||||
|
||||
# One pipeline, whatever the concurrency setting:
|
||||
# 1. List every type's objects in ONE Get-GraphPolicies call.
|
||||
# 2. Batch-GET the full body for every listed object across all types.
|
||||
# 2.5 Pre-cache every Entra group the export will reference.
|
||||
# 2.6 Pre-walk the nested-group hierarchy when nested export is on.
|
||||
# 3. Write each policy to disk type-by-type.
|
||||
# Whether the $batch POSTs behind steps 1, 2, 2.5 and 2.6 go out one at a
|
||||
# time or concurrently is decided inside Invoke-GraphBatchRequest from the
|
||||
# UseParallelBatchAPI setting - it is not this function's concern. The old
|
||||
# per-type interleaved loop that ran when that setting was off is gone: it
|
||||
# skipped the group prefetch entirely, which cost a measured 22 minutes on a
|
||||
# 963-object tenant (one direct GET per assignment group at ~1.6 s each,
|
||||
# versus ~2 s for twenty of them in one $batch). See Docs/GraphBatching.md.
|
||||
# Phase 1 — list ALL types in a single Get-GraphPolicies call so the listing
|
||||
# round-trips ride the parallel-batch dispatcher (chunks of 20, throttle-many
|
||||
# POSTs concurrently). Calling Get-GraphPolicies once per type instead would
|
||||
# produce N single-item batches dispatched sequentially — for ~50 types that's
|
||||
# ~50 round-trips at ~1s each. Combined it's ceil(N/20) rounds run in parallel.
|
||||
Write-Status ("Bulk export - listing {0} policy type(s)" -f $targetTypes.Count) -SkipLog -Force
|
||||
|
||||
$perType = [ordered]@{}
|
||||
foreach ($pt in $targetTypes) { $perType[$pt.Id] = [System.Collections.Generic.List[object]]::new() }
|
||||
$allTypeIds = @($targetTypes | ForEach-Object { $_.Id })
|
||||
|
||||
try {
|
||||
$allPolicies = @(Get-GraphPolicies -PolicyType $allTypeIds -TokenId $TokenId -IncludeAssignments:$ExportSettings.ExportAssignments -ErrorAction Stop)
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Bulk export: failed to list policies" $_.Exception
|
||||
$failedTypes = $targetTypes.Count
|
||||
$allPolicies = @()
|
||||
}
|
||||
|
||||
# Bucket each returned policy back into its owning type so Phase 3 can iterate
|
||||
# type-by-type (which keeps per-type folder routing + status messages intact).
|
||||
# Dedup by policy Id within each bucket — some Graph endpoints have overlapping
|
||||
# paging cursors so Get-GraphPolicies' AllPages merge can include the same item
|
||||
# twice. Without this we'd write the same file (and run all subclass Get()
|
||||
# extras) once per duplicate, multiplying the wall time for nothing.
|
||||
$seenByType = @{}
|
||||
foreach ($p in $allPolicies) {
|
||||
$tid = $null
|
||||
if ($p.PolicyType) { $tid = $p.PolicyType.Id }
|
||||
if (-not ($tid -and $perType.Contains($tid))) { continue }
|
||||
if (-not $seenByType.ContainsKey($tid)) {
|
||||
$seenByType[$tid] = [System.Collections.Generic.HashSet[string]]::new()
|
||||
}
|
||||
$key = "$($p.Id)"
|
||||
if (-not $seenByType[$tid].Add($key)) {
|
||||
# Already seen this id under this type — skip duplicate.
|
||||
continue
|
||||
}
|
||||
[void]$perType[$tid].Add($p)
|
||||
}
|
||||
|
||||
if ($filterRegex) {
|
||||
foreach ($tid in @($perType.Keys)) {
|
||||
$filtered = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($p in $perType[$tid]) {
|
||||
if ($filterRegex.IsMatch([string]$p.Name)) { [void]$filtered.Add($p) }
|
||||
}
|
||||
$perType[$tid] = $filtered
|
||||
}
|
||||
}
|
||||
|
||||
# Phase 2 — batch-fetch full objects across every type at once
|
||||
$needFull = [System.Collections.Generic.List[object]]::new()
|
||||
$allPoliciesFlat = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($pt in $targetTypes) {
|
||||
$policies = $perType[$pt.Id]
|
||||
if (-not $policies) { continue }
|
||||
foreach ($p in $policies) {
|
||||
[void]$allPoliciesFlat.Add($p)
|
||||
if ($p.IsFullObject -eq $false -and $p.Id) { [void]$needFull.Add($p) }
|
||||
}
|
||||
}
|
||||
if ($allPoliciesFlat.Count -gt 0) {
|
||||
Write-Status "Bulk export - hydrating policies" -SkipLog -Force
|
||||
Invoke-PolicyHydrate -Policies $allPoliciesFlat -TokenId $TokenId
|
||||
}
|
||||
|
||||
# Phase 2.5 — pre-cache every AAD group the export will touch in one parallel
|
||||
# batch. Sources: assignment targets (when ExportAssignments is on), plus CA
|
||||
# conditions.users.includeGroups/excludeGroups and compliance notificationMessageCCList
|
||||
# (always exported, so always worth prefetching). Without this, the per-policy
|
||||
# PostExportCommand path does a sequential Graph GET per unknown groupId during
|
||||
# Phase 3 — hundreds of single-item round-trips for a CA-heavy tenant.
|
||||
if ($allPoliciesFlat.Count -gt 0) {
|
||||
Sync-BulkExportMigrationGroups -Policies $allPoliciesFlat -TokenId $TokenId
|
||||
}
|
||||
|
||||
# Phase 2.6 — when nested-group export is on, walk the hierarchy in batches
|
||||
# and cache each parent's child-group list (plus any new child bodies) so the
|
||||
# per-policy recursion inside Add-GraphMigrationObject is pure cache hits.
|
||||
# Skipped when ExportNestedGroupLevels = 1 (no recursion happens then).
|
||||
if ($ExportSettings.ExportNestedGroupLevels -gt 1) {
|
||||
Sync-BulkExportNestedGroupHierarchy -MaxDepth $ExportSettings.ExportNestedGroupLevels -TokenId $TokenId
|
||||
}
|
||||
|
||||
# Phase 3 — write to disk (sequential I/O; Get() inside Export-GraphPolicy is a no-op
|
||||
# for non-override types because Phase 2 already pre-populated them). Status updates
|
||||
# per type so the bar doesn't freeze for the duration of the write phase — without
|
||||
# these the user sees the last Phase 2 status message for minutes on end.
|
||||
$totalToWrite = 0
|
||||
foreach ($pt in $targetTypes) { $totalToWrite += $perType[$pt.Id].Count }
|
||||
$writeProgress = 0
|
||||
$typeIndex = 0
|
||||
|
||||
# Once, across EVERY type - not per type inside the loop below. Two policies
|
||||
# collide when they resolve to the same destination path, and the folder is part
|
||||
# of that path: with AddObjectType off every type writes into the export root, so
|
||||
# a per-type pass never compared a Compliance policy against a Configuration
|
||||
# policy of the same name and one silently overwrote the other.
|
||||
Set-BulkExportFilenameCollisionFlag -Policies $allPoliciesFlat `
|
||||
-AddObjectType:($ExportSettings.AddObjectType -eq $true)
|
||||
|
||||
foreach ($pt in $targetTypes) {
|
||||
$typeIndex++
|
||||
$policies = $perType[$pt.Id]
|
||||
if (-not $policies -or $policies.Count -eq 0) {
|
||||
Write-Log "Bulk export: no $($pt.Title) objects matched"
|
||||
continue
|
||||
}
|
||||
Write-Status `
|
||||
-Text ("Bulk export - {0} ({1} of {2})" -f $pt.Title, $typeIndex, $targetTypes.Count) `
|
||||
-Detail ("Writing {0} object(s) · {1} of {2} total" -f $policies.Count, $writeProgress, $totalToWrite) `
|
||||
-SkipLog -Force
|
||||
try {
|
||||
$policies | Export-GraphPolicy -ExportSettings $ExportSettings
|
||||
$totalPolicies += $policies.Count
|
||||
$writeProgress += $policies.Count
|
||||
}
|
||||
catch {
|
||||
$failedTypes++
|
||||
Write-LogError "Bulk export: failed while exporting $($pt.Title)" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
# Flush every MigrationTable.json that Add-GraphMigrationObject mutated. The
|
||||
# in-memory cache batched all the per-policy mutations during Phase 3; this one
|
||||
# call writes each touched file to disk exactly once. Wrapped in try so any
|
||||
# disk error doesn't mask the export-finished status.
|
||||
try { Save-GraphMigrationFilesPending } catch {
|
||||
Write-LogError "Bulk export: failed to flush migration table(s)" $_.Exception
|
||||
}
|
||||
|
||||
}
|
||||
finally {
|
||||
# Clear the per-type-flush guard so future standalone Export-GraphPolicy
|
||||
# calls flush normally in their End block. Runs even on Phase 3 exceptions.
|
||||
$script:_bulkExportDeferMigFlush = $false
|
||||
$script:_skipDirectGet = $false
|
||||
}
|
||||
|
||||
Write-Status $null
|
||||
|
||||
# ---- 4. Persist user-settings (round-trip the form's "remember last used") ----
|
||||
try { $ExportSettings.Save() } catch { }
|
||||
# Persist to the SAME SubPath these keys are registered under ("IntuneManager") so
|
||||
# Get-SettingValue reads them back - a root-path write here was a dead location
|
||||
# (same bug the import side fixed; see IntuneBaseClasses.ps1 ImportSettings.Save).
|
||||
Save-SettingStoreValue "IntuneManager" "AddCompanyName" $ExportSettings.AddCompanyName
|
||||
Save-SettingStoreValue "IntuneManager" "ExportAssignments" $ExportSettings.ExportAssignments
|
||||
Save-SettingStoreValue "IntuneManager" "ExportNestedGroupLevels" $ExportSettings.ExportNestedGroupLevels
|
||||
|
||||
$stopwatch.Stop()
|
||||
$summary = [PSCustomObject]@{
|
||||
Types = $targetTypes.Count
|
||||
Policies = $totalPolicies
|
||||
Failed = $failedTypes
|
||||
UnknownSelectors = $unknownSelectors
|
||||
Duration = $stopwatch.Elapsed
|
||||
}
|
||||
Write-Log ("Bulk export finished. Types={0} Policies={1} Failed={2} Duration={3}" -f `
|
||||
$summary.Types, $summary.Policies, $summary.Failed, $summary.Duration)
|
||||
return $summary
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
function Start-GraphBulkImport {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bulk-import exported Intune policy objects from disk.
|
||||
|
||||
.DESCRIPTION
|
||||
Public, UI-independent driver for bulk import. The WPF and Avalonia
|
||||
bulk-import forms are thin callers of this function; the same function
|
||||
can be invoked from a scheduled task or pipeline.
|
||||
|
||||
Groups are processed in ascending ImportOrder so dependencies (Scope
|
||||
Tags, etc.) import before the objects that reference them.
|
||||
|
||||
.PARAMETER ImportFolder
|
||||
Root folder of a previous export (the folder that contains the
|
||||
per-policy-type sub-folders).
|
||||
|
||||
.PARAMETER Filter
|
||||
Name filter (literal substring, case-insensitive) applied to the file
|
||||
objects before import. Empty = import everything found.
|
||||
|
||||
.PARAMETER PolicyGroup
|
||||
Restrict the import to these PolicyGroup IDs. If omitted, every group
|
||||
that allows Import is processed.
|
||||
|
||||
.PARAMETER ImportAssignments
|
||||
Import object assignments. Persisted to the ImportAssignments setting
|
||||
(the import pipeline reads it from there) when supplied.
|
||||
|
||||
.PARAMETER ImportScopeTags
|
||||
Import scope tags. Persisted to the ImportScopeTags setting when supplied.
|
||||
|
||||
.PARAMETER ReplaceDependencyIDs
|
||||
Translate dependency object IDs via the migration table. Persisted to
|
||||
the ResolveReferenceInfo setting when supplied.
|
||||
|
||||
.PARAMETER ImportType
|
||||
How files are imported: alwaysImport (default), skipIfExist, update,
|
||||
replace, or replace_with_assignments. Persisted to the ImportType
|
||||
setting when supplied. Anything other than alwaysImport resolves each
|
||||
file against the existing objects (Resolve-IntuneImportUpdateTarget)
|
||||
and skips / updates / replaces accordingly.
|
||||
|
||||
.PARAMETER TokenId
|
||||
Authentication token id. Defaults to the current default token.
|
||||
|
||||
.EXAMPLE
|
||||
Start-GraphBulkImport -ImportFolder C:\IntuneExport
|
||||
|
||||
.EXAMPLE
|
||||
Start-GraphBulkImport -ImportFolder C:\IntuneExport -PolicyGroup DeviceConfiguration -Filter "Baseline"
|
||||
|
||||
.OUTPUTS
|
||||
PSCustomObject with summary statistics (Groups, Imported, Duration).
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]
|
||||
$ImportFolder,
|
||||
|
||||
[string]
|
||||
$Filter,
|
||||
|
||||
[string[]]
|
||||
$PolicyGroup,
|
||||
|
||||
[Nullable[bool]]
|
||||
$ImportAssignments,
|
||||
|
||||
[Nullable[bool]]
|
||||
$ImportScopeTags,
|
||||
|
||||
[Nullable[bool]]
|
||||
$ReplaceDependencyIDs,
|
||||
|
||||
[string]
|
||||
$ImportType,
|
||||
|
||||
[Int]
|
||||
$TokenId = (Get-DefaultTokenId)
|
||||
)
|
||||
|
||||
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
$ImportFolder = Expand-FileName $ImportFolder
|
||||
if (-not [IO.Directory]::Exists($ImportFolder)) {
|
||||
throw "Import folder not found: $ImportFolder"
|
||||
}
|
||||
|
||||
# The import pipeline (ImportObject / assignment + scope-tag handling) reads
|
||||
# these via Get-SettingValue, which resolves them from their registered
|
||||
# SubPath ("IntuneManager"). Persist to the SAME SubPath so a supplied
|
||||
# per-run value is actually read back (saving to root "" left them orphaned).
|
||||
if ($null -ne $ImportAssignments) { Save-SettingStoreValue "IntuneManager" "ImportAssignments" $ImportAssignments }
|
||||
if ($null -ne $ImportScopeTags) { Save-SettingStoreValue "IntuneManager" "ImportScopeTags" $ImportScopeTags }
|
||||
if ($null -ne $ReplaceDependencyIDs) { Save-SettingStoreValue "IntuneManager" "ResolveReferenceInfo" $ReplaceDependencyIDs }
|
||||
if ($ImportType) { Save-SettingStoreValue "IntuneManager" "ImportType" $ImportType }
|
||||
|
||||
# ---- Resolve target groups ----
|
||||
$selection = Resolve-IntuneTargetSelectors -PolicyGroup $PolicyGroup -Caller 'Start-GraphBulkImport'
|
||||
$unknownSelectors = $selection.Unknown # ids from -PolicyGroup that matched nothing
|
||||
$targetGroups = [System.Collections.Generic.List[object]]::new()
|
||||
$targetGroups.AddRange([object[]]$selection.Groups)
|
||||
if (-not $PolicyGroup) {
|
||||
foreach ($grp in $script:IntuneGroups) {
|
||||
if (-not $grp.Title) { continue }
|
||||
if ($grp.ShowButtons -is [Object[]] -and $grp.ShowButtons -notcontains "Import") { continue }
|
||||
[void]$targetGroups.Add($grp)
|
||||
}
|
||||
}
|
||||
if ($targetGroups.Count -eq 0) {
|
||||
Write-Log "Bulk import: no policy groups selected" 2
|
||||
return [PSCustomObject]@{ Groups = 0; Imported = 0; UnknownSelectors = $unknownSelectors; Duration = $stopwatch.Elapsed }
|
||||
}
|
||||
|
||||
# Honour the ClearCacheBeforeExportImport setting (bit 4 = bulk import).
|
||||
Invoke-GraphCacheClearBeforeOperation -Operation BulkImport -TokenId $TokenId
|
||||
|
||||
Write-Log "****************************************************************"
|
||||
Write-Log "Start bulk import from $ImportFolder"
|
||||
Write-Log "****************************************************************"
|
||||
|
||||
# Effective import type: parameter wins, then the persisted setting.
|
||||
$importTypeEffective = if ($ImportType) { $ImportType } else { [string](Get-SettingValue "ImportType" "alwaysImport") }
|
||||
if (-not $importTypeEffective) { $importTypeEffective = "alwaysImport" }
|
||||
|
||||
$sameTenant = $false
|
||||
if ($importTypeEffective -ne "alwaysImport") {
|
||||
try {
|
||||
$null, $sameTenant = Get-MigrationTableInfo $ImportFolder (Get-CurrentTenantId)
|
||||
} catch { }
|
||||
}
|
||||
|
||||
$totalImported = 0
|
||||
$totalUpdated = 0
|
||||
$totalReplaced = 0
|
||||
$totalSkipped = 0
|
||||
# Ascending ImportOrder so dependencies import before their dependents.
|
||||
$orderedGroups = @($targetGroups | Sort-Object { ($_.PolicyTypes | Measure-Object ImportOrder -Minimum).Minimum })
|
||||
$groupTotal = $orderedGroups.Count
|
||||
$groupIndex = 0
|
||||
|
||||
foreach ($grp in $orderedGroups) {
|
||||
$groupIndex++
|
||||
$policyTypes = $grp.PolicyTypes
|
||||
$subFolders = @($policyTypes | ForEach-Object { $_.Folder } | Where-Object { $_ })
|
||||
|
||||
Write-Log "----------------------------------------------------------------"
|
||||
Write-Log "Import $($grp.Title)"
|
||||
Write-Log "----------------------------------------------------------------"
|
||||
|
||||
try {
|
||||
Write-Status `
|
||||
-Text ("Bulk import - {0} ({1} of {2})" -f $grp.Title, $groupIndex, $groupTotal) `
|
||||
-Detail "Loading objects from folder" `
|
||||
-Force
|
||||
|
||||
$params = @{ Path = $ImportFolder; PolicyTypes = $policyTypes }
|
||||
if ($subFolders.Count -gt 0) { $params["SubFolders"] = $subFolders }
|
||||
|
||||
$policiesToImport = @(Get-PoliciesFromFolder @params)
|
||||
|
||||
if ($Filter) {
|
||||
$policiesToImport = @($policiesToImport | Where-Object { $_.Name -match [RegEx]::Escape($Filter) })
|
||||
}
|
||||
|
||||
if ($policiesToImport.Count -gt 0) {
|
||||
# Any mode other than alwaysImport resolves each file against
|
||||
# the existing objects first (skip / update / replace).
|
||||
if ($importTypeEffective -ne "alwaysImport") {
|
||||
$existing = @(Get-GraphPolicies -PolicyGroup $grp.ID -TokenId $TokenId)
|
||||
$toCreate = @()
|
||||
|
||||
foreach ($importPolicy in $policiesToImport) {
|
||||
$match = Resolve-IntuneImportUpdateTarget -ImportPolicy $importPolicy -ExistingPolicies $existing -SameTenant $sameTenant -ImportType $importTypeEffective
|
||||
|
||||
switch ($match.Action) {
|
||||
"Update" {
|
||||
try {
|
||||
if ($importPolicy.UpdateObject($match.Target, $TokenId)) { $totalUpdated++ }
|
||||
} catch { Write-LogError "UpdateObject failed for $($importPolicy.Name)" $_.Exception }
|
||||
}
|
||||
"Replace" {
|
||||
try {
|
||||
if (Invoke-IntuneImportReplace -ImportPolicy $importPolicy -Target $match.Target -ImportType $importTypeEffective -TokenId $TokenId) { $totalReplaced++ }
|
||||
} catch { Write-LogError "Replace failed for $($importPolicy.Name)" $_.Exception }
|
||||
}
|
||||
"Ambiguous" {
|
||||
$totalSkipped++
|
||||
Write-Log "Skip import for $($importPolicy.Name): $($match.Message)" 2
|
||||
}
|
||||
"Skip" {
|
||||
$totalSkipped++
|
||||
Write-Log "Skip import for $($importPolicy.Name): $($match.Message)"
|
||||
}
|
||||
default { $toCreate += $importPolicy }
|
||||
}
|
||||
}
|
||||
$policiesToImport = $toCreate
|
||||
}
|
||||
|
||||
if ($policiesToImport.Count -gt 0) {
|
||||
Write-Status -Detail ("Importing {0} object(s)" -f $policiesToImport.Count) -SkipLog -Force
|
||||
$imported = @($policiesToImport | Import-GraphPolicy)
|
||||
$totalImported += $imported.Count
|
||||
Write-Log "Imported $($imported.Count) $($grp.Title) object(s)"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Log "No $($grp.Title) files found in $ImportFolder"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed when importing $($grp.Title)" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
Write-Status $null
|
||||
Write-Log "****************************************************************"
|
||||
Write-Log "Bulk import finished. $totalImported imported, $totalUpdated updated, $totalReplaced replaced, $totalSkipped skipped"
|
||||
Write-Log "****************************************************************"
|
||||
|
||||
return [PSCustomObject]@{
|
||||
Groups = $orderedGroups.Count
|
||||
Imported = $totalImported
|
||||
Updated = $totalUpdated
|
||||
Replaced = $totalReplaced
|
||||
Skipped = $totalSkipped
|
||||
UnknownSelectors = $unknownSelectors
|
||||
Duration = $stopwatch.Elapsed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Choose where settings are read from and written to for the rest of the session.
|
||||
|
||||
.DESCRIPTION
|
||||
Exported as Use-IMSettingsStore.
|
||||
|
||||
Three stores:
|
||||
|
||||
-Memory settings live in this session only. Reads and writes work
|
||||
normally and nothing is read from or written to disk. This is
|
||||
what automation on a shared machine wants: a runbook on a
|
||||
Hybrid Worker must not rewrite that worker's configuration, and
|
||||
must not inherit whatever the last run left behind.
|
||||
-Path <file> a JSON settings file. Created if it does not exist.
|
||||
-Registry HKCU:\Software\IntuneManagement. Windows only.
|
||||
|
||||
The default without calling this is the registry on Windows and a settings file
|
||||
under LocalApplicationData elsewhere. It can also be set before the module is
|
||||
imported, which is the only way to keep the very first log lines from resolving
|
||||
against the machine's store:
|
||||
|
||||
$env:IM_SETTINGS_STORE = 'Memory'
|
||||
$env:IM_SETTINGS_FILE = 'C:\config\intune-settings.json'
|
||||
|
||||
.PARAMETER Memory
|
||||
Use an in-memory store. Nothing is written to disk.
|
||||
|
||||
.PARAMETER Path
|
||||
Use this JSON settings file.
|
||||
|
||||
.PARAMETER Registry
|
||||
Use the Windows registry.
|
||||
|
||||
.PARAMETER Seed
|
||||
Copy the machine's currently persisted settings into the in-memory store first,
|
||||
so the session starts from the real configuration and then diverges without
|
||||
writing back. Without it the store starts empty and every setting resolves to
|
||||
its registered default.
|
||||
|
||||
.PARAMETER PassThru
|
||||
Return the resulting store, as Get-IMSettingsStore would.
|
||||
|
||||
.EXAMPLE
|
||||
Use-IMSettingsStore -Memory -PassThru
|
||||
|
||||
.EXAMPLE
|
||||
Use-IMSettingsStore -Memory
|
||||
Import-IMSettingsStore -Path .\runbook-settings.json
|
||||
|
||||
The full automation pattern: an empty store, then the configuration the run is
|
||||
supposed to use, from a file under source control. Nothing on the worker is read
|
||||
or changed.
|
||||
|
||||
.EXAMPLE
|
||||
Use-IMSettingsStore -Path 'D:\shared\IntuneManagement.json'
|
||||
|
||||
.LINK
|
||||
Get-IMSettingsStore
|
||||
.LINK
|
||||
Import-IMSettingsStore
|
||||
.LINK
|
||||
Export-IMSettingsStore
|
||||
#>
|
||||
function Use-SettingsStore
|
||||
{
|
||||
[CmdletBinding(DefaultParameterSetName = "Memory", SupportsShouldProcess = $true)]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, ParameterSetName = "Memory")]
|
||||
[switch]$Memory,
|
||||
[Parameter(Mandatory = $true, ParameterSetName = "Json", Position = 0)]
|
||||
[string]$Path,
|
||||
[Parameter(Mandatory = $true, ParameterSetName = "Registry")]
|
||||
[switch]$Registry,
|
||||
[switch]$Seed,
|
||||
[switch]$PassThru
|
||||
)
|
||||
|
||||
$mode = $PSCmdlet.ParameterSetName
|
||||
|
||||
if(-not $PSCmdlet.ShouldProcess("the settings store", "Switch to the $mode store")) { return }
|
||||
|
||||
Set-SettingsStoreMode -Mode $mode -Path $Path -Seed:$Seed
|
||||
|
||||
if($PassThru) { Get-SettingsStoreInfo }
|
||||
}
|
||||
Reference in New Issue
Block a user