IntuneManagement 4.0.0-beta1

This commit is contained in:
Mikael Karlsson
2026-09-23 19:13:09 +10:00
commit 7869619510
892 changed files with 577109 additions and 0 deletions
+102
View File
@@ -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
}
}
}
}
}
}
+415
View File
@@ -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
}
+106
View File
@@ -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
}
}
+143
View File
@@ -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
}
}
}
}
+44
View File
@@ -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
}
+90
View File
@@ -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)
}
+62
View File
@@ -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)
}
+16
View File
@@ -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
}
+102
View File
@@ -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
}
}
}
+151
View File
@@ -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 }
}
}
}
+357
View File
@@ -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()
}
+57
View File
@@ -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
}
}
}
+123
View File
@@ -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 }
}
}
}
+71
View File
@@ -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
}
}
}
}
+50
View File
@@ -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
}
}
+127
View File
@@ -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
}
}
+53
View File
@@ -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
}
+576
View File
@@ -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
}
}
+54
View File
@@ -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
}
}
+69
View File
@@ -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
}
+40
View File
@@ -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"
}
+524
View File
@@ -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
}
}
+338
View File
@@ -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
}
}
+110
View File
@@ -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
}
+164
View File
@@ -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
}
}
+113
View File
@@ -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
}
}
+195
View File
@@ -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
}
}
}
+372
View File
@@ -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
}
+233
View File
@@ -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
}
}
+86
View File
@@ -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 }
}